diff --git a/.editorconfig b/.editorconfig index f42572b..af8f708 100644 --- a/.editorconfig +++ b/.editorconfig @@ -19,6 +19,9 @@ dotnet_diagnostic.IDE0045.severity = suggestion # IDE0301: Simplify collection initialization dotnet_diagnostic.IDE0301.severity = suggestion +# IDE0305: Simplify collection initialization +dotnet_diagnostic.IDE0305.severity = suggestion + [{*Test}/**.cs] #Inline variable declaration (IDE0018) dotnet_diagnostic.IDE0018.severity = none diff --git a/DocxTemplater.Images/ImageFormatter.cs b/DocxTemplater.Images/ImageFormatter.cs index bc73e8d..aebce52 100644 --- a/DocxTemplater.Images/ImageFormatter.cs +++ b/DocxTemplater.Images/ImageFormatter.cs @@ -1,12 +1,12 @@ -using System; -using System.IO; -using System.Linq; -using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using DocxTemplater.Formatter; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Metadata; +using System; +using System.IO; +using System.Linq; using A = DocumentFormat.OpenXml.Drawing; using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; using PIC = DocumentFormat.OpenXml.Drawing.Pictures; // http://schemas.openxmlformats.org/drawingml/2006/picture" @@ -39,37 +39,37 @@ public void ApplyFormat(FormatterContext context, Text target) using var image = Image.Load(imageBytes); var imagePartType = DetectPartTypeInfo(context.Placeholder, image.Metadata); var root = target.GetRoot(); - string impagepartRelationShipId = null; + string imagePartRelId = null; uint maxPropertyId = 0; if (root is OpenXmlPartRootElement openXmlPartRootElement && openXmlPartRootElement.OpenXmlPart != null) { maxPropertyId = openXmlPartRootElement.OpenXmlPart.GetMaxDocPropertyId(); if (openXmlPartRootElement.OpenXmlPart is HeaderPart headerPart) { - impagepartRelationShipId = CreateImagePart(headerPart, imageBytes, imagePartType); + imagePartRelId = CreateImagePart(headerPart, imageBytes, imagePartType); } else if (openXmlPartRootElement.OpenXmlPart is FooterPart footerPart) { - impagepartRelationShipId = CreateImagePart(footerPart, imageBytes, imagePartType); + imagePartRelId = CreateImagePart(footerPart, imageBytes, imagePartType); } else if (openXmlPartRootElement.OpenXmlPart is MainDocumentPart mainDocumentPart) { - impagepartRelationShipId = CreateImagePart(mainDocumentPart, imageBytes, imagePartType); + imagePartRelId = CreateImagePart(mainDocumentPart, imageBytes, imagePartType); } } - if (impagepartRelationShipId == null) + if (imagePartRelId == null) { throw new OpenXmlTemplateException("Could not find a valid image part"); } // case 1. Image ist the only child element of a (TextBox) - if (TryHandleImageInWordprocessingShape(target, impagepartRelationShipId, image, context.Args.FirstOrDefault() ?? string.Empty, maxPropertyId)) + if (TryHandleImageInWordprocessingShape(target, imagePartRelId, image, context.Args.FirstOrDefault() ?? string.Empty, maxPropertyId)) { return; } - AddInlineGraphicToRun(target, impagepartRelationShipId, image, maxPropertyId); + AddInlineGraphicToRun(target, imagePartRelId, image, maxPropertyId); } catch (Exception e) when (e is InvalidImageContentException or UnknownImageFormatException) { @@ -103,13 +103,8 @@ private static bool TryHandleImageInWordprocessingShape(Text target, string impa return false; } - var anchor = target.GetFirstAncestor(); - if (anchor == null) - { - return false; - } - - var targetExtent = anchor.GetFirstChild(); + // get extent of the drawing either from the anchor or inline + var targetExtent = target.GetFirstAncestor()?.GetFirstChild() ?? target.GetFirstAncestor()?.GetFirstChild(); if (targetExtent != null) { double scale = 0; @@ -142,29 +137,48 @@ private static bool TryHandleImageInWordprocessingShape(Text target, string impa } - private static void ReplaceAnchorContentWithPicture(string impagepartRelationShipId, uint maxDocumentPropertyId, Drawing original) + private static void ReplaceAnchorContentWithPicture(string impagepartRelationShipId, uint maxDocumentPropertyId, + Drawing original) { var propertyId = maxDocumentPropertyId + 1; - var originalAnchor = original.GetFirstChild(); - var originaleExtent = originalAnchor.GetFirstChild(); + var inlineOrAnchor = (OpenXmlElement)original.GetFirstChild() ?? + (OpenXmlElement)original.GetFirstChild(); + var originaleExtent = inlineOrAnchor.GetFirstChild(); - var horzPosition = originalAnchor.GetFirstChild().CloneNode(true); - var vertPosition = originalAnchor.GetFirstChild().CloneNode(true); + var clonedInlineOrAnchor = inlineOrAnchor.CloneNode(false); - var anchorChildElments = new OpenXmlElement[] + if (inlineOrAnchor is DW.Anchor anchor) { - new DW.SimplePosition {X = 0L, Y = 0L}, - horzPosition, - vertPosition, - new DW.Extent {Cx = originaleExtent.Cx, Cy = originaleExtent.Cy}, - new DW.EffectExtent + clonedInlineOrAnchor.Append(new DW.SimplePosition { X = 0L, Y = 0L }); + var horzPosition = anchor.GetFirstChild().CloneNode(true); + var vertPosition = inlineOrAnchor.GetFirstChild().CloneNode(true); + clonedInlineOrAnchor.Append(horzPosition); + clonedInlineOrAnchor.Append(vertPosition); + clonedInlineOrAnchor.Append(new DW.Extent { Cx = originaleExtent.Cx, Cy = originaleExtent.Cy }); + clonedInlineOrAnchor.Append(new DW.EffectExtent { LeftEdge = 0L, TopEdge = 0L, RightEdge = 0L, BottomEdge = 0L - }, - new DW.WrapNone(), + }); + clonedInlineOrAnchor.Append(new DW.WrapNone()); + } + else if (inlineOrAnchor is DW.Inline) + { + clonedInlineOrAnchor.Append(new DW.Extent { Cx = originaleExtent.Cx, Cy = originaleExtent.Cy }); + clonedInlineOrAnchor.Append(new DW.EffectExtent + { + LeftEdge = 0L, + TopEdge = 0L, + RightEdge = 0L, + BottomEdge = 0L + }); + } + + clonedInlineOrAnchor.Append(new OpenXmlElement[] + { + new DW.DocProperties { Id = propertyId, @@ -177,11 +191,8 @@ private static void ReplaceAnchorContentWithPicture(string impagepartRelationShi CreatePicture(impagepartRelationShipId, propertyId, originaleExtent.Cx, originaleExtent.Cy) ) {Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture"}) - }; - - var anchor = originalAnchor.CloneNode(false); - anchor.Append(anchorChildElments); - var dw = new Drawing(anchor); + }); + var dw = new Drawing(clonedInlineOrAnchor); original.InsertAfterSelf(dw); original.Remove(); } diff --git a/DocxTemplater.Test/ComplexTemplateTest.cs b/DocxTemplater.Test/ComplexTemplateTest.cs new file mode 100644 index 0000000..e4b9df0 --- /dev/null +++ b/DocxTemplater.Test/ComplexTemplateTest.cs @@ -0,0 +1,128 @@ +using DocxTemplater.Images; + +namespace DocxTemplater.Test +{ + internal class ComplexTemplateTest + { + [Test] + public void ProcessComplexTemplate() + { + + var imageBytes = File.ReadAllBytes("Resources/testImage.jpg"); + using var fileStream = File.OpenRead("Resources/ComplexTemplate.docx"); + var docTemplate = new DocxTemplate(fileStream); + docTemplate.RegisterFormatter(new ImageFormatter()); + + var model = CreateModel(imageBytes); + docTemplate.BindModel("ds", model); + + var result = docTemplate.Process(); + docTemplate.Validate(); + result.Position = 0; + result.SaveAsFileAndOpenInWord(); + } + + private object CreateModel(byte[] imageBytes) + { + var items = new List + { + new() + { + IsHw = true, + Name = "Item 1", + HardwareRevisions = new List + { + new() {IsMajor = true, Version = "1.0"}, + new() {IsMajor = false, Version = "1.1"}, + new() {IsMajor = false, Version = "1.2"}, + new() {IsMajor = false, Version = "1.3"}, + } + }, + new() + { + IsHw = true, + Name = "Item 2", + SoftwareVersions = new List + { + new() {IsMajor = true, Version = "1.0"}, + new() {IsMajor = false, Version = "1.1"}, + new() {IsMajor = false, Version = "1.2"}, + new() {IsMajor = false, Version = "1.3"}, + } + }, + new() + { + IsHw = false, + Name = "Item 3", + SoftwareVersions = new List + { + new() {IsMajor = true, Version = "1.0"}, + new() {IsMajor = false, Version = "1.1"}, + new() {IsMajor = false, Version = "1.2"}, + new() {IsMajor = false, Version = "1.3"}, + } + }, + new() + { + IsHw = false, + Name = "Item 4", + SoftwareVersions = new List + { + new() {IsMajor = true, Version = "42.0"}, + }, + HardwareRevisions = new List + { + new() {IsMajor = true, Version = "1.0"}, + new() {IsMajor = false, Version = "1.1"}, + new() {IsMajor = false, Version = "1.2"}, + new() {IsMajor = false, Version = "1.3"}, + } + } + }; + + var images = new List + { + imageBytes, + imageBytes, + imageBytes + }; + + return new ComplexTemplateModel + { + Items = items, + Images = images + }; + } + + private class ComplexTemplateModel + { + public IReadOnlyCollection Items { get; set; } + + public IReadOnlyCollection Images { get; set; } + } + + private class WarehouseItem + { + public bool IsHw { get; set; } + + public string Name { get; set; } + + public IReadOnlyCollection HardwareRevisions { get; set; } + + public IReadOnlyCollection SoftwareVersions { get; set; } + + } + + private class VersionInfo + { + public bool IsMajor { get; set; } + + public string Version { get; set; } + + public override string ToString() + { + return IsMajor ? $"Major Version: {Version}" : $"Minor Version: {Version}"; + } + } + } +} diff --git a/DocxTemplater.Test/DocxTemplateTest.cs b/DocxTemplater.Test/DocxTemplateTest.cs index 6923e99..6c8a6d6 100644 --- a/DocxTemplater.Test/DocxTemplateTest.cs +++ b/DocxTemplater.Test/DocxTemplateTest.cs @@ -43,6 +43,24 @@ public void DynamicTable() Assert.That(rows[4].InnerText, Is.EqualTo("Value7Value8Value9")); } + [Test] + public void EmptyDynamicTable() + { + using var fileStream = File.OpenRead("Resources/DynamicTable.docx"); + var docTemplate = new DocxTemplate(fileStream); + var tableModel = new DynamicTable(); + 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().FirstOrDefault(); + Assert.That(table, Is.Null); + } + /// /// 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. @@ -205,6 +223,53 @@ public void InsertHtmlInLoop() Assert.That(altChunks.Count, Is.EqualTo(2)); } + [Test] + public void ConditionalBlockInLoop() + { + var content = "{{#Educations}}{?{.HasTeacher}}{{.ChecklistName}}{{:}}noTeacher {{.ChecklistName}}{{/}}{{:s:}}, {{/Educations}}"; + 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(content))))); + wpDocument.Save(); + memStream.Position = 0; + var docTemplate = new DocxTemplate(memStream); + docTemplate.BindModel("Educations", new[] + { + new { HasTeacher = true, ChecklistName = "ChecklistName1" }, + new { HasTeacher = false, ChecklistName = "ChecklistName2" }, + new { HasTeacher = true, ChecklistName = "ChecklistName3" } + }); + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.IsNotNull(result); + // validate content + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + Assert.That(body.InnerText, Is.EqualTo("ChecklistName1, noTeacher ChecklistName2, ChecklistName3")); + } + + [Test] + public void NullValueHandlingForNesteObjects() + { + 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{{ds.Model.Outer.BillName}}"))))); + wpDocument.Save(); + memStream.Position = 0; + var docTemplate = new DocxTemplate(memStream); + docTemplate.Settings.BindingErrorHandling = BindingErrorHandling.SkipBindingAndRemoveContent; + docTemplate.BindModel("ds", new { Model = new { Outer = (LessonReportModel)null } }); + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.IsNotNull(result); + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + //check values have been replaced + Assert.That(body.InnerText, Is.EqualTo("Test")); + } + [Test] public void MissingVariableWithSkipErrorHandling() { @@ -274,6 +339,7 @@ public void CollectionSeparatorTest() Assert.That(body.InnerText, Is.EqualTo("Item1,Item2,Item3")); } + [Test] public void ConditionsWithAndWithoutPrefix() { @@ -285,8 +351,9 @@ public void ConditionsWithAndWithoutPrefix() 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{{/}}"))) - )); + new Paragraph(new Run(new Text("{?{!ds4.MyBool}}Test5{{:}}else4{{/}}"))), + new Paragraph(new Run(new Text("{?{!ds3.MyBool}}NoElse{{/}}"))) + )); wpDocument.Save(); memStream.Position = 0; var docTemplate = new DocxTemplate(memStream); diff --git a/DocxTemplater.Test/DocxTemplater.Test.csproj b/DocxTemplater.Test/DocxTemplater.Test.csproj index 82ec6e2..b29bacb 100644 --- a/DocxTemplater.Test/DocxTemplater.Test.csproj +++ b/DocxTemplater.Test/DocxTemplater.Test.csproj @@ -10,6 +10,7 @@ None + @@ -27,6 +28,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest @@ -36,6 +40,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/DocxTemplater.Test/MultipleRowsBoundToCollectionTest.cs b/DocxTemplater.Test/MultipleRowsBoundToCollectionTest.cs new file mode 100644 index 0000000..e3fee6c --- /dev/null +++ b/DocxTemplater.Test/MultipleRowsBoundToCollectionTest.cs @@ -0,0 +1,107 @@ +using AutoBogus; +using DocxTemplater.Images; + +namespace DocxTemplater.Test +{ + internal class MultipleRowsBoundToCollectionTest + { + [Test] + public void ProcessComplexTemplate() + { + + using var fileStream = File.OpenRead("Resources/MultipleRowsBoundToCollection.docx"); + var docTemplate = new DocxTemplate(fileStream); + docTemplate.RegisterFormatter(new ImageFormatter()); + + var timeReportFaker = new AutoFaker(); + var activityFaker = new AutoFaker() + .RuleFor(a => a.Duration, f => TimeSpan.FromHours(f.Random.Double(0, 4))); + var activityTotalFaker = new AutoFaker(); + var model = new DummyModel + { + FromDate = DateTime.Now.AddDays(-7), + ToDate = DateTime.Now, + NameFirst = "John", + NameFamily = "Doe", + Reports = timeReportFaker.Generate(7).ToList(), + ActivityTotals = activityTotalFaker.Generate(3).ToList() + }; + foreach (var report in model.Reports) + { + report.Activities = activityFaker.Generate(3).ToList(); + } + docTemplate.BindModel("ds", model); + docTemplate.BindModel("rs", new StringLocalizerDummyModel()); + + var result = docTemplate.Process(); + docTemplate.Validate(); + result.Position = 0; + result.SaveAsFileAndOpenInWord(); + } + + public class StringLocalizerDummyModel : ITemplateModel + { + public bool TryGetPropertyValue(string propertyName, out object value) + { + value = propertyName; + return true; + } + } + + private class DummyModel + { + public DateTime FromDate { get; set; } + + public DateTime ToDate { get; set; } + + public string NameFirst { get; set; } + + public string NameFamily { get; set; } + + public List Reports { get; set; } = new(); + + public double TotalLessons => Reports.Sum(r => r.TotalLessons); + + public double TotalHours => Reports.Sum(r => r.TotalHours); + + public List ActivityTotals + { + get; + set; + } = new(); + } + + private class TimeReportDate + { + public DateTime Date { get; set; } + + public List Activities { get; set; } = new(); + + public double TotalLessons => Activities.Sum(a => a.Lessons); + + public double TotalHours => Activities.DefaultIfEmpty().Sum(a => a.Duration.TotalHours); + } + + private class TimeReportActivityTotal + { + public string Activity { get; set; } + + public double TotalLessons { get; set; } + + public double TotalHours { get; set; } + } + + private class TimeReportActivity + { + public DateTime DateAndTime { get; set; } + + public double Lessons { get; set; } + + public TimeSpan Duration { get; set; } + + public string Description { get; set; } + + public string Activity { get; set; } + } + } +} diff --git a/DocxTemplater.Test/PatternMatcherTest.cs b/DocxTemplater.Test/PatternMatcherTest.cs index 978e9ba..c289bca 100644 --- a/DocxTemplater.Test/PatternMatcherTest.cs +++ b/DocxTemplater.Test/PatternMatcherTest.cs @@ -23,11 +23,11 @@ static IEnumerable TestPatternMatch_Cases() { yield return new TestCaseData("{{Foo}}").Returns(new[] { PatternType.Variable }); yield return new TestCaseData("{{Foo}:blupp()}").Returns(new[] { PatternType.Variable }); - - 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("{{#..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"); @@ -41,6 +41,8 @@ static IEnumerable TestPatternMatch_Cases() 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('HH : mm : s')}").Returns(new[] { PatternType.Variable }).SetName("Format with date pattern"); + yield return new TestCaseData("{{ds.foo.var}:f(HH:mm)}").Returns(new[] { PatternType.Variable }).SetName("Format with date pattern"); 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 }); @@ -50,8 +52,7 @@ static IEnumerable TestPatternMatch_Cases() 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("{{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( @@ -86,6 +87,15 @@ static IEnumerable PatternMatcherArgumentParsingTest_Cases() yield return new TestCaseData("{{Foo}:format(a,'a b',c)}").Returns(new[] { "a", "a b", "c" }); 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" }); + yield return new TestCaseData("{{Foo}:f(a,'HH:mm',c)}").Returns(new[] { "a", "HH:mm", "c" }); + yield return new TestCaseData("{{Foo}:F(yyyy MM dd - HH mm ss)}").Returns(new[] { "yyyy MM dd - HH mm ss" }); + yield return new TestCaseData("{{Foo}:f(HH:mm,'HH:mm','HH : mm : ss')}").Returns(new[] { "HH:mm", "HH:mm", "HH : mm : ss" }); + yield return new TestCaseData("{{Foo}:f('comma in , argument', foo)}").Returns(new[] { "comma in , argument", "foo" }); + yield return new TestCaseData("{{Foo}:f(',', ',,')}").Returns(new[] { ",", ",," }); + yield return new TestCaseData("{{Foo}:f(' whitespacequoted ', white space not quoted )}").Returns(new[] { " whitespacequoted ", "white space not quoted " }); + yield return new TestCaseData("{{Foo}:f('foo', 'this is \\'quoted\\' end')}").Returns(new[] { "foo", "this is 'quoted' end" }); + yield return new TestCaseData("{{Foo}:f('äöü', 'Foo \"blubb\" Test', \")}").Returns(new[] { "äöü", "Foo \"blubb\" Test", "\"" }); + } [TestCase("Some TExt {{ Variable }} Some other text", ExpectedResult = "Variable")] diff --git a/DocxTemplater.Test/Resources/ComplexTemplate.docx b/DocxTemplater.Test/Resources/ComplexTemplate.docx new file mode 100644 index 0000000..272c490 Binary files /dev/null and b/DocxTemplater.Test/Resources/ComplexTemplate.docx differ diff --git a/DocxTemplater.Test/Resources/ImageFormatterTest.docx b/DocxTemplater.Test/Resources/ImageFormatterTest.docx index 212b462..1c433e3 100644 Binary files a/DocxTemplater.Test/Resources/ImageFormatterTest.docx and b/DocxTemplater.Test/Resources/ImageFormatterTest.docx differ diff --git a/DocxTemplater.Test/Resources/MultipleRowsBoundToCollection.docx b/DocxTemplater.Test/Resources/MultipleRowsBoundToCollection.docx new file mode 100644 index 0000000..c201e6a Binary files /dev/null and b/DocxTemplater.Test/Resources/MultipleRowsBoundToCollection.docx differ diff --git a/DocxTemplater.Test/ScriptCompilerTest.cs b/DocxTemplater.Test/ScriptCompilerTest.cs index fc039a5..f4ef9e6 100644 --- a/DocxTemplater.Test/ScriptCompilerTest.cs +++ b/DocxTemplater.Test/ScriptCompilerTest.cs @@ -9,7 +9,7 @@ internal class ScriptCompilerTest public void Setup() { m_modelDictionary = new ModelLookup(); - m_scriptCompiler = new ScriptCompiler(m_modelDictionary); + m_scriptCompiler = new ScriptCompiler(m_modelDictionary, null); } [Test] diff --git a/DocxTemplater/Blocks/ConditionalBlock.cs b/DocxTemplater/Blocks/ConditionalBlock.cs index a96c1e2..809169c 100644 --- a/DocxTemplater/Blocks/ConditionalBlock.cs +++ b/DocxTemplater/Blocks/ConditionalBlock.cs @@ -1,7 +1,5 @@ using DocumentFormat.OpenXml; using DocxTemplater.Formatter; -using System.Collections.Generic; -using System.Linq; namespace DocxTemplater.Blocks { @@ -9,7 +7,7 @@ internal class ConditionalBlock : ContentBlock { private readonly string m_condition; private readonly ScriptCompiler m_scriptCompiler; - private IReadOnlyCollection m_elseContent; + private ContentBlock m_elseBlock; public ConditionalBlock(string condition, VariableReplacer variableReplacer, ScriptCompiler scriptCompiler) : base(variableReplacer) @@ -20,29 +18,36 @@ public ConditionalBlock(string condition, VariableReplacer variableReplacer, Scr 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) + bool conditionResult = false; + bool removeBlock = true; + try { - var cloned = content.Select(x => x.CloneNode(true)).ToList(); - InsertContent(parentNode, cloned); - m_variableReplacer.ReplaceVariables(cloned); - ExpandChildBlocks(models, parentNode); + conditionResult = m_scriptCompiler.CompileScript(m_condition)(); } - var element = m_leadingPart.GetElement(parentNode); - element.Remove(); - } - - public override void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection blockContent) - { - if (m_leadingPart == null) + catch (OpenXmlTemplateException) when (m_scriptCompiler.ProcessSettings.BindingErrorHandling != BindingErrorHandling.ThrowException) { - base.SetContent(leadingPart, blockContent); + removeBlock = false; + } + if (conditionResult) + { + base.Expand(models, parentNode); } else { - m_elseContent = blockContent; + m_elseBlock?.Expand(models, parentNode); + } + + if (removeBlock) + { + var element = m_insertionPoint.GetElement(parentNode); + element.Remove(); } + + } + + public void SetElseBlock(ContentBlock elseBlock) + { + m_elseBlock = elseBlock; } public override string ToString() diff --git a/DocxTemplater/Blocks/ContentBlock.cs b/DocxTemplater/Blocks/ContentBlock.cs index b984634..3c13830 100644 --- a/DocxTemplater/Blocks/ContentBlock.cs +++ b/DocxTemplater/Blocks/ContentBlock.cs @@ -8,14 +8,15 @@ namespace DocxTemplater.Blocks { internal class ContentBlock { - protected InsertionPoint m_leadingPart; + protected InsertionPoint m_insertionPoint; protected IReadOnlyCollection m_content; protected readonly List m_childBlocks; protected readonly VariableReplacer m_variableReplacer; - public ContentBlock(VariableReplacer variableReplacer) + public ContentBlock(VariableReplacer variableReplacer, ContentBlock rootBlock = null, InsertionPoint insertionPoint = null) { - m_leadingPart = null; + m_insertionPoint = insertionPoint; + RootBlock = rootBlock ?? this; m_content = new List(); m_childBlocks = new List(); m_variableReplacer = variableReplacer; @@ -23,6 +24,11 @@ public ContentBlock(VariableReplacer variableReplacer) public IReadOnlyCollection ChildBlocks => m_childBlocks; + public ContentBlock RootBlock + { + get; + } + public virtual void Expand(ModelLookup models, OpenXmlElement parentNode) { var cloned = m_content.Select(x => x.CloneNode(true)).ToList(); @@ -41,23 +47,23 @@ protected void ExpandChildBlocks(ModelLookup models, OpenXmlElement parentNode) protected void InsertContent(OpenXmlElement parentNode, IEnumerable paragraphs) { - var element = m_leadingPart.GetElement(parentNode); + var element = m_insertionPoint.GetElement(parentNode); if (element == null) { Console.WriteLine(parentNode.ToPrettyPrintXml()); - throw new OpenXmlTemplateException($"Insertion point {m_leadingPart.Id} not found"); + throw new OpenXmlTemplateException($"Insertion point {m_insertionPoint.Id} not found"); } element.InsertAfterSelf(paragraphs); } public override string ToString() { - return m_leadingPart?.Id ?? "RootBlock"; + return m_insertionPoint?.Id ?? "RootBlock"; } - public virtual void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection blockContent) + public virtual void SetContent(InsertionPoint insertionPoint, IReadOnlyCollection blockContent) { - m_leadingPart = InsertionPoint.CreateForElement(leadingPart); + m_insertionPoint ??= insertionPoint; m_content = blockContent; } diff --git a/DocxTemplater/Blocks/DynamicTableBlock.cs b/DocxTemplater/Blocks/DynamicTableBlock.cs index bf7bcc9..3fbd697 100644 --- a/DocxTemplater/Blocks/DynamicTableBlock.cs +++ b/DocxTemplater/Blocks/DynamicTableBlock.cs @@ -21,6 +21,10 @@ public override void Expand(ModelLookup models, OpenXmlElement parentNode) var model = models.GetValue(m_tablenName); if (model is IDynamicTable dynamicTable) { + if (!dynamicTable.Headers.Any()) + { + return; + } var headersName = $"{m_tablenName}.{nameof(IDynamicTable.Headers)}"; var columnsName = $"{m_tablenName}.{nameof(IDynamicTable.Rows)}"; @@ -74,7 +78,7 @@ public override void Expand(ModelLookup models, OpenXmlElement parentNode) dataCell.Remove(); // ensure all rows have the same number of cells - var maxCells = dynamicTable.Rows.Max(r => r.Count()); + var maxCells = dynamicTable.Rows.DefaultIfEmpty().Max(r => r?.Count() ?? 0); foreach (var row in table.Elements()) { var cells = row.Elements().ToList(); @@ -94,14 +98,14 @@ public override void Expand(ModelLookup models, OpenXmlElement parentNode) } } - public override void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection blockContent) + public override void SetContent(InsertionPoint insertionPoint, 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); + base.SetContent(insertionPoint, tables); } public override string ToString() diff --git a/DocxTemplater/Blocks/LoopBlock.cs b/DocxTemplater/Blocks/LoopBlock.cs index e7a19a9..baf64c2 100644 --- a/DocxTemplater/Blocks/LoopBlock.cs +++ b/DocxTemplater/Blocks/LoopBlock.cs @@ -9,7 +9,7 @@ namespace DocxTemplater.Blocks internal class LoopBlock : ContentBlock { private readonly string m_collectionName; - private IReadOnlyCollection m_separatorBlock; + private ContentBlock m_separatorBlock; public LoopBlock(string collectionName, VariableReplacer variableReplacer) : base(variableReplacer) @@ -35,30 +35,19 @@ public override void Expand(ModelLookup models, OpenXmlElement parentNode) 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); + m_separatorBlock.Expand(models, parentNode); } } } - else + else if (model != null) { - throw new OpenXmlTemplateException($"Value of {m_collectionName} is not enumerable"); + throw new OpenXmlTemplateException($"Value of {m_collectionName} is not enumerable - it is of type {model.GetType().FullName}"); } } - public override void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection blockContent) + public void SetSeparatorBlock(ContentBlock separatorBlock) { - if (m_leadingPart == null) - { - base.SetContent(leadingPart, blockContent); - } - else - { - m_separatorBlock = blockContent; - leadingPart.RemoveWithEmptyParent(); - } + m_separatorBlock = separatorBlock; } public override string ToString() diff --git a/DocxTemplater/DocxTemplate.cs b/DocxTemplater/DocxTemplate.cs index 7ab3147..a067d9b 100644 --- a/DocxTemplater/DocxTemplate.cs +++ b/DocxTemplater/DocxTemplate.cs @@ -8,7 +8,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using ContentBlock = DocxTemplater.Blocks.ContentBlock; +using DocumentFormat.OpenXml.Drawing.Wordprocessing; namespace DocxTemplater { @@ -43,7 +43,7 @@ public DocxTemplate(Stream docXStream, ProcessSettings settings = null) m_wpDocument = WordprocessingDocument.Open(m_stream, true, openSettings); m_models = new ModelLookup(); - m_scriptCompiler = new ScriptCompiler(m_models); + m_scriptCompiler = new ScriptCompiler(m_models, Settings); m_variableReplacer = new VariableReplacer(m_models, Settings); Processed = false; } @@ -182,7 +182,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.CollectionStart or PatternType.CollectionEnd or PatternType.ConditionEnd or PatternType.ConditionElse) + if (value is not PatternType.Variable) { var parent = markedText.Parent; markedText.RemoveWithEmptyParent(); @@ -198,22 +198,38 @@ private static void Cleanup(OpenXmlCompositeElement element) } // make all Bookmark ids unique - int id = 0; + uint id = 0; foreach (var bookmarkStart in element.Descendants()) { bookmarkStart.Id = $"{id++}"; bookmarkStart.NextSibling().Id = bookmarkStart.Id; } + + id = 1; + var dockProperties = element.Descendants().ToList(); + var existingIds = new HashSet(dockProperties.Select(x => x.Id.Value).ToList()); + foreach (var docPropertiesWithSameId in dockProperties.GroupBy(x => x.Id).Where(x => x.Count() > 1)) + { + foreach (var docProperties in docPropertiesWithSameId.Skip(1)) + { + while (existingIds.Contains(id)) + { + id++; + } + docProperties.Id = id; + existingIds.Add(id); + } + } } private IReadOnlyCollection ExpandLoops(OpenXmlPartRootElement element) { // TODO: store metadata for tag in cache - var blockStack = new Stack<(ContentBlock Block, PatternMatch Match, Text MatchedTextNode)>(); - blockStack.Push((new ContentBlock(m_variableReplacer), null, null)); // dummy block for root + var blockStack = new Stack<(ContentBlock Block, PatternType type, Text MatchedTextNode)>(); + blockStack.Push((new ContentBlock(m_variableReplacer), PatternType.None, null)); // dummy block for root // find all begin or end markers - foreach (var text in element.Descendants().Where(x => x.IsMarked())) + foreach (var text in element.Descendants().ToList().Where(x => x.IsMarked())) { var value = text.GetMarker(); if (value is PatternType.CollectionStart) @@ -221,63 +237,76 @@ private IReadOnlyCollection ExpandLoops(OpenXmlPartRootElement ele var match = PatternMatcher.FindSyntaxPatterns(text.Text).Single(); if (match.Formatter.Equals("dyntable", StringComparison.InvariantCultureIgnoreCase)) { - blockStack.Push((new DynamicTableBlock(match.Variable, m_variableReplacer), match, text)); + blockStack.Push((new DynamicTableBlock(match.Variable, m_variableReplacer), value, text)); } else { - blockStack.Push((new LoopBlock(match.Variable, m_variableReplacer), match, text)); + blockStack.Push((new LoopBlock(match.Variable, m_variableReplacer), value, text)); } } else if (value == PatternType.CollectionSeparator) { - var (block, patternMatch, matchedTextNode) = blockStack.Pop(); - if (block is not LoopBlock) + var (block, _, matchedTextNode) = blockStack.Pop(); + if (block is not LoopBlock collectionStartBlock) { 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 + var insertPoint = InsertionPoint.CreateForElement(leadingPart); + collectionStartBlock.SetContent(insertPoint, loopContent); + var separatorBlock = new ContentBlock(m_variableReplacer, collectionStartBlock, insertPoint); + collectionStartBlock.SetSeparatorBlock(separatorBlock); + blockStack.Push((separatorBlock, value, text)); } else if (value == PatternType.CollectionEnd) { - var (block, patternMatch, matchedTextNode) = blockStack.Pop(); - if (patternMatch.Type != PatternType.CollectionStart) + var (block, startType, matchedTextNode) = blockStack.Pop(); + if (startType is not PatternType.CollectionStart and not PatternType.CollectionSeparator) { - throw new OpenXmlTemplateException($"'{block}' is not closed"); + throw new OpenXmlTemplateException($"'{text.InnerText}' is mission collection start: {text.ElementBeforeInDocument()?.InnerText} >> {text.InnerText} << {text.ElementAfterInDocument()?.InnerText}"); } var loopContent = ExtractBlockContent(matchedTextNode, text, out var leadingPart); - block.SetContent(leadingPart, loopContent); - blockStack.Peek().Block.AddInnerBlock(block); + block.SetContent(InsertionPoint.CreateForElement(leadingPart), loopContent); + blockStack.Peek().Block.AddInnerBlock(block.RootBlock); } else if (value == PatternType.Condition) { var match = PatternMatcher.FindSyntaxPatterns(text.Text).Single(); - blockStack.Push((new ConditionalBlock(match.Condition, m_variableReplacer, m_scriptCompiler), match, text)); + blockStack.Push((new ConditionalBlock(match.Condition, m_variableReplacer, m_scriptCompiler), value, text)); } else if (value == PatternType.ConditionElse) { - var (block, patternMatch, matchedTextNode) = blockStack.Pop(); - if (block is not ConditionalBlock) + var (block, startType, matchedTextNode) = blockStack.Pop(); + if (block is not ConditionalBlock conditionalBlock) { throw new OpenXmlTemplateException($"else block 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 + var insertPoint = InsertionPoint.CreateForElement(leadingPart); + conditionalBlock.SetContent(insertPoint, loopContent); + var elseBlock = new ContentBlock(m_variableReplacer, conditionalBlock, insertPoint); + conditionalBlock.SetElseBlock(elseBlock); + blockStack.Push((elseBlock, value, text)); // push else block on stack but with other text element + } else if (value == PatternType.ConditionEnd) { - var (block, _, matchedTextNode) = blockStack.Pop(); - if (block is not ConditionalBlock) + var (block, startType, matchedTextNode) = blockStack.Pop(); + if (startType is not PatternType.Condition and not PatternType.ConditionElse) { - throw new OpenXmlTemplateException($"'{block}' is not closed"); + throw new OpenXmlTemplateException($"'{text.InnerText}' is mission condition start: {text.ElementBeforeInDocument()?.InnerText} >> {text.InnerText} << {text.ElementAfterInDocument()?.InnerText}"); } var loopContent = ExtractBlockContent(matchedTextNode, text, out var leadingPart); - block.SetContent(leadingPart, loopContent); - blockStack.Peek().Block.AddInnerBlock(block); + var insertPoint = InsertionPoint.CreateForElement(leadingPart); + block.SetContent(insertPoint, loopContent); + blockStack.Peek().Block.AddInnerBlock(block.RootBlock); } } + if (blockStack.Count != 1) + { + var notClosedBlocks = blockStack.Reverse().Select(x => x.Block).Skip(1).ToList(); + throw new OpenXmlTemplateException($"Not all blocks are closed: {string.Join(", ", notClosedBlocks)}"); + } var (contentBlock, _, _) = blockStack.Pop(); return contentBlock.ChildBlocks; } diff --git a/DocxTemplater/DynamicTable.cs b/DocxTemplater/DynamicTable.cs index 02d92d0..0e3456a 100644 --- a/DocxTemplater/DynamicTable.cs +++ b/DocxTemplater/DynamicTable.cs @@ -5,13 +5,15 @@ namespace DocxTemplater { public class DynamicTable : IDynamicTable { + private readonly IEqualityComparer m_headerComparer; private readonly List> m_rows; - public IEnumerable Headers => m_rows.SelectMany(x => x.Keys).Distinct().ToList(); + public IEnumerable Headers => m_rows.SelectMany(x => x.Keys).Distinct(m_headerComparer).ToList(); public IEnumerable> Rows => m_rows.Select(x => x.Values.ToList()).ToList(); - public DynamicTable() + public DynamicTable(IEqualityComparer headerComparer = null) { + m_headerComparer = headerComparer ?? EqualityComparer.Default; m_rows = new List>(); } diff --git a/DocxTemplater/Formatter/FormatPatternFormatter.cs b/DocxTemplater/Formatter/FormatPatternFormatter.cs index dd8920b..31970c4 100644 --- a/DocxTemplater/Formatter/FormatPatternFormatter.cs +++ b/DocxTemplater/Formatter/FormatPatternFormatter.cs @@ -7,28 +7,41 @@ internal class FormatPatternFormatter : IFormatter { public bool CanHandle(Type type, string prefix) { - if (prefix.Equals("FORMAT", StringComparison.CurrentCultureIgnoreCase) || prefix.Equals("F", StringComparison.CurrentCultureIgnoreCase)) + if (prefix.Equals("FORMAT", StringComparison.CurrentCultureIgnoreCase) || + prefix.Equals("F", StringComparison.CurrentCultureIgnoreCase)) { return type.IsAssignableTo(typeof(IFormattable)); } + return false; } public void ApplyFormat(FormatterContext context, Text target) { + if (context.Args.Length != 1) { - throw new OpenXmlTemplateException($"DateTime formatter requires exactly one argument, e.g. FORMAT(dd.MM.yyyy)"); + throw new OpenXmlTemplateException( + $"DateTime formatter requires exactly one argument, e.g. FORMAT(dd.MM.yyyy)"); } + if (context.Value is IFormattable formattable) { - target.Text = formattable.ToString(context.Args[0], context.Culture); + var formatString = context.Args[0]; + try + { + target.Text = formattable.ToString(formatString, context.Culture); + } + catch (FormatException e) + { + throw new OpenXmlTemplateException($"Format {formatString} cannot be applied to {context.Placeholder} of type {context.Value.GetType()}", e); + } } else { - throw new OpenXmlTemplateException($"Formatter {context.Formatter} can only be applied to IFormattable objects - property {context.Placeholder}"); + throw new OpenXmlTemplateException( + $"Formatter {context.Formatter} can only be applied to IFormattable objects - property {context.Placeholder}"); } } } - } diff --git a/DocxTemplater/Formatter/VariableReplacer.cs b/DocxTemplater/Formatter/VariableReplacer.cs index 18edf00..f65a4bb 100644 --- a/DocxTemplater/Formatter/VariableReplacer.cs +++ b/DocxTemplater/Formatter/VariableReplacer.cs @@ -78,12 +78,16 @@ public void ReplaceVariables(OpenXmlElement cloned) var value = m_models.GetValue(variableMatch.Variable); ApplyFormatter(variableMatch, value, text); } - catch (OpenXmlTemplateException) when (m_processSettings.BindingErrorHandling != BindingErrorHandling.ThrowException) + catch (Exception e) when (e is OpenXmlTemplateException or FormatException) { - if (m_processSettings.BindingErrorHandling == BindingErrorHandling.SkipBindingAndRemoveContent) + if (m_processSettings.BindingErrorHandling != BindingErrorHandling.ThrowException) { text.RemoveWithEmptyParent(); } + else + { + throw new OpenXmlTemplateException($"'{text.InnerText}' could not be replaced: {text.ElementBeforeInDocument()?.InnerText} >> {text.InnerText} << {text.ElementAfterInDocument()?.InnerText}", e); + } } } } diff --git a/DocxTemplater/ModelLookup.cs b/DocxTemplater/ModelLookup.cs index caf8eff..4feafe2 100644 --- a/DocxTemplater/ModelLookup.cs +++ b/DocxTemplater/ModelLookup.cs @@ -91,6 +91,13 @@ public object GetValue(string variableName) if (property != null) { model = property.GetValue(model); + if (model == null) + { + // if a property is null, we can't continue searching + //same behavior as null propagation in C# + // ae A.B.C.D --> A?.B?.C?.D + return null; + } } else if (model is ICollection) { diff --git a/DocxTemplater/OpenXmlHelper.cs b/DocxTemplater/OpenXmlHelper.cs index 7b4332f..3e3973c 100644 --- a/DocxTemplater/OpenXmlHelper.cs +++ b/DocxTemplater/OpenXmlHelper.cs @@ -1,12 +1,12 @@ -using System; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Drawing.Wordprocessing; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Xml.Linq; -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Drawing.Wordprocessing; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; namespace DocxTemplater { @@ -30,6 +30,45 @@ public static bool IsChildOf(this OpenXmlElement element, OpenXmlElement parent) return false; } + + /// + /// Traverses the tree upwards and returns the first element of the given type. + /// + /// + /// + /// + public static OpenXmlElement ElementBeforeInDocument(this OpenXmlElement element) + where TElement : OpenXmlElement + { + var parent = element.Parent; + while (parent != null) + { + var result = (parent?.Descendants()).LastOrDefault(x => x.IsBefore(element)); + if (result != null) + { + return result; + } + parent = parent.PreviousSibling() ?? parent.Parent; + } + return null; + } + + public static OpenXmlElement ElementAfterInDocument(this OpenXmlElement element) + where TElement : OpenXmlElement + { + var parent = element.Parent; + while (parent != null) + { + var result = parent?.Descendants().FirstOrDefault(x => x.IsAfter(element)); + if (result != null) + { + return result; + } + parent = parent.NextSibling() ?? parent.Parent; + } + return null; + } + public static OpenXmlElement FindCommonParent(this OpenXmlElement element, OpenXmlElement otherElement) { ArgumentNullException.ThrowIfNull(element); diff --git a/DocxTemplater/PatterMatcher.cs b/DocxTemplater/PatterMatcher.cs index 13f523c..330b4f7 100644 --- a/DocxTemplater/PatterMatcher.cs +++ b/DocxTemplater/PatterMatcher.cs @@ -35,13 +35,15 @@ internal static class PatternMatcher \s*\} (?:: (?[a-zA-z0-9]+) #formatter - (?:\( #arguments with brackets + (?:\( (?: - (?:,? - (?: (?[a-zA-Z0-9\s-\\/_-]+) | (?:'(?[a-zA-Z0-9\s-\\/_-]*)') ) - )* - ) - \))? + (?: + '(?(?:(?:\\')|[\w\s-\\/:,""])*?)' # quoted string can contain , or whitespace or can be empty + | + (?[\w\s-\\/:'""]+) # unquoted string + )(?:\s*,\s*)? # starting and leading whitespaces are ignored + )* + \))? )? \s*\} ", RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace); @@ -81,7 +83,7 @@ public static IEnumerable FindSyntaxPatterns(string text) else if (match.Groups["varname"].Success) { var argGroup = match.Groups["arg"]; - var arguments = argGroup.Success ? argGroup.Captures.Select(x => x.Value).ToArray() : Array.Empty(); + var arguments = argGroup.Success ? argGroup.Captures.Select(x => x.Value?.Replace("\\'", "'")).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 diff --git a/DocxTemplater/ScriptCompiler.cs b/DocxTemplater/ScriptCompiler.cs index 9701d19..2ba246e 100644 --- a/DocxTemplater/ScriptCompiler.cs +++ b/DocxTemplater/ScriptCompiler.cs @@ -10,11 +10,14 @@ internal class ScriptCompiler private readonly ModelLookup m_modelDictionary; private static readonly Regex RegexWordStartingWithDot = new(@"^(\.+)([a-zA-z0-9_]+)", RegexOptions.Compiled); - public ScriptCompiler(ModelLookup modelDictionary) + public ScriptCompiler(ModelLookup modelDictionary, ProcessSettings processSettings) { this.m_modelDictionary = modelDictionary; + ProcessSettings = processSettings; } + public ProcessSettings ProcessSettings { get; } + public Func CompileScript(string scriptAsString) { // replace replace leading dots (implicit scope) with variables @@ -33,7 +36,14 @@ public Func CompileScript(string scriptAsString) interpreter.SetVariable(identifier, new ModelVariable(m_modelDictionary, identifier)); } } - return interpreter.ParseAsDelegate>(scriptAsString); + try + { + return interpreter.ParseAsDelegate>(scriptAsString); + } + catch (DynamicExpresso.Exceptions.ParseException e) + { + throw new OpenXmlTemplateException($"Error parsing script {scriptAsString}", e); + } } private string OnVariableReplace(Match match, Interpreter interpreter)