diff --git a/DocxTemplater.Test/Resources/ComplexTemplate.docx b/DocxTemplater.Test/Resources/ComplexTemplate.docx index d5dcbde..4b25df7 100644 Binary files a/DocxTemplater.Test/Resources/ComplexTemplate.docx and b/DocxTemplater.Test/Resources/ComplexTemplate.docx differ diff --git a/DocxTemplater.Test/TemplateSyntaxValidationTest.cs b/DocxTemplater.Test/TemplateSyntaxValidationTest.cs new file mode 100644 index 0000000..c72d13e --- /dev/null +++ b/DocxTemplater.Test/TemplateSyntaxValidationTest.cs @@ -0,0 +1,351 @@ +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace DocxTemplater.Test +{ + internal class TemplateSyntaxValidationTest + { + private static DocxTemplate BuildTemplate(params string[] paragraphs) + { + var stream = new MemoryStream(); + using (var wp = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var main = wp.AddMainDocumentPart(); + main.Document = new Document(new Body(paragraphs.Select(p => new Paragraph(new Run(new Text(p)))))); + wp.Save(); + } + stream.Position = 0; + return new DocxTemplate(stream); + } + + private static IReadOnlyList Validate(params string[] paragraphs) + { + using var template = BuildTemplate(paragraphs); + return template.ValidateTemplateSyntax(); + } + + [Test] + public void ValidTemplate_HasNoErrors() + { + var errors = Validate( + "Hello {{customer.Name}:toUpper} {{(customer.Age + 1)}}", + "{{#Items}}{{.Name}}{{:s:}}, {{/Items}}", + "{?{customer.Age > 18 && customer.Name != 'Bob'}}adult{{:}}child{{/}}", + "{{#switch: customer.Kind}}{{#case: 'A'}}A{{#c: 'B'}}B{{/}}{{#default}}other{{/}}{{/}}", + "{{@i:customer.Count}}{{i}}{{/}}", + "{{#Orders}}{{#.Lines}}{{.Name}}{{/.Lines}}{{/Orders}}", + "{{:PageBreak}}{{:break}}{{:SectionBreak}}", + "{{:ignore}} {{ not a tag }} {{/:ignore}}"); + + Assert.That(errors, Is.Empty, string.Join(Environment.NewLine, errors)); + } + + [Test] + public void UnclosedBlocks_AreReported() + { + var errors = Validate("{{#Items}}{{.Name}}", "{?{a > 1}}foo"); + + Assert.That(errors.Select(x => x.Tag), Is.EqualTo(["{{#Items}}", "{?{a > 1}}"])); + Assert.That(errors.Select(x => x.Message), Is.All.Contains("is not closed")); + } + + [Test] + public void IgnoreBlock_IsOnlyClosedByIgnoreEnd() + { + // everything inside the ignore block is plain text, including a {{/}} + var errors = Validate("{{:ignore}}foo{{/}}"); + + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0].Tag, Is.EqualTo("{{:ignore}}")); + Assert.That(errors[0].Message, Does.Contain("is not closed")); + Assert.That(errors[0].Severity, Is.EqualTo(TemplateSyntaxErrorSeverity.Error)); + } + + [Test] + public void ClosingTagWithoutOpening_IsReported() + { + var errors = Validate("{{Name}}{{/}}"); + + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0].Tag, Is.EqualTo("{{/}}")); + Assert.That(errors[0].Message, Does.Contain("no matching opening tag")); + Assert.That(errors[0].Part, Is.EqualTo("Body")); + Assert.That(errors[0].Context, Does.Contain("{{Name}}")); + } + + [TestCase("{{#Items}}foo{{/Orders}}", "{{/Orders}}", "does not match '{{#Items}}'")] + [TestCase("{?{a}}foo{{/Items}}", "{{/Items}}", "expected '{{/}}'")] + [TestCase("{{#Items}}foo{{/:ignore}}", "{{/:ignore}}", "does not match '{{#Items}}'")] + public void MismatchedClosingTag_IsReportedAsWarning(string template, string tag, string message) + { + var errors = Validate(template); + + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0].Tag, Is.EqualTo(tag)); + Assert.That(errors[0].Message, Does.Contain(message)); + Assert.That(errors[0].Severity, Is.EqualTo(TemplateSyntaxErrorSeverity.Warning)); + } + + [Test] + public void MismatchedClosingTag_StillRenders() + { + using var template = BuildTemplate("{{#Items}}{{.}}{{/Orders}}"); + template.BindModel("Items", new[] { "a", "b" }); + + var result = template.Process(); + + using var document = WordprocessingDocument.Open(result, false); + Assert.That(document.MainDocumentPart.Document.Body.InnerText, Is.EqualTo("ab")); + } + + [TestCase("{{#Items}}foo{{/}}")] + [TestCase("{{#items}}foo{{/Items}}")] + [TestCase("{{#ds.Items}}foo{{/Items}}")] + [TestCase("{{#Items}}foo{{/ds.Items}}")] + [TestCase("{{#Outer}}{{#.Inner}}foo{{/Inner}}{{/Outer}}")] + public void LoopClosedWithEmptyOrEquivalentName_IsValid(string template) + { + Assert.That(Validate(template), Is.Empty); + } + + [TestCase("foo{{:}}bar", "{{:}}", "only allowed directly inside a condition")] + [TestCase("{{#Items}}foo{{:}}bar{{/Items}}", "{{:}}", "only allowed directly inside a condition")] + [TestCase("{?{a}}foo{{:}}bar{{:}}baz{{/}}", "{{:}}", "more than one else")] + [TestCase("foo{{:s:}}bar", "{{:s:}}", "only allowed directly inside a collection loop")] + [TestCase("{{#case: 'A'}}foo{{/}}", "{{#case: 'A'}}", "must be inside a '{{#switch: ...}}' block")] + [TestCase("{{#switch}}foo{{/}}", "{{#switch}}", "requires an expression")] + [TestCase("{{#case}}foo{{/}}", "{{#case}}", "requires an expression")] + [TestCase("{{#}}foo{{/}}", "{{#}}", "requires a collection name")] + [TestCase("{{:Foo}}", "{{:Foo}}", "Unknown keyword")] + public void MisplacedOrInvalidTag_IsReported(string template, string tag, string message) + { + var errors = Validate(template); + + Assert.That(errors, Is.Not.Empty); + Assert.That(errors[0].Tag, Is.EqualTo(tag)); + Assert.That(errors[0].Message, Does.Contain(message)); + } + + [Test] + public void SeparatorInRangeLoop_IsReported() + { + var errors = Validate("{{@i:3}}{{i}}{{:s:}}{{/}}"); + + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0].Tag, Is.EqualTo("{{:s:}}")); + } + + [TestCase("Hello {{Name}", "{{Name}", "is not terminated")] + [TestCase("Hello {{first name}}", "{{first name}}", "Invalid tag")] + [TestCase("Hello Name}} foo", "}}", "without matching")] + [TestCase("{{#Items:foo}}", "{{#Items:foo}}", "Invalid tag")] + public void MalformedTag_IsReported(string template, string tag, string message) + { + var errors = Validate(template); + + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0].Tag, Is.EqualTo(tag)); + Assert.That(errors[0].Message, Does.Contain(message)); + } + + [TestCase("{?{(a > 5}}foo{{/}}", "Unclosed '('")] + [TestCase("{?{a > 5)}}foo{{/}}", "Unbalanced ')'")] + [TestCase("{?{a == 'foo}}foo{{/}}", "Unterminated string literal")] + [TestCase("{?{ }}foo{{/}}", "empty expression")] + [TestCase("{{(a + \"x)}}", "Unterminated string literal")] + [TestCase("{{#switch: (a}}{{#case: 1}}x{{/}}{{/}}", "Unclosed '('")] + [TestCase("{{#s: a}}{{#c: (1}}x{{/}}{{/}}", "Unclosed '('")] + [TestCase("{{#s}}x{{/}}", "is a loop over a collection named 's'")] + public void InvalidExpression_IsReportedAsWarning(string template, string message) + { + var errors = Validate(template); + + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0].Message, Does.Contain(message)); + Assert.That(errors[0].Severity, Is.EqualTo(TemplateSyntaxErrorSeverity.Warning)); + } + + [Test] + public void ContentOfIgnoreBlock_IsNotValidated() + { + var errors = Validate("{{:ignore}}Use {{:}} for else, {{:s:}} for separators, {{#case: 1}} and {{Name} or {{/switch}}{{/:ignore}}{{Name}}"); + + Assert.That(errors, Is.Empty, string.Join(Environment.NewLine, errors)); + } + + [Test] + public void ContentOfIgnoreBlock_IsRenderedVerbatim() + { + using var template = BuildTemplate("{{:ignore}}{{:}} {{#Items}} {{Name}}{{/:ignore}} {{Name}}"); + template.BindModel("Name", "foo"); + + var result = template.Process(); + + using var document = WordprocessingDocument.Open(result, false); + Assert.That(document.MainDocumentPart.Document.Body.InnerText, Is.EqualTo("{{:}} {{#Items}} {{Name}} foo")); + } + + [Test] + public void Validate_AfterFailedProcess_Throws() + { + using var template = BuildTemplate("{{Name}}", "{{Missing}}"); + template.Settings.BindingErrorHandling = BindingErrorHandling.ThrowException; + template.BindModel("Name", "foo"); + Assert.Throws(() => template.Process()); + + Assert.Throws(() => template.ValidateTemplateSyntax()); + } + + [TestCase("{{/switch}}")] + [TestCase("{{/:foo}}")] + public void PatternMatcherError_IsReportedInsteadOfThrown(string template) + { + var errors = Validate(template); + + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0].Tag, Is.EqualTo(template)); + Assert.That(errors[0].Message, Does.Contain("Invalid syntax")); + } + + [Test] + public void AllErrorsAreCollected_InDocumentOrder() + { + var errors = Validate("{{Name}", "{{:}}", "{{#Items}}", "{{/Orders}}", "{{/}}"); + + Assert.That(errors.Select(x => x.Tag), Is.EqualTo(["{{Name}", "{{:}}", "{{/Orders}}", "{{/}}"])); + } + + [Test] + public void ErrorsInHeaderAndFooter_AreReportedWithPart() + { + var stream = new MemoryStream(); + using (var wp = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var main = wp.AddMainDocumentPart(); + main.Document = new Document(new Body(new Paragraph(new Run(new Text("{{Name}}"))))); + var header = main.AddNewPart(); + header.Header = new Header(new Paragraph(new Run(new Text("{{#Items}}")))); + var footer = main.AddNewPart(); + footer.Footer = new Footer(new Paragraph(new Run(new Text("{{/}}")))); + wp.Save(); + } + stream.Position = 0; + using var template = new DocxTemplate(stream); + + var errors = template.ValidateTemplateSyntax(); + + Assert.That(errors.Select(x => (x.Part, x.Tag)), Is.EqualTo([("Header", "{{#Items}}"), ("Footer", "{{/}}")])); + } + + [Test] + public void TagSplitAcrossRuns_IsValid() + { + var stream = new MemoryStream(); + using (var wp = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var main = wp.AddMainDocumentPart(); + main.Document = new Document(new Body(new Paragraph( + new Run(new Text("{{#Ite")), + new Run(new Text("ms}}{{.}}{{/Items")), + new Run(new Text("}}"))))); + wp.Save(); + } + stream.Position = 0; + using var template = new DocxTemplate(stream); + + Assert.That(template.ValidateTemplateSyntax(), Is.Empty); + } + + [Test] + public void Validate_DoesNotModifyDocument() + { + using var template = BuildTemplate("{{#Items}}{{.}}{{/Items}}"); + Assert.That(template.ValidateTemplateSyntax(), Is.Empty); + + template.BindModel("Items", new[] { "a", "b" }); + var result = template.Process(); + + using var document = WordprocessingDocument.Open(result, false); + Assert.That(document.MainDocumentPart.Document.Body.InnerText, Is.EqualTo("ab")); + } + + [Test] + public void Validate_AfterGetTemplateSchema_UsesOriginalText() + { + using var template = BuildTemplate("{{#Items}}{{.}}{{/Items}}", "{{Name}"); + template.GetTemplateSchema(); + + var errors = template.ValidateTemplateSyntax(); + + Assert.That(errors.Select(x => x.Tag), Is.EqualTo(["{{Name}"])); + } + + [Test] + public void Validate_AfterProcess_Throws() + { + using var template = BuildTemplate("{{Name}}"); + template.BindModel("Name", "foo"); + template.Process(); + + Assert.Throws(() => template.ValidateTemplateSyntax()); + } + + private static IEnumerable ResourceTemplates() + { + return Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "Resources"), "*.docx") + .Select(Path.GetFileName); + } + + [TestCaseSource(nameof(ResourceTemplates))] + public void ExistingTestTemplates_HaveNoSyntaxErrors(string fileName) + { + using var template = DocxTemplate.Open(Path.Combine(AppContext.BaseDirectory, "Resources", fileName)); + + var errors = template.ValidateTemplateSyntax(); + + Assert.That(errors, Is.Empty, string.Join(Environment.NewLine, errors)); + } + + [Test] + public void Process_ThrowsWithAllSyntaxErrors() + { + using var template = BuildTemplate("{{#Items}}{{.}}{{/Items}}", "{{:}}", "{{/}}", "{{:Foo}}"); + template.BindModel("Items", new[] { "a" }); + + var ex = Assert.Throws(() => template.Process()); + + Assert.That(ex.Message, Does.Contain("'{{:}}' (else) is only allowed")); + Assert.That(ex.Message, Does.Contain("'{{/}}' has no matching opening tag")); + Assert.That(ex.Message, Does.Contain("Unknown keyword '{{:Foo}}'")); + } + + [Test] + public void Process_RendersDespiteWarnings() + { + using var template = BuildTemplate("{{Name} {{Name}} }}"); + var errors = template.ValidateTemplateSyntax(); + Assert.That(errors, Has.Count.EqualTo(2)); + Assert.That(errors.Select(x => x.Severity), Is.All.EqualTo(TemplateSyntaxErrorSeverity.Warning)); + + template.BindModel("Name", "foo"); + var result = template.Process(); + + using var document = WordprocessingDocument.Open(result, false); + Assert.That(document.MainDocumentPart.Document.Body.InnerText, Is.EqualTo("{{Name} foo }}")); + } + + [Test] + public void StructuralErrors_AreErrors_ExpressionAndTagIssues_AreWarnings() + { + var errors = Validate("{{#Items}}{{/}}{{/}}", "{?{(a}}x{{/}}", "{{Name}"); + + Assert.That(errors.Select(x => (x.Tag, x.Severity)), Is.EqualTo( + [ + ("{{/}}", TemplateSyntaxErrorSeverity.Error), + ("{?{(a}}", TemplateSyntaxErrorSeverity.Warning), + ("{{Name}", TemplateSyntaxErrorSeverity.Warning), + ])); + } + } +} diff --git a/DocxTemplater/DocxTemplate.cs b/DocxTemplater/DocxTemplate.cs index dff16ef..69daa43 100644 --- a/DocxTemplater/DocxTemplate.cs +++ b/DocxTemplater/DocxTemplate.cs @@ -105,6 +105,48 @@ public void Validate() } } + /// + /// Checks the template syntax without rendering it, e.g. that all blocks are closed, closing tags match their + /// opening tags, else / separator / case tags are used inside the right block, tags are well-formed and + /// expressions have balanced parentheses and terminated strings. + /// Binding errors (unknown variables, wrong types) are not detected - they require a model. + /// + /// + /// All syntax errors found; an empty list if the template syntax is valid. Entries with + /// make throw; warnings do not. + /// + /// Must be called before . The document is not modified. + public IReadOnlyList ValidateTemplateSyntax() + { + if (Processed || m_faulted) + { + throw new OpenXmlTemplateException($"{nameof(ValidateTemplateSyntax)} must be called before {nameof(Process)}."); + } + var errors = new List(); + var mainPart = m_wpDocument.MainDocumentPart; + if (mainPart != null) + { + foreach (var header in mainPart.HeaderParts) + { + Validate(header.Header); + } + Validate(mainPart.RootElement); + foreach (var footer in mainPart.FooterParts) + { + Validate(footer.Footer); + } + } + return errors; + + void Validate(OpenXmlCompositeElement root) + { + if (root != null) + { + errors.AddRange(TemplateSyntaxParser.Parse(GetPartText(root), GetPartName(root)).Errors); + } + } + } + public Stream Process() { if (m_faulted) diff --git a/DocxTemplater/PatterMatcher.cs b/DocxTemplater/PatterMatcher.cs index 2618cd5..a37d190 100644 --- a/DocxTemplater/PatterMatcher.cs +++ b/DocxTemplater/PatterMatcher.cs @@ -87,6 +87,16 @@ internal static class PatternMatcher ", RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace, TimeSpan.FromSeconds(1)); public static IEnumerable FindSyntaxPatterns(string text) + { + return FindSyntaxPatterns(text, null); + } + + /// + /// Finds all syntax patterns in . If is null, + /// an invalid pattern throws an ; otherwise it is reported to the + /// callback and skipped, so all errors of a text can be collected. + /// + public static IEnumerable FindSyntaxPatterns(string text, Action onInvalidPattern) { try { @@ -123,7 +133,8 @@ public static IEnumerable FindSyntaxPatterns(string text) { if (varname == null) { - throw new OpenXmlTemplateException($"Invalid syntax '{match.Value}'"); + ReportInvalid(onInvalidPattern, match, $"Invalid syntax '{match.Value}'"); + continue; } if (varname.Equals("ignore", StringComparison.CurrentCultureIgnoreCase)) @@ -140,9 +151,10 @@ public static IEnumerable FindSyntaxPatterns(string text) } else if (prefix == "/:") { - if (!varname.Equals("ignore", StringComparison.CurrentCultureIgnoreCase)) + if (varname == null || !varname.Equals("ignore", StringComparison.CurrentCultureIgnoreCase)) { - throw new OpenXmlTemplateException($"Invalid syntax '{match.Value}'"); + ReportInvalid(onInvalidPattern, match, $"Invalid syntax '{match.Value}'"); + continue; } result.Add(new PatternMatch(match, PatternType.IgnoreEnd, null, prefix, varname, null, null, match.Index, match.Length)); @@ -181,7 +193,8 @@ public static IEnumerable FindSyntaxPatterns(string text) { if (string.IsNullOrWhiteSpace(varname)) { - throw new OpenXmlTemplateException($"Invalid range loop syntax '{match.Value}' - variable name is required"); + ReportInvalid(onInvalidPattern, match, $"Invalid range loop syntax '{match.Value}' - variable name is required"); + continue; } result.Add(new PatternMatch(match, PatternType.RangeStart, null, @@ -204,7 +217,8 @@ public static IEnumerable FindSyntaxPatterns(string text) trimmedVarname.Equals("default", StringComparison.OrdinalIgnoreCase) || trimmedVarname.Equals("d", StringComparison.OrdinalIgnoreCase)) { - throw new OpenXmlTemplateException($"Invalid syntax '{match.Value}'. Use '{{{{/}}}}' instead."); + ReportInvalid(onInvalidPattern, match, $"Invalid syntax '{match.Value}'. Use '{{{{/}}}}' instead."); + continue; } var patternType = PatternType.CollectionEnd; result.Add(new PatternMatch(match, patternType, null, @@ -239,7 +253,7 @@ public static IEnumerable FindSyntaxPatterns(string text) } else { - throw new OpenXmlTemplateException($"Invalid syntax '{match.Value}'"); + ReportInvalid(onInvalidPattern, match, $"Invalid syntax '{match.Value}'"); } } @@ -250,5 +264,14 @@ public static IEnumerable FindSyntaxPatterns(string text) throw new OpenXmlTemplateException($"Invalid syntax '{text}' - match timeout"); } } + + private static void ReportInvalid(Action onInvalidPattern, Match match, string message) + { + if (onInvalidPattern == null) + { + throw new OpenXmlTemplateException(message); + } + onInvalidPattern(match, message); + } } } diff --git a/DocxTemplater/TemplateProcessor.cs b/DocxTemplater/TemplateProcessor.cs index dfcaed6..852f9f7 100644 --- a/DocxTemplater/TemplateProcessor.cs +++ b/DocxTemplater/TemplateProcessor.cs @@ -16,6 +16,10 @@ public abstract class TemplateProcessor { internal ITemplateProcessingContextAccess Context { get; } + // Text of each part as seen by the syntax parser, captured by BuildBlockTree before it mutates the part. + // Lets DocxTemplate.ValidateTemplateSyntax run after GetTemplateSchema without rebuilding the text. + private readonly Dictionary m_partTexts = new(); + private protected TemplateProcessor(ITemplateProcessingContextAccess context) { Context = context; @@ -94,7 +98,7 @@ private void PreProcess(OpenXmlCompositeElement content) } } - private void RemoveLineBreaksAroundSyntaxPatterns(IReadOnlyCollection<(PatternMatch, Text)> matches) + private void RemoveLineBreaksAroundSyntaxPatterns(IEnumerable markerTexts) { if (!Context.ProcessSettings.IgnoreLineBreaksAroundTags) { @@ -108,7 +112,7 @@ static bool RemoveBreakAndCheckForText(OpenXmlElement openXmlElement) } return openXmlElement is Text; } - foreach (var (_, text) in matches) + foreach (var text in markerTexts) { foreach (var next in text.ElementsSameLevelAfterInDocument()) { @@ -127,11 +131,14 @@ static bool RemoveBreakAndCheckForText(OpenXmlElement openXmlElement) } } - private static IReadOnlyCollection<(PatternMatch, Text)> IsolateAndMergeTextTemplateMarkers(OpenXmlCompositeElement content) + /// + /// Merges the runs of every syntax pattern into a single marked element and returns the + /// element for each match. + /// + private static Dictionary IsolateAndMergeTextTemplateMarkers(CharacterMap charMap, IEnumerable matches) { - var charMap = new CharacterMap(content); - List<(PatternMatch, Text)> patternMatches = []; - foreach (var m in PatternMatcher.FindSyntaxPatterns(charMap.Text)) + var texts = new Dictionary(); + foreach (var m in matches) { var firstChar = charMap[m.Index]; var lastChar = charMap[m.Index + m.Length - 1]; @@ -139,9 +146,9 @@ static bool RemoveBreakAndCheckForText(OpenXmlElement openXmlElement) // for this reason it does not matter that the new nodes are not in the charMap var mergedText = charMap.MergeText(firstChar, lastChar); mergedText.Element.Mark(m.Type); - patternMatches.Add(new(m, mergedText.Element)); + texts[m] = mergedText.Element; } - return patternMatches; + return texts; } private static void Cleanup(OpenXmlCompositeElement element, bool removeEmptyElements) @@ -269,50 +276,23 @@ private static void MakeBookmarksValid(OpenXmlCompositeElement element) } } - private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement element, IReadOnlyCollection<(PatternMatch, Text)> matches) + /// + /// Creates the tree from the syntax tree. The syntax tree already has the block + /// shape (content / else / separator nodes), so this is a plain one-to-one mapping. + /// + private void CreateBlocks(IEnumerable nodes, ContentBlock parent, IReadOnlyDictionary texts) { - Stack blockStack = new(); - blockStack.Push(new ContentBlock()); // dummy block for root - foreach (var item in matches) + foreach (var node in nodes) { - var match = item.Item1; - var text = (Text)item.Item2; - var patternType = match.Type; - if (patternType is PatternType.InlineKeyWord) - { - StartBlock(blockStack, match, patternType, text); - CloseBlock(blockStack, match, text); - } - - if (patternType is PatternType.Condition or PatternType.CollectionStart or PatternType.IgnoreBlock or PatternType.Switch or PatternType.Case or PatternType.Default or PatternType.RangeStart) - { - if (patternType is PatternType.Case or PatternType.Default) - { - AutoCloseCaseOrDefaultIfNeeded(blockStack, match, text); - } - StartBlock(blockStack, match, patternType, text); - StartBlock(blockStack, match, PatternType.None, text); // open the child content block of the loop or condition - } - else if (patternType is PatternType.ConditionElse or PatternType.CollectionSeparator) - { - CloseBlock(blockStack, match, text); - StartBlock(blockStack, match, patternType, text); - } - if (patternType is PatternType.ConditionEnd or PatternType.CollectionEnd or PatternType.IgnoreEnd) - { - CloseBlock(blockStack, match, text); - CloseBlock(blockStack, match, text); - } - } - if (blockStack.Count != 1) - { - var notClosedBlocks = blockStack.Reverse().Skip(1).Select(x => x.StartMatch.Match.Value).Skip(1).ToList(); - throw new OpenXmlTemplateException($"Not all blocks are closed: {string.Join(", ", notClosedBlocks)}"); + var block = ContentBlock.Crate(Context, node.Type, texts[node.Start], node.Start); + parent.AddChildBlock(block); + block.CloseBlock(texts[node.End], node.End); + CreateBlocks(node.Children, block, texts); } + } - var rootBlock = blockStack.Peek(); - var rootChilds = rootBlock.ChildBlocks; - + private static IReadOnlyCollection ExtractBlockContent(OpenXmlCompositeElement element, IReadOnlyCollection rootChilds) + { foreach (var block in rootChilds) { block.AddInsertionPointsRecursively(); @@ -337,34 +317,6 @@ private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement el return rootChilds; } - private void StartBlock(Stack blockStack, PatternMatch match, PatternType value, Text text) - { - var newBlock = ContentBlock.Crate(Context, value, text, match); - blockStack.Peek().AddChildBlock(newBlock); - blockStack.Push(newBlock); - } - - private static void AutoCloseCaseOrDefaultIfNeeded(Stack blockStack, PatternMatch match, Text text) - { - var current = blockStack.Peek(); - if (current.PatternType == PatternType.None && current.ParentBlock != null && - (current.ParentBlock.PatternType == PatternType.Case || current.ParentBlock.PatternType == PatternType.Default)) - { - CloseBlock(blockStack, match, text); // Close None - CloseBlock(blockStack, match, text); // Close Case/Default - } - } - - private static void CloseBlock(Stack blockStack, PatternMatch match, Text text) - { - if (blockStack.Count == 1) - { - throw new OpenXmlTemplateException($"Block was not open {text.InnerText}"); - } - var closedBlock = blockStack.Pop(); - closedBlock.CloseBlock(text, match); - } - public void BindModel(string prefix, object model) { Context.ModelLookup.Add(prefix, model); @@ -400,13 +352,41 @@ internal IReadOnlyCollection BuildBlockTree(OpenXmlCompositeElemen Console.WriteLine(rootElement.ToPrettyPrintXml()); #endif PreProcess(rootElement); - var matches = IsolateAndMergeTextTemplateMarkers(rootElement); - RemoveLineBreaksAroundSyntaxPatterns(matches); + var charMap = new CharacterMap(rootElement); + m_partTexts[rootElement] = charMap.Text; + var syntaxTree = TemplateSyntaxParser.Parse(charMap.Text, GetPartName(rootElement)); + syntaxTree.ThrowIfErrors(); + var texts = IsolateAndMergeTextTemplateMarkers(charMap, syntaxTree.Matches); + RemoveLineBreaksAroundSyntaxPatterns(texts.Values); #if DEBUG Console.WriteLine("----------- Isolate Texts --------"); Console.WriteLine(rootElement.ToPrettyPrintXml()); #endif - return ExpandLoops(rootElement, matches); + var rootBlock = new ContentBlock(); // dummy block for root + CreateBlocks(syntaxTree.Blocks, rootBlock, texts); + return ExtractBlockContent(rootElement, rootBlock.ChildBlocks); + } + + /// + /// The text of a part as seen by the syntax parser: the text captured by if it + /// already ran for this part (the part is mutated afterwards), otherwise the current text. + /// + private protected string GetPartText(OpenXmlCompositeElement rootElement) + { + return m_partTexts.TryGetValue(rootElement, out var text) ? text : new CharacterMap(rootElement).Text; + } + + /// + /// Name of the document part a root element belongs to, as reported in . + /// + internal static string GetPartName(OpenXmlElement rootElement) + { + return rootElement switch + { + Header => "Header", + Footer => "Footer", + _ => "Body" + }; } } } \ No newline at end of file diff --git a/DocxTemplater/TemplateSyntaxError.cs b/DocxTemplater/TemplateSyntaxError.cs new file mode 100644 index 0000000..31f892c --- /dev/null +++ b/DocxTemplater/TemplateSyntaxError.cs @@ -0,0 +1,58 @@ +namespace DocxTemplater +{ + public enum TemplateSyntaxErrorSeverity + { + /// + /// The template can still be rendered, but the construct is most likely not what the author intended + /// (e.g. a malformed tag that stays in the document as plain text). + /// + Warning, + + /// + /// The template cannot be rendered; throws an . + /// + Error + } + + /// + /// A syntax error found by . + /// + public sealed class TemplateSyntaxError + { + internal TemplateSyntaxError(TemplateSyntaxErrorSeverity severity, string part, string tag, string message, string context) + { + Severity = severity; + Part = part; + Tag = tag; + Message = message; + Context = context; + } + + public TemplateSyntaxErrorSeverity Severity { get; } + + /// + /// The document part containing the error: "Body", "Header" or "Footer". + /// + public string Part { get; } + + /// + /// The offending template text, e.g. {{#Items}} or a malformed fragment like {{Name}. + /// + public string Tag { get; } + + /// + /// Description of the error. + /// + public string Message { get; } + + /// + /// Text surrounding the error, to help locating it in the document. + /// + public string Context { get; } + + public override string ToString() + { + return $"{Part}: {Message} (near '{Context}')"; + } + } +} diff --git a/DocxTemplater/TemplateSyntaxParser.cs b/DocxTemplater/TemplateSyntaxParser.cs new file mode 100644 index 0000000..4cd185c --- /dev/null +++ b/DocxTemplater/TemplateSyntaxParser.cs @@ -0,0 +1,500 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace DocxTemplater +{ + /// + /// A block of the template syntax tree. The tree has the same shape as the tree + /// built from it: every opening tag yields a node with a child node holding the content; + /// else / separator tags yield siblings of that content node. + /// + internal sealed class TemplateSyntaxNode + { + private readonly List m_children = []; + + public TemplateSyntaxNode(PatternType type, PatternMatch start, TemplateSyntaxNode parent) + { + Type = type; + Start = start; + Parent = parent; + parent?.m_children.Add(this); + } + + public PatternType Type { get; } + + public PatternMatch Start { get; } + + public PatternMatch End { get; set; } + + public TemplateSyntaxNode Parent { get; } + + public IReadOnlyList Children => m_children; + } + + internal sealed class TemplateSyntaxTree + { + public TemplateSyntaxTree(IReadOnlyList matches, IReadOnlyList blocks, IReadOnlyList errors) + { + Matches = matches; + Blocks = blocks; + Errors = errors; + } + + /// All valid syntax patterns in document order. + public IReadOnlyList Matches { get; } + + /// The top level blocks. + public IReadOnlyList Blocks { get; } + + public IReadOnlyList Errors { get; } + + public bool HasErrors => Errors.Any(x => x.Severity == TemplateSyntaxErrorSeverity.Error); + + public void ThrowIfErrors() + { + if (HasErrors) + { + var errors = Errors.Where(x => x.Severity == TemplateSyntaxErrorSeverity.Error); + throw new OpenXmlTemplateException($"Template syntax errors:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}"); + } + } + } + + /// + /// Parses the text of a template part into a . All syntax errors are collected + /// instead of throwing on the first one, so the same parser serves + /// and the rendering pipeline. + /// + internal static class TemplateSyntaxParser + { + private const int ContextLength = 30; + private const int MaxUnterminatedTagLength = 40; + + // remains of a tag that the pattern matcher did not recognize, e.g. "{{Name}" or "{{first name}}" + private static readonly Regex StrayBracesRegex = new(@"(?\{\s*(?:\?\s*)?\{)|(?\}\s*\})", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + + private static readonly HashSet InlineKeyWords = new(StringComparer.OrdinalIgnoreCase) { "Break", "PageBreak", "SectionBreak" }; + + private sealed class ParseContext + { + private readonly string m_text; + private readonly string m_part; + private readonly List<(int Index, TemplateSyntaxError Error)> m_errors = []; + + public ParseContext(string text, string part) + { + m_text = text; + m_part = part; + } + + public void AddError(int index, int length, string message) + { + Add(TemplateSyntaxErrorSeverity.Error, index, length, message); + } + + public void AddError(PatternMatch match, string message) + { + Add(TemplateSyntaxErrorSeverity.Error, match.Index, match.Length, message); + } + + public void AddWarning(PatternMatch match, string message) + { + Add(TemplateSyntaxErrorSeverity.Warning, match.Index, match.Length, message); + } + + public void AddWarning(int index, int length, string message) + { + Add(TemplateSyntaxErrorSeverity.Warning, index, length, message); + } + + public IReadOnlyList SortedErrors => m_errors.OrderBy(x => x.Index).Select(x => x.Error).ToList(); + + private void Add(TemplateSyntaxErrorSeverity severity, int index, int length, string message) + { + var start = Math.Max(0, index - ContextLength); + var end = Math.Min(m_text.Length, index + length + ContextLength); + m_errors.Add((index, new TemplateSyntaxError(severity, m_part, m_text.Substring(index, length), message, m_text[start..end]))); + } + } + + public static TemplateSyntaxTree Parse(string text, string part) + { + text ??= string.Empty; + var ctx = new ParseContext(text, part); + + // all patterns in document order - valid ones and those the pattern matcher rejected + var found = new List<(int Index, int Length, PatternMatch Match, string Error)>(); + try + { + foreach (var m in PatternMatcher.FindSyntaxPatterns(text, (m, message) => found.Add((m.Index, m.Length, null, message)))) + { + found.Add((m.Index, m.Length, m, null)); + } + } + catch (OpenXmlTemplateException e) + { + ctx.AddError(0, 0, e.Message); + return new TemplateSyntaxTree([], [], ctx.SortedErrors); + } + found.Sort((a, b) => a.Index.CompareTo(b.Index)); + + // Everything between {{:ignore}} and {{/:ignore}} is opaque text: no match, no error, no marker. + var matches = new List(); + var covered = new List<(int Start, int End)>(); // text ranges consumed by a pattern + bool inIgnore = false; + foreach (var (index, length, match, error) in found) + { + if (inIgnore && match?.Type != PatternType.IgnoreEnd) + { + continue; + } + covered.Add((index, index + length)); + if (match == null) + { + ctx.AddError(index, length, error); + continue; + } + matches.Add(match); + inIgnore = match.Type == PatternType.IgnoreBlock; + } + + var root = BuildTree(matches, ctx); + CheckUnrecognizedTags(text, covered, GetIgnoredRanges(root, text.Length), ctx); + return new TemplateSyntaxTree(matches, root.Children, ctx.SortedErrors); + } + + /// + /// Builds the block tree. Tags that are misplaced are reported and skipped, so the remaining structure can still + /// be checked and all errors of a template are reported at once. + /// + private static TemplateSyntaxNode BuildTree(List matches, ParseContext ctx) + { + var root = new TemplateSyntaxNode(PatternType.None, null, null); + var current = root; // always a content (None), else or separator node - never an opening block itself + + void Open(PatternMatch m) + { + var block = new TemplateSyntaxNode(m.Type, m, current); + current = new TemplateSyntaxNode(PatternType.None, m, block); + } + + void Close(PatternMatch m) + { + current.End = m; + current.Parent.End = m; + current = current.Parent.Parent; + } + + foreach (var m in matches) + { + switch (m.Type) + { + case PatternType.Expression: + CheckExpression(m, m.Variable, ctx); + break; + case PatternType.InlineKeyWord: + if (!InlineKeyWords.Contains(m.Variable.Trim())) + { + ctx.AddError(m, $"Unknown keyword '{m.Match.Value}'. Supported keywords are {string.Join(", ", InlineKeyWords.Select(x => $"{{{{:{x}}}}}"))}"); + } + new TemplateSyntaxNode(m.Type, m, current).End = m; + break; + case PatternType.Condition: + CheckExpression(m, m.Condition, ctx); + Open(m); + break; + case PatternType.CollectionStart: + CheckCollectionName(m, ctx); + Open(m); + break; + case PatternType.Switch: + CheckExpression(m, GetKeywordArgument(m), ctx); + Open(m); + break; + case PatternType.RangeStart: + case PatternType.IgnoreBlock: + Open(m); + break; + case PatternType.Case: + CheckExpression(m, GetKeywordArgument(m), ctx); + goto case PatternType.Default; + case PatternType.Default: + // a case or default implicitly closes the preceding case / default + if (current.Parent?.Type is PatternType.Case or PatternType.Default) + { + Close(m); + } + if (!IsInside(current, PatternType.Switch)) + { + ctx.AddError(m, $"'{m.Match.Value}' must be inside a '{{{{#switch: ...}}}}' block"); + } + Open(m); + break; + case PatternType.ConditionElse: + if (current.Parent?.Type != PatternType.Condition) + { + ctx.AddError(m, $"'{m.Match.Value}' (else) is only allowed directly inside a condition '{{?{{...}}}}'"); + } + else if (current.Type != PatternType.None) + { + ctx.AddError(m, $"Condition '{current.Parent.Start.Match.Value}' has more than one else '{m.Match.Value}'"); + } + else + { + current.End = m; + current = new TemplateSyntaxNode(m.Type, m, current.Parent); + } + break; + case PatternType.CollectionSeparator: + if (current.Parent?.Type != PatternType.CollectionStart || IsDynamicTable(current.Parent.Start)) + { + ctx.AddError(m, $"Separator '{m.Match.Value}' is only allowed directly inside a collection loop '{{{{#Items}}}}'"); + } + else if (current.Type != PatternType.None) + { + ctx.AddError(m, $"Loop '{current.Parent.Start.Match.Value}' has more than one separator '{m.Match.Value}'"); + } + else + { + current.End = m; + current = new TemplateSyntaxNode(m.Type, m, current.Parent); + } + break; + case PatternType.ConditionEnd: + case PatternType.CollectionEnd: + case PatternType.IgnoreEnd: + if (current == root) + { + ctx.AddError(m, $"'{m.Match.Value}' has no matching opening tag"); + break; + } + var closeError = CheckClosingTag(current.Parent.Start, m); + if (closeError != null) + { + // rendering closes the current block regardless of the name, so this is only a warning + ctx.AddWarning(m, closeError); + } + Close(m); + break; + } + } + + for (var node = current; node != root; node = node.Parent.Parent) + { + ctx.AddError(node.Parent.Start, $"'{node.Parent.Start.Match.Value}' is not closed"); + } + return root; + } + + private static bool IsInside(TemplateSyntaxNode node, PatternType type) + { + for (; node != null; node = node.Parent) + { + if (node.Type == type) + { + return true; + } + } + return false; + } + + /// + /// {{#switch}} / {{#case}} without argument are parsed as loops over a collection named "switch" / + /// "case" - almost certainly not intended. The short forms {{#s}} / {{#c}} could be real collection + /// names, so they only get a warning. + /// + private static void CheckCollectionName(PatternMatch m, ParseContext ctx) + { + var name = m.Variable.Trim(); + if (name.Length == 0) + { + ctx.AddError(m, $"'{m.Match.Value}' requires a collection name, e.g. '{{{{#Items}}}}'"); + } + else if (name.Equals("switch", StringComparison.OrdinalIgnoreCase) || name.Equals("case", StringComparison.OrdinalIgnoreCase)) + { + ctx.AddError(m, $"'{m.Match.Value}' requires an expression, e.g. '{{{{#{name}: Value}}}}'"); + } + else if (name.Equals("s", StringComparison.OrdinalIgnoreCase) || name.Equals("c", StringComparison.OrdinalIgnoreCase)) + { + ctx.AddWarning(m, $"'{m.Match.Value}' is a loop over a collection named '{name}'. For a switch / case use '{{{{#{name}: Value}}}}'"); + } + } + + /// The expression after the keyword of a switch / case tag, e.g. "ds.Val" for {{#switch: ds.Val}}. + private static string GetKeywordArgument(PatternMatch m) + { + var colon = m.Variable.IndexOf(':'); + return colon < 0 ? string.Empty : m.Variable[(colon + 1)..]; + } + + private static string CheckClosingTag(PatternMatch open, PatternMatch close) + { + var openTag = open.Match.Value; + var closeTag = close.Match.Value; + // an ignore block is always closed by its own end tag - everything else inside is skipped by Parse + if (close.Type == PatternType.IgnoreEnd) + { + return open.Type == PatternType.IgnoreBlock ? null : $"'{closeTag}' does not match '{openTag}'"; + } + if (close.Type == PatternType.CollectionEnd) + { + if (open.Type == PatternType.CollectionStart) + { + return IsSameCollection(open.Variable, close.Variable) ? null : $"'{closeTag}' does not match '{openTag}'"; + } + if (open.Type != PatternType.RangeStart) + { + return $"'{closeTag}' closes '{openTag}', expected '{{{{/}}}}'"; + } + } + return null; + } + + /// + /// The closing tag may omit the implicit model prefix or leading dots, e.g. {{#ds.Items}}...{{/Items}} + /// (the same way {{Items.Value}} resolves to ds.Items.Value at runtime). + /// + private static bool IsSameCollection(string openName, string closeName) + { + openName = openName.Trim().TrimStart('.'); + closeName = closeName.Trim().TrimStart('.'); + return string.Equals(openName, closeName, StringComparison.OrdinalIgnoreCase) + || openName.EndsWith("." + closeName, StringComparison.OrdinalIgnoreCase) + || closeName.EndsWith("." + openName, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDynamicTable(PatternMatch match) + { + return string.Equals(match.Formatter, "dyntable", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Lightweight check of a condition or expression: not empty, string literals terminated and parentheses balanced. + /// Whether the expression compiles can only be checked with a bound model, so these are warnings. + /// + private static void CheckExpression(PatternMatch m, string expression, ParseContext ctx) + { + if (string.IsNullOrWhiteSpace(expression)) + { + ctx.AddWarning(m, $"'{m.Match.Value}' has an empty expression"); + return; + } + var sanitized = HelperFunctions.SanitizeQuotes(expression); + var brackets = new Stack(); + bool inString = false; + for (int i = 0; i < sanitized.Length; i++) + { + var c = sanitized[i]; + if (inString) + { + if (c == '\\') + { + i++; + } + else if (c == '"') + { + inString = false; + } + continue; + } + switch (c) + { + case '"': + inString = true; + break; + case '(': + case '[': + brackets.Push(c); + break; + case ')': + case ']': + var expected = c == ')' ? '(' : '['; + if (brackets.Count == 0 || brackets.Pop() != expected) + { + ctx.AddWarning(m, $"Unbalanced '{c}' in expression '{expression}'"); + return; + } + break; + } + } + if (inString) + { + ctx.AddWarning(m, $"Unterminated string literal in expression '{expression}'"); + } + else if (brackets.Count > 0) + { + ctx.AddWarning(m, $"Unclosed '{brackets.Peek()}' in expression '{expression}'"); + } + } + + private static List<(int Start, int End)> GetIgnoredRanges(TemplateSyntaxNode root, int textLength) + { + var ranges = new List<(int Start, int End)>(); + Collect(root); + return ranges; + + void Collect(TemplateSyntaxNode node) + { + foreach (var child in node.Children) + { + if (child.Type == PatternType.IgnoreBlock) + { + ranges.Add((child.Start.Index, child.End != null ? child.End.Index + child.End.Length : textLength)); + } + else + { + Collect(child); + } + } + } + } + + /// + /// Reports '{{' / '{?{' / '}}' outside of recognized tags - these are tags the pattern matcher could not + /// parse (e.g. "{{Name}", "{{first name}}") and are left as plain text in the generated document. + /// Content of :ignore blocks is skipped. + /// + private static void CheckUnrecognizedTags(string text, List<(int Start, int End)> covered, List<(int Start, int End)> ignoredRanges, ParseContext ctx) + { + int gapStart = 0; + foreach (var (start, end) in covered.Append((text.Length, text.Length))) + { + if (start > gapStart && !ignoredRanges.Any(r => r.Start <= gapStart && start <= r.End)) + { + CheckGap(text, gapStart, start, ctx); + } + gapStart = Math.Max(gapStart, end); + } + } + + private static void CheckGap(string text, int gapStart, int gapEnd, ParseContext ctx) + { + var braces = StrayBracesRegex.Matches(text[gapStart..gapEnd]).Cast().ToList(); + for (int i = 0; i < braces.Count; i++) + { + var brace = braces[i]; + var index = gapStart + brace.Index; + if (brace.Groups["close"].Success) + { + ctx.AddWarning(index, brace.Length, $"Unexpected '{brace.Value}' without matching '{{{{'"); + continue; + } + var next = i + 1 < braces.Count ? braces[i + 1] : null; + if (next != null && next.Groups["close"].Success) + { + var length = next.Index + next.Length - brace.Index; + ctx.AddWarning(index, length, $"Invalid tag '{text.Substring(index, length)}'"); + i++; + } + else + { + var end = next != null ? gapStart + next.Index : gapEnd; + var length = Math.Min(MaxUnterminatedTagLength, end - index); + ctx.AddWarning(index, length, $"Tag '{text.Substring(index, length)}' is not terminated"); + } + } + } + } +} diff --git a/README.md b/README.md index 1ef1fdb..9d5d5bf 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,31 @@ _DocxTemplater is a library to generate docx documents from a docx template. The [![MIT](https://img.shields.io/github/license/Amberg/DocxTemplater)](https://github.com/Amberg/DocxTemplater/blob/main/LICENSE) [![CI-Build](https://github.com/Amberg/DocxTemplater/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Amberg/DocxTemplater/actions/workflows/ci.yml) +## Table of Contents + +- [Features](#features) +- [Quickstart](#quickstart) +- [Placeholder Syntax](#placeholder-syntax) + - [Quick Reference Examples](#quick-reference-examples) + - [Collections](#collections) + - [Range Loops](#range-loops) + - [Chart Data binding](#chart-data-binding) + - [Conditional Blocks](#conditional-blocks) + - [Switch / Case Blocks](#switch--case-blocks) + - [C# Expressions](#c-expressions) +- [Formatters](#formatters) +- [Image Formatter](#image-formatter) +- [Markdown Formatter](#markdown-formatter) +- [Sub-Template Formatter - Inserting Documents](#sub-template-formatter---inserting-documents) +- [Content Controls](#content-controls) +- [Whitespace Trimming Around Directives](#whitespace-trimming-around-directives) +- [Error Handling](#error-handling) +- [Culture](#culture) +- [Advanced Model Binding](#advanced-model-binding) +- [Template Schema Inspection](#template-schema-inspection) +- [Template Syntax Validation](#template-syntax-validation) +- [Support This Project](#support-this-project) + ## Features - Variable Replacement - Collections - Bind to collections @@ -17,6 +42,7 @@ _DocxTemplater is a library to generate docx documents from a docx template. The - Dynamic Tables - Columns are defined by the datasource - Content Controls - Fill Word content controls from the model, addressed by their tag - Template Schema - Statically inspect which variables a template expects, without rendering +- Syntax Validation - Check a template for syntax errors without rendering it ## Quickstart @@ -517,7 +543,9 @@ var docTemplate = new DocxTemplate(memStream, new ProcessSettings() var result = docTemplate.Process(); ``` -## Advanced Model Binding: `ITemplateModel` and `TemplateModelWithDisplayNames` +## Advanced Model Binding + +Two ways to control how placeholders are resolved against your model: `ITemplateModel` and `TemplateModelWithDisplayNames`. ### `ITemplateModel` Interface @@ -602,6 +630,34 @@ template.Save("generated.docx"); // render - reuses the cached analy - Dynamic tables (`:dyntable`): only the collection itself is reported, not its runtime-defined rows/columns. - String-key indexing (`props["key"]`): the key is not statically known and is not reflected in the schema. +## Template Syntax Validation + +`ValidateTemplateSyntax()` checks the template syntax **without rendering it and without a model** and returns all errors found (an empty list if the syntax is valid). The document is not modified. +The same parser runs at the start of `Process()`: errors with `Severity == Error` (broken block structure) make `Process()` throw an `OpenXmlTemplateException` listing all of them, while warnings (malformed tags, suspicious expressions) only show up here. + +```csharp +using var template = DocxTemplate.Open("template.docx"); +foreach (var error in template.ValidateTemplateSyntax()) +{ + // e.g. "Body: '{{/Orders}}' does not match '{{#Items}}' (near '...')" + Console.WriteLine(error); +} +``` + +Errors: +- Blocks that are not closed and closing tags without an opening tag. +- Else `{{:}}` outside of a condition or more than once, separator `{{:s:}}` outside of a collection loop, `{{#case}}`/`{{#default}}` outside of a switch, `{{#}}` / `{{#switch}}` without a name or expression, unknown inline keywords (`{{:Foo}}`). + +Warnings: +- Closing tags that do not match their opening tag (`{{#Items}}...{{/Orders}}`, `{?{...}}...{{/Items}}`, `{{:ignore}}...{{/}}`) - rendering closes the current block anyway. The closing tag may omit the implicit model prefix (`{{#ds.Items}}...{{/Items}}`). +- Malformed tags that silently remain as text, e.g. `{{Name}`, `{{first name}}`, a stray `}}`. +- Unbalanced parentheses and unterminated strings in conditions, expressions, switch selectors and case values. + +Everything between `{{:ignore}}` and `{{/:ignore}}` is treated as plain text and not validated. + +Each `TemplateSyntaxError` exposes the `Severity`, the `Part` (`Body`, `Header` or `Footer`), the offending `Tag`, a `Message` and the surrounding text as `Context`. +Binding errors (unknown variables, wrong types, unknown formatters) are not detected, as they depend on the model. + ## Support This Project If you find DocxTemplater useful, please consider supporting its development: