diff --git a/CLAUDE.md b/CLAUDE.md index 12dfb3e..e5bb818 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -253,7 +253,6 @@ The library uses a **visitor pattern** for processing Word documents, enabling: **Conditionals:** - `Conditionals/ConditionalBlock.cs` - Data structure for if/else blocks - `Conditionals/ConditionalDetector.cs` - Finds conditional blocks -- `Conditionals/ConditionalProcessor.cs` - Legacy processor (kept for reference) - `Conditionals/ConditionalEvaluator.cs` - Evaluates expressions with operators - `Conditionals/IConditionEvaluator.cs` - Public interface for standalone condition evaluation - `Conditionals/ConditionEvaluator.cs` - Public implementation of standalone evaluator @@ -263,7 +262,6 @@ The library uses a **visitor pattern** for processing Word documents, enabling: **Loops:** - `Loops/LoopBlock.cs` - Data structure for loop blocks - `Loops/LoopDetector.cs` - Finds foreach blocks -- `Loops/LoopProcessor.cs` - Legacy processor (kept for reference) - `Loops/LoopContext.cs` - Loop iteration state - `Loops/LoopEvaluationContext.cs` - Loop-scoped variable resolution @@ -271,8 +269,6 @@ The library uses a **visitor pattern** for processing Word documents, enabling: - `Placeholders/PlaceholderFinder.cs` - Pattern matching for {{placeholders}} - `Placeholders/ValueResolver.cs` - Variable lookup - `Placeholders/ValueConverter.cs` - Type conversion to strings -- `Placeholders/DocumentBodyReplacer.cs` - Legacy body replacer -- `Placeholders/TableReplacer.cs` - Legacy table replacer **Property Paths:** - `PropertyPaths/PropertyPath.cs` - Parsed property path representation diff --git a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs index 9a9c1a0..41f3250 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs @@ -225,6 +225,63 @@ private AbstractNum CreateNumberedListDefinition(int abstractNumId) return abstractNum; } + /// + /// Adds a table where each cell can contain multiple paragraphs. + /// Useful for reproducing block-level conditionals (e.g. {{#if}} / {{/if}} on + /// separate paragraphs) whose entire content is a single table cell. + /// + /// Number of rows. + /// Number of columns. + /// Returns the paragraph texts for cell (row, col). One paragraph per string. + public DocumentBuilder AddTableWithCellParagraphs( + int rows, + int columns, + Func cellParagraphsProvider) + { + Table table = new Table(); + + TableProperties tableProperties = new TableProperties(); + TableBorders tableBorders = new TableBorders( + new TopBorder { Val = new EnumValue(BorderValues.Single), Size = 4 }, + new BottomBorder { Val = new EnumValue(BorderValues.Single), Size = 4 }, + new LeftBorder { Val = new EnumValue(BorderValues.Single), Size = 4 }, + new RightBorder { Val = new EnumValue(BorderValues.Single), Size = 4 }, + new InsideHorizontalBorder { Val = new EnumValue(BorderValues.Single), Size = 4 }, + new InsideVerticalBorder { Val = new EnumValue(BorderValues.Single), Size = 4 } + ); + tableProperties.Append(tableBorders); + table.Append(tableProperties); + + for (int row = 0; row < rows; row++) + { + TableRow tableRow = new TableRow(); + + for (int col = 0; col < columns; col++) + { + TableCell cell = new TableCell(); + + foreach (string paragraphText in cellParagraphsProvider(row, col)) + { + Paragraph paragraph = new Paragraph(); + Run run = new Run(); + Text text = new Text(paragraphText); + text.Space = SpaceProcessingModeValues.Preserve; + run.Append(text); + paragraph.Append(run); + cell.Append(paragraph); + } + + tableRow.Append(cell); + } + + table.Append(tableRow); + } + + _body.Append(table); + + return this; + } + /// /// Adds a table with the specified number of rows and columns. /// diff --git a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs index 44ead26..89faf2e 100644 --- a/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs +++ b/TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs @@ -203,6 +203,54 @@ public List> GetTableCellTexts(int tableIndex) return result; } + /// + /// Gets the number of paragraphs directly inside a specific table cell. + /// + public int GetTableCellParagraphCount(int tableIndex, int rowIndex, int columnIndex) + { + return GetTableCell(tableIndex, rowIndex, columnIndex).Elements().Count(); + } + + /// + /// Determines whether a specific table cell's last child element is a paragraph. + /// Per ECMA-376 §17.4.66, a table cell must contain at least one block-level + /// element and must end with a paragraph, otherwise the OOXML is invalid. + /// + public bool DoesTableCellEndWithParagraph(int tableIndex, int rowIndex, int columnIndex) + { + return GetTableCell(tableIndex, rowIndex, columnIndex).LastChild is Paragraph; + } + + /// + /// Navigates to a specific table cell, validating all indexes. + /// + private TableCell GetTableCell(int tableIndex, int rowIndex, int columnIndex) + { + IEnumerable tables = _body.Elements
(); + + if (tableIndex < 0 || tableIndex >= tables.Count()) + { + throw new ArgumentOutOfRangeException(nameof(tableIndex)); + } + + Table table = tables.ElementAt(tableIndex); + List rows = table.Elements().ToList(); + + if (rowIndex < 0 || rowIndex >= rows.Count) + { + throw new ArgumentOutOfRangeException(nameof(rowIndex)); + } + + List cells = rows[rowIndex].Elements().ToList(); + + if (columnIndex < 0 || columnIndex >= cells.Count) + { + throw new ArgumentOutOfRangeException(nameof(columnIndex)); + } + + return cells[columnIndex]; + } + /// /// Gets the RunProperties from a specific cell in a table. /// diff --git a/TriasDev.Templify.Tests/Integration/ConditionalTableCellTests.cs b/TriasDev.Templify.Tests/Integration/ConditionalTableCellTests.cs new file mode 100644 index 0000000..54b0115 --- /dev/null +++ b/TriasDev.Templify.Tests/Integration/ConditionalTableCellTests.cs @@ -0,0 +1,174 @@ +// Copyright (c) 2025 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Core; +using TriasDev.Templify.Tests.Helpers; + +namespace TriasDev.Templify.Tests.Integration; + +/// +/// Integration tests for conditional blocks that make up the entire content of a table cell. +/// Regression tests for issue #117: removing a branch must not leave an empty <w:tc>, +/// which is invalid OOXML (ECMA-376 §17.4.66 requires a cell to end with a paragraph). +/// +public sealed class ConditionalTableCellTests +{ + [Fact] + public void ProcessTemplate_ConditionalIsEntireCellContent_ConditionFalse_CellKeepsParagraph() + { + // Arrange: a 1x1 table whose only cell content is a block conditional. + DocumentBuilder builder = new DocumentBuilder(); + builder.AddTableWithCellParagraphs(1, 1, (row, col) => new[] + { + "{{#if ShowX}}", + "X", + "{{/if}}" + }); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["ShowX"] = false + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + Assert.Equal(1, verifier.GetTableCount()); + + // The X content is gone... + Assert.Equal(string.Empty, verifier.GetTableCellText(0, 0, 0)); + + // ...but the cell must remain a valid OOXML cell: at least one paragraph, ending with one. + Assert.True( + verifier.DoesTableCellEndWithParagraph(0, 0, 0), + "Table cell must end with a element, otherwise the OOXML is invalid."); + Assert.Equal(1, verifier.GetTableCellParagraphCount(0, 0, 0)); + } + + [Fact] + public void ProcessTemplate_ConditionalIsEntireCellContent_ConditionTrue_CellKeepsContent() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddTableWithCellParagraphs(1, 1, (row, col) => new[] + { + "{{#if ShowX}}", + "X", + "{{/if}}" + }); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["ShowX"] = true + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + Assert.Equal("X", verifier.GetTableCellText(0, 0, 0)); + Assert.True(verifier.DoesTableCellEndWithParagraph(0, 0, 0)); + } + + [Fact] + public void ProcessTemplate_NestedConditionalsAreEntireCellContent_AllFalse_CellKeepsParagraph() + { + // Arrange: mirrors the issue #117 reproduction (nested {{#if}} matrix cell). + DocumentBuilder builder = new DocumentBuilder(); + builder.AddTableWithCellParagraphs(1, 1, (row, col) => new[] + { + "{{#if a = 1}}", + "{{#if b = 4}}", + "X", + "{{/if}}", + "{{/if}}" + }); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["a"] = 2, + ["b"] = 3 + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + Assert.Equal(string.Empty, verifier.GetTableCellText(0, 0, 0)); + Assert.True( + verifier.DoesTableCellEndWithParagraph(0, 0, 0), + "Nested-conditional matrix cell must still end with a when no branch matches."); + Assert.Equal(1, verifier.GetTableCellParagraphCount(0, 0, 0)); + } + + [Fact] + public void ProcessTemplate_MatrixOfConditionalCells_OnlyMatchingCellKeepsX_AllCellsStayValid() + { + // Arrange: 2x2 matrix, each cell an independent conditional keyed on its coordinates. + // Only cell (0,0) matches; the other three must remain valid (non-empty) cells. + DocumentBuilder builder = new DocumentBuilder(); + builder.AddTableWithCellParagraphs(2, 2, (row, col) => new[] + { + $"{{{{#if Match_{row}_{col}}}}}", + "X", + "{{/if}}" + }); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Match_0_0"] = true, + ["Match_0_1"] = false, + ["Match_1_0"] = false, + ["Match_1_1"] = false + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + Assert.Equal("X", verifier.GetTableCellText(0, 0, 0)); + + for (int row = 0; row < 2; row++) + { + for (int col = 0; col < 2; col++) + { + Assert.True( + verifier.DoesTableCellEndWithParagraph(0, row, col), + $"Cell ({row},{col}) must end with a element."); + } + } + } +} diff --git a/TriasDev.Templify/ARCHITECTURE.md b/TriasDev.Templify/ARCHITECTURE.md index b31fed8..db07463 100644 --- a/TriasDev.Templify/ARCHITECTURE.md +++ b/TriasDev.Templify/ARCHITECTURE.md @@ -335,6 +335,13 @@ graph TD ## Core Components +> **Note (December 2025):** The classes `DocumentBodyReplacer` (§3), `TableReplacer` (§4), +> `LoopProcessor` (§7), and `ConditionalProcessor` were **removed** from the codebase. They +> were the pre-Phase 2 processing path and had been kept only for reference after the visitor +> pattern superseded them. Their responsibilities now live in `PlaceholderVisitor`, +> `LoopVisitor`, and `ConditionalVisitor` (see *Current Architecture* above). The sections +> below are retained as a description of the historical design. + ### 1. DocumentTemplateProcessor **Purpose**: Main entry point and orchestrator diff --git a/TriasDev.Templify/Conditionals/ConditionalProcessor.cs b/TriasDev.Templify/Conditionals/ConditionalProcessor.cs deleted file mode 100644 index 76f439c..0000000 --- a/TriasDev.Templify/Conditionals/ConditionalProcessor.cs +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) 2025 TriasDev GmbH & Co. KG -// Licensed under the MIT License. See LICENSE file in the project root for full license information. - -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; -using TriasDev.Templify.Core; - -namespace TriasDev.Templify.Conditionals; - -/// -/// Processes conditional blocks by evaluating conditions and removing false branches. -/// -internal sealed class ConditionalProcessor -{ - private readonly ConditionalEvaluator _evaluator; - - public ConditionalProcessor() - { - _evaluator = new ConditionalEvaluator(); - } - - /// - /// Processes all conditional blocks in the document. - /// Processes nested conditionals from deepest to shallowest to ensure inner blocks are evaluated first. - /// - public void ProcessConditionals( - WordprocessingDocument document, - IEvaluationContext context) - { - // Detect all conditionals (including nested ones) - IReadOnlyList conditionals = ConditionalDetector.DetectConditionals(document); - - // Sort conditionals by nesting level (deepest first) to process inner blocks before outer blocks - // This ensures that nested conditionals are evaluated and cleaned up before their parent blocks - List sortedConditionals = conditionals - .OrderByDescending(c => c.NestingLevel) - .ThenBy(c => c.StartMarker.GetHashCode()) // Secondary sort for stable ordering - .ToList(); - - // Process each conditional block - foreach (ConditionalBlock conditional in sortedConditionals) - { - // Skip if the conditional's markers have already been removed by a parent conditional - if (conditional.StartMarker.Parent == null || conditional.EndMarker.Parent == null) - { - continue; - } - - ProcessConditionalBlock(conditional, context); - } - } - - /// - /// Processes a single conditional block. - /// - internal void ProcessConditionalBlock(ConditionalBlock conditional, IEvaluationContext context) - { - // Evaluate the condition - bool conditionResult = _evaluator.Evaluate(conditional.ConditionExpression, context); - - if (conditionResult) - { - // Condition is TRUE: Keep IF branch, remove ELSE branch - ProcessTrueBranch(conditional); - } - else - { - // Condition is FALSE: Remove IF branch, keep ELSE branch - ProcessFalseBranch(conditional); - } - } - - /// - /// Processes conditional when condition is TRUE. - /// Keeps IF content, removes ELSE content and all markers. - /// - private void ProcessTrueBranch(ConditionalBlock conditional) - { - // Remove the start marker (if it still has a parent) - if (conditional.StartMarker.Parent != null) - { - conditional.StartMarker.Remove(); - } - - // Remove ELSE content elements (if any) - foreach (OpenXmlElement element in conditional.ElseContentElements) - { - // Skip if already removed by a nested conditional - if (element.Parent != null) - { - element.Remove(); - } - } - - // Remove the else marker (if any and if it still has a parent) - if (conditional.ElseMarker?.Parent != null) - { - conditional.ElseMarker.Remove(); - } - - // Remove the end marker (if it still has a parent) - if (conditional.EndMarker.Parent != null) - { - conditional.EndMarker.Remove(); - } - - // IF content elements remain in the document - } - - /// - /// Processes conditional when condition is FALSE. - /// Removes IF content, keeps ELSE content (if any), removes all markers. - /// - private void ProcessFalseBranch(ConditionalBlock conditional) - { - // Remove the start marker (if it still has a parent) - if (conditional.StartMarker.Parent != null) - { - conditional.StartMarker.Remove(); - } - - // Remove IF content elements - foreach (OpenXmlElement element in conditional.IfContentElements) - { - // Skip if already removed by a nested conditional - if (element.Parent != null) - { - element.Remove(); - } - } - - // Remove the else marker (if any and if it still has a parent) - if (conditional.ElseMarker?.Parent != null) - { - conditional.ElseMarker.Remove(); - } - - // Remove the end marker (if it still has a parent) - if (conditional.EndMarker.Parent != null) - { - conditional.EndMarker.Remove(); - } - - // ELSE content elements (if any) remain in the document - // If there's no else branch, nothing remains (which is correct) - } -} diff --git a/TriasDev.Templify/Loops/LoopProcessor.cs b/TriasDev.Templify/Loops/LoopProcessor.cs deleted file mode 100644 index 68bb362..0000000 --- a/TriasDev.Templify/Loops/LoopProcessor.cs +++ /dev/null @@ -1,648 +0,0 @@ -// Copyright (c) 2025 TriasDev GmbH & Co. KG -// Licensed under the MIT License. See LICENSE file in the project root for full license information. - -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Wordprocessing; -using System.Collections; -using TriasDev.Templify.Conditionals; -using TriasDev.Templify.Core; -using TriasDev.Templify.Placeholders; -using TriasDev.Templify.Utilities; - -namespace TriasDev.Templify.Loops; - -/// -/// Processes loop blocks by cloning content for each item in the collection. -/// -internal sealed class LoopProcessor -{ - private readonly ValueResolver _valueResolver; - private readonly PlaceholderReplacementOptions _options; - private readonly PlaceholderFinder _finder; - private readonly IEvaluationContext _globalContext; - - public LoopProcessor(PlaceholderReplacementOptions options, IEvaluationContext globalContext) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - _globalContext = globalContext ?? throw new ArgumentNullException(nameof(globalContext)); - _valueResolver = new ValueResolver(); - _finder = new PlaceholderFinder(); - } - - /// - /// Processes all loop blocks in the document. - /// Returns the total number of replacements made. - /// - public int ProcessLoops( - IReadOnlyList loops, - Dictionary data, - List missingVariables) - { - int totalReplacements = 0; - - foreach (LoopBlock loop in loops) - { - totalReplacements += ProcessLoop(loop, data, missingVariables); - } - - return totalReplacements; - } - - /// - /// Processes a single loop block. - /// - private int ProcessLoop( - LoopBlock loop, - Dictionary data, - List missingVariables) - { - // Resolve the collection - if (!_valueResolver.TryResolveValue(data, loop.CollectionName, out object? collectionObj)) - { - HandleMissingCollection(loop.CollectionName, missingVariables); - RemoveLoopBlock(loop); - return 0; - } - - if (collectionObj is not IEnumerable collection) - { - throw new InvalidOperationException( - $"Variable '{loop.CollectionName}' is not a collection. Cannot iterate."); - } - - // Create loop contexts - IReadOnlyList contexts = LoopContext.CreateContexts( - collection, - loop.CollectionName, - parent: null); - - // Handle empty collection - if (contexts.Count == 0) - { - RemoveLoopBlock(loop); - return 0; - } - - // Process each iteration - int totalReplacements = 0; - OpenXmlElement? insertionPoint = loop.EndMarker; - - for (int i = contexts.Count - 1; i >= 0; i--) - { - LoopContext context = contexts[i]; - - // Clone content elements - List clonedElements = new List(); - foreach (OpenXmlElement contentElement in loop.ContentElements) - { - OpenXmlElement clonedElement = (OpenXmlElement)contentElement.CloneNode(true); - clonedElements.Add(clonedElement); - } - - // Process nested loops in the cloned content (within this loop's context) - totalReplacements += ProcessNestedLoops(clonedElements, context, data, missingVariables); - - // Process conditionals in the cloned content with loop context (enables conditionals in loops) - ProcessConditionalsInLoopIteration(clonedElements, context); - - // Process placeholders in the cloned elements - foreach (OpenXmlElement clonedElement in clonedElements) - { - totalReplacements += ProcessPlaceholdersInElement( - clonedElement, - context, - data, - missingVariables); - - // Insert after the end marker - insertionPoint.InsertAfterSelf(clonedElement); - } - } - - // Remove the loop block (markers and original content) - RemoveLoopBlock(loop); - - return totalReplacements; - } - - /// - /// Processes placeholders in an element using the loop context. - /// - private int ProcessPlaceholdersInElement( - OpenXmlElement element, - LoopContext context, - Dictionary data, - List missingVariables) - { - int replacementCount = 0; - - if (element is Paragraph paragraph) - { - replacementCount += ProcessParagraph(paragraph, context, data, missingVariables); - } - else if (element is TableRow tableRow) - { - replacementCount += ProcessTableRow(tableRow, context, data, missingVariables); - } - else if (element is Table table) - { - replacementCount += ProcessTable(table, context, data, missingVariables); - } - - return replacementCount; - } - - /// - /// Processes placeholders in a paragraph. - /// - private int ProcessParagraph( - Paragraph paragraph, - LoopContext context, - Dictionary data, - List missingVariables) - { - int replacementCount = 0; - string originalText = paragraph.InnerText; - IReadOnlyList matches = _finder.FindPlaceholdersAsList(originalText); - - if (matches.Count == 0) - { - return 0; - } - - // Build replacement text - string replacedText = originalText; - int offset = 0; - - foreach (PlaceholderMatch match in matches) - { - object? value = null; - bool resolved = false; - - // Try to resolve from loop context first - if (context.TryResolveVariable(match.VariableName, out value)) - { - resolved = true; - } - // Try to resolve from root data - else if (_valueResolver.TryResolveValue(data, match.VariableName, out value)) - { - resolved = true; - } - - if (resolved) - { - string replacementValue = ValueConverter.ConvertToString(value, _options.Culture); - int adjustedIndex = match.StartIndex + offset; - replacedText = replacedText.Remove(adjustedIndex, match.Length) - .Insert(adjustedIndex, replacementValue); - offset += replacementValue.Length - match.Length; - replacementCount++; - } - else - { - HandleMissingVariable(match.VariableName, missingVariables); - } - } - - // Update paragraph text if changes were made - if (replacementCount > 0) - { - UpdateParagraphText(paragraph, replacedText); - } - - return replacementCount; - } - - /// - /// Processes placeholders in a table. - /// - private int ProcessTable( - Table table, - LoopContext context, - Dictionary data, - List missingVariables) - { - int replacementCount = 0; - - foreach (TableRow row in table.Elements()) - { - replacementCount += ProcessTableRow(row, context, data, missingVariables); - } - - return replacementCount; - } - - /// - /// Processes placeholders in a table row. - /// - private int ProcessTableRow( - TableRow tableRow, - LoopContext context, - Dictionary data, - List missingVariables) - { - int replacementCount = 0; - - foreach (TableCell cell in tableRow.Elements()) - { - foreach (Paragraph paragraph in cell.Elements()) - { - replacementCount += ProcessParagraph(paragraph, context, data, missingVariables); - } - } - - return replacementCount; - } - - /// - /// Updates the text content of a paragraph. - /// Preserves formatting (RunProperties) from the original runs. - /// - private void UpdateParagraphText(Paragraph paragraph, string newText) - { - // Extract and clone formatting from the original runs before removing them - List runs = paragraph.Elements().ToList(); - RunProperties? clonedProperties = FormattingPreserver.ExtractAndCloneRunProperties(runs); - - // Remove all runs - paragraph.RemoveAllChildren(); - - // Create a new run with the updated text - Run newRun = new Run(); - Text text = new Text(newText); - text.Space = SpaceProcessingModeValues.Preserve; - newRun.Append(text); - - // Apply the preserved formatting to the new run - FormattingPreserver.ApplyRunProperties(newRun, clonedProperties); - - paragraph.Append(newRun); - } - - /// - /// Removes a loop block from the document (markers and content). - /// - private void RemoveLoopBlock(LoopBlock loop) - { - // Remove start marker - loop.StartMarker.Remove(); - - // Remove content elements - foreach (OpenXmlElement element in loop.ContentElements) - { - element.Remove(); - } - - // Remove end marker - loop.EndMarker.Remove(); - } - - private void HandleMissingVariable(string variableName, List missingVariables) - { - if (!missingVariables.Contains(variableName)) - { - missingVariables.Add(variableName); - } - - if (_options.MissingVariableBehavior == MissingVariableBehavior.ThrowException) - { - throw new InvalidOperationException($"Variable '{variableName}' not found in data."); - } - } - - private void HandleMissingCollection(string collectionName, List missingVariables) - { - if (!missingVariables.Contains(collectionName)) - { - missingVariables.Add(collectionName); - } - - if (_options.MissingVariableBehavior == MissingVariableBehavior.ThrowException) - { - throw new InvalidOperationException($"Collection '{collectionName}' not found in data."); - } - } - - /// - /// Processes nested loops within cloned elements using the current loop context. - /// This enables nested loop support by processing inner loops with the outer loop's current item. - /// - private int ProcessNestedLoops( - List elements, - LoopContext outerContext, - Dictionary rootData, - List missingVariables) - { - int totalReplacements = 0; - - // Detect nested loops in the cloned elements - List nestedLoops = new List(); - int i = 0; - - while (i < elements.Count) - { - string? text = GetElementText(elements[i]); - if (text != null && text.Contains("{{#foreach")) - { - // Try to find a loop starting at this element - LoopBlock? nestedLoop = TryDetectLoop(elements, i); - if (nestedLoop != null) - { - nestedLoops.Add(nestedLoop); - i = elements.IndexOf(nestedLoop.EndMarker) + 1; - continue; - } - } - i++; - } - - // Process each nested loop - foreach (LoopBlock nestedLoop in nestedLoops) - { - totalReplacements += ProcessNestedLoop(nestedLoop, outerContext, rootData, missingVariables, elements); - } - - return totalReplacements; - } - - /// - /// Tries to detect a loop starting at the specified index in the elements list. - /// - private LoopBlock? TryDetectLoop(List elements, int startIndex) - { - string? startText = GetElementText(elements[startIndex]); - if (startText == null) - { - return null; - } - - // Check for {{#foreach CollectionName}} - System.Text.RegularExpressions.Match match = System.Text.RegularExpressions.Regex.Match( - startText, - @"\{\{#foreach\s+(\w+)\}\}", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - - if (!match.Success) - { - return null; - } - - string collectionName = match.Groups[1].Value; - - // Find matching end marker - int endIndex = FindMatchingEndInElements(elements, startIndex); - if (endIndex == -1) - { - return null; - } - - // Get content elements - List contentElements = new List(); - for (int j = startIndex + 1; j < endIndex; j++) - { - contentElements.Add(elements[j]); - } - - return new LoopBlock( - collectionName, - iterationVariableName: null, - contentElements, - elements[startIndex], - elements[endIndex], - isTableRowLoop: false, - emptyBlock: null); - } - - /// - /// Finds the matching {{/foreach}} for a {{#foreach}} at the given index. - /// - private int FindMatchingEndInElements(List elements, int startIndex) - { - int depth = 1; - - for (int i = startIndex + 1; i < elements.Count; i++) - { - string? text = GetElementText(elements[i]); - if (text == null) - { - continue; - } - - if (System.Text.RegularExpressions.Regex.IsMatch(text, @"\{\{#foreach\s+\w+\}\}", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - { - depth++; - } - else if (System.Text.RegularExpressions.Regex.IsMatch(text, @"\{\{/foreach\}\}", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - { - depth--; - if (depth == 0) - { - return i; - } - } - } - - return -1; - } - - /// - /// Processes a nested loop within the context of an outer loop. - /// - private int ProcessNestedLoop( - LoopBlock loop, - LoopContext outerContext, - Dictionary rootData, - List missingVariables, - List parentElements) - { - // Try to resolve the collection from the outer loop context first - object? collectionObj = null; - bool resolved = false; - - if (outerContext.TryResolveVariable(loop.CollectionName, out collectionObj)) - { - resolved = true; - } - else if (_valueResolver.TryResolveValue(rootData, loop.CollectionName, out collectionObj)) - { - resolved = true; - } - - if (!resolved || collectionObj is not IEnumerable collection) - { - HandleMissingCollection(loop.CollectionName, missingVariables); - RemoveLoopBlockFromList(loop, parentElements); - return 0; - } - - // Create loop contexts for the nested loop - IReadOnlyList contexts = LoopContext.CreateContexts( - collection, - loop.CollectionName, - parent: outerContext); - - if (contexts.Count == 0) - { - RemoveLoopBlockFromList(loop, parentElements); - return 0; - } - - // Process each iteration - int totalReplacements = 0; - int insertIndex = parentElements.IndexOf(loop.EndMarker); - List allClonedElements = new List(); - - for (int i = contexts.Count - 1; i >= 0; i--) - { - LoopContext context = contexts[i]; - - // Clone content elements - List clonedElements = new List(); - foreach (OpenXmlElement contentElement in loop.ContentElements) - { - OpenXmlElement clonedElement = (OpenXmlElement)contentElement.CloneNode(true); - clonedElements.Add(clonedElement); - } - - // Recursively process nested loops within this iteration - totalReplacements += ProcessNestedLoops(clonedElements, context, rootData, missingVariables); - - // Process conditionals in the cloned content with loop context (enables conditionals in nested loops) - ProcessConditionalsInLoopIteration(clonedElements, context); - - // Process placeholders in the cloned elements - foreach (OpenXmlElement clonedElement in clonedElements) - { - totalReplacements += ProcessPlaceholdersInElement( - clonedElement, - context, - rootData, - missingVariables); - - allClonedElements.Add(clonedElement); - } - } - - // Insert all cloned elements at the appropriate position - parentElements.InsertRange(insertIndex, allClonedElements); - - // Remove the loop block from the parent elements list - RemoveLoopBlockFromList(loop, parentElements); - - return totalReplacements; - } - - /// - /// Removes a loop block from a list of elements. - /// - private void RemoveLoopBlockFromList(LoopBlock loop, List elements) - { - elements.Remove(loop.StartMarker); - foreach (OpenXmlElement element in loop.ContentElements) - { - elements.Remove(element); - } - elements.Remove(loop.EndMarker); - } - - /// - /// Gets the text content of an element. - /// - private string? GetElementText(OpenXmlElement element) - { - if (element is Paragraph paragraph) - { - return paragraph.InnerText; - } - - if (element is TableRow row) - { - return row.InnerText; - } - - if (element is Table table) - { - return table.InnerText; - } - - return null; - } - - /// - /// Processes conditionals in cloned loop content with loop context. - /// This enables conditionals inside loops to access loop-scoped variables and metadata. - /// - /// - /// Since clonedElements are not yet inserted into the document, we can't use Remove(). - /// Instead, we process conditionals by modifying the clonedElements list directly. - /// - private void ProcessConditionalsInLoopIteration( - List clonedElements, - LoopContext loopContext) - { - // Create loop evaluation context that chains to global context - LoopEvaluationContext loopEvalContext = new LoopEvaluationContext(loopContext, _globalContext); - - // Detect conditionals in the cloned elements - IReadOnlyList conditionals = ConditionalDetector.DetectConditionalsInElements(clonedElements); - - if (conditionals.Count == 0) - { - return; - } - - // Create conditional evaluator - ConditionalEvaluator evaluator = new ConditionalEvaluator(); - - // Process conditionals from deepest to shallowest (same as top-level processing) - List sortedConditionals = conditionals - .OrderByDescending(c => c.NestingLevel) - .ToList(); - - // Collect elements to remove (can't modify list while iterating) - HashSet elementsToRemove = new HashSet(); - - foreach (ConditionalBlock conditional in sortedConditionals) - { - // Skip if markers already marked for removal by nested conditional - if (elementsToRemove.Contains(conditional.StartMarker) || - elementsToRemove.Contains(conditional.EndMarker)) - { - continue; - } - - // Evaluate the condition with loop context - bool conditionResult = evaluator.Evaluate(conditional.ConditionExpression, loopEvalContext); - - // Mark elements for removal based on condition result - elementsToRemove.Add(conditional.StartMarker); // Always remove start marker - elementsToRemove.Add(conditional.EndMarker); // Always remove end marker - - if (conditional.ElseMarker != null) - { - elementsToRemove.Add(conditional.ElseMarker); // Always remove else marker - } - - if (conditionResult) - { - // Condition TRUE: keep IF content, remove ELSE content - foreach (OpenXmlElement element in conditional.ElseContentElements) - { - elementsToRemove.Add(element); - } - } - else - { - // Condition FALSE: remove IF content, keep ELSE content - foreach (OpenXmlElement element in conditional.IfContentElements) - { - elementsToRemove.Add(element); - } - } - } - - // Remove marked elements from clonedElements list - clonedElements.RemoveAll(e => elementsToRemove.Contains(e)); - } -} diff --git a/TriasDev.Templify/Placeholders/DocumentBodyReplacer.cs b/TriasDev.Templify/Placeholders/DocumentBodyReplacer.cs deleted file mode 100644 index 918a08f..0000000 --- a/TriasDev.Templify/Placeholders/DocumentBodyReplacer.cs +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) 2025 TriasDev GmbH & Co. KG -// Licensed under the MIT License. See LICENSE file in the project root for full license information. - -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using TriasDev.Templify.Core; -using TriasDev.Templify.Utilities; - -namespace TriasDev.Templify.Placeholders; - -/// -/// Handles placeholder replacement in document body paragraphs. -/// -internal sealed class DocumentBodyReplacer -{ - private readonly PlaceholderFinder _finder; - private readonly ValueResolver _valueResolver; - - public DocumentBodyReplacer(PlaceholderFinder finder, ValueResolver valueResolver) - { - _finder = finder ?? throw new ArgumentNullException(nameof(finder)); - _valueResolver = valueResolver ?? throw new ArgumentNullException(nameof(valueResolver)); - } - - /// - /// Replaces placeholders in the document body. - /// - /// The Word document to process. - /// The data dictionary containing replacement values. - /// Processing options. - /// Collection to track missing variables. - /// The number of replacements made. - public int ReplaceInBody( - WordprocessingDocument document, - Dictionary data, - PlaceholderReplacementOptions options, - HashSet missingVariables) - { - if (document.MainDocumentPart?.Document?.Body == null) - { - return 0; - } - - int replacementCount = 0; - Body body = document.MainDocumentPart.Document.Body; - - // Process all paragraphs in the body - IEnumerable paragraphs = body.Descendants(); - - foreach (Paragraph paragraph in paragraphs) - { - replacementCount += ProcessParagraph(paragraph, data, options, missingVariables); - } - - return replacementCount; - } - - /// - /// Processes a single paragraph, replacing placeholders in its text. - /// - internal int ProcessParagraph( - Paragraph paragraph, - Dictionary data, - PlaceholderReplacementOptions options, - HashSet missingVariables) - { - // Get all text runs in the paragraph - List runs = paragraph.Descendants().ToList(); - - if (runs.Count == 0) - { - return 0; - } - - // Concatenate all text from runs - string fullText = string.Concat(runs.Select(r => r.InnerText)); - - // Find all placeholders - List matches = _finder.FindPlaceholders(fullText).ToList(); - - if (matches.Count == 0) - { - return 0; - } - - // Replace placeholders in the full text - string replacedText = fullText; - int replacementCount = 0; - - // Process matches in reverse order to maintain correct indices - foreach (PlaceholderMatch match in matches.OrderByDescending(m => m.StartIndex)) - { - if (_valueResolver.TryResolveValue(data, match.VariableName, out object? value)) - { - string replacementValue = ValueConverter.ConvertToString(value, options.Culture); - replacedText = replacedText.Remove(match.StartIndex, match.Length) - .Insert(match.StartIndex, replacementValue); - replacementCount++; - } - else - { - // Handle missing variable based on options - missingVariables.Add(match.VariableName); - - switch (options.MissingVariableBehavior) - { - case MissingVariableBehavior.ReplaceWithEmpty: - replacedText = replacedText.Remove(match.StartIndex, match.Length); - replacementCount++; - break; - - case MissingVariableBehavior.ThrowException: - throw new InvalidOperationException($"Missing variable: {match.VariableName}"); - - case MissingVariableBehavior.LeaveUnchanged: - default: - // Leave the placeholder as-is - break; - } - } - } - - // If text changed, update the paragraph - if (replacedText != fullText) - { - UpdateParagraphText(paragraph, runs, replacedText); - } - - return replacementCount; - } - - /// - /// Updates the paragraph text by removing old runs and creating a new run with the replaced text. - /// Preserves formatting (RunProperties) from the original runs. - /// - private static void UpdateParagraphText(Paragraph paragraph, List runs, string newText) - { - // Extract and clone formatting from the original runs before removing them - RunProperties? clonedProperties = FormattingPreserver.ExtractAndCloneRunProperties(runs); - - // Remove all existing runs - foreach (Run run in runs) - { - run.Remove(); - } - - // Create a new run with the replaced text - Text text = new Text(newText); - text.Space = SpaceProcessingModeValues.Preserve; - Run newRun = new Run(text); - - // Apply the preserved formatting to the new run - FormattingPreserver.ApplyRunProperties(newRun, clonedProperties); - - // Insert the new run at the beginning of the paragraph - paragraph.AppendChild(newRun); - } -} diff --git a/TriasDev.Templify/Placeholders/TableReplacer.cs b/TriasDev.Templify/Placeholders/TableReplacer.cs deleted file mode 100644 index a64608f..0000000 --- a/TriasDev.Templify/Placeholders/TableReplacer.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) 2025 TriasDev GmbH & Co. KG -// Licensed under the MIT License. See LICENSE file in the project root for full license information. - -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using TriasDev.Templify.Core; - -namespace TriasDev.Templify.Placeholders; - -/// -/// Handles placeholder replacement in table cells. -/// -internal sealed class TableReplacer -{ - private readonly DocumentBodyReplacer _bodyReplacer; - - public TableReplacer(DocumentBodyReplacer bodyReplacer) - { - _bodyReplacer = bodyReplacer ?? throw new ArgumentNullException(nameof(bodyReplacer)); - } - - /// - /// Replaces placeholders in all tables in the document. - /// - /// The Word document to process. - /// The data dictionary containing replacement values. - /// Processing options. - /// Collection to track missing variables. - /// The number of replacements made. - public int ReplaceInTables( - WordprocessingDocument document, - Dictionary data, - PlaceholderReplacementOptions options, - HashSet missingVariables) - { - if (document.MainDocumentPart?.Document?.Body == null) - { - return 0; - } - - int replacementCount = 0; - Body body = document.MainDocumentPart.Document.Body; - - // Process all tables in the body - IEnumerable
tables = body.Descendants
(); - - foreach (Table table in tables) - { - replacementCount += ProcessTable(table, data, options, missingVariables); - } - - return replacementCount; - } - - /// - /// Processes a single table, replacing placeholders in all cells. - /// - private int ProcessTable( - Table table, - Dictionary data, - PlaceholderReplacementOptions options, - HashSet missingVariables) - { - int replacementCount = 0; - - // Get all table cells - IEnumerable cells = table.Descendants(); - - foreach (TableCell cell in cells) - { - // Process each paragraph in the cell - IEnumerable paragraphs = cell.Descendants(); - - foreach (Paragraph paragraph in paragraphs) - { - replacementCount += _bodyReplacer.ProcessParagraph(paragraph, data, options, missingVariables); - } - } - - return replacementCount; - } -} diff --git a/TriasDev.Templify/Visitors/ConditionalVisitor.cs b/TriasDev.Templify/Visitors/ConditionalVisitor.cs index 485b92f..1c9fca9 100644 --- a/TriasDev.Templify/Visitors/ConditionalVisitor.cs +++ b/TriasDev.Templify/Visitors/ConditionalVisitor.cs @@ -17,12 +17,10 @@ namespace TriasDev.Templify.Visitors; /// Evaluates conditions and removes non-matching branches. /// /// -/// This visitor wraps the logic from ConditionalProcessor into the visitor pattern. /// Benefits: /// - Works with DocumentWalker for unified traversal /// - Context-aware (uses IEvaluationContext from Phase 1) /// - Can be composed with other visitors -/// - Eliminates duplication between ConditionalProcessor and LoopProcessor /// internal sealed class ConditionalVisitor : ITemplateElementVisitor { @@ -90,6 +88,10 @@ public void VisitConditional(ConditionalBlock conditional, IEvaluationContext co /// private void ProcessBranches(ConditionalBlock conditional, ConditionalBranch? matchingBranch) { + // Table cells that could be emptied by this block's removals. + // Captured BEFORE mutating the tree, so ancestor lookups still resolve. + HashSet affectedCells = CollectAncestorCells(conditional); + // Remove all branch markers foreach (ConditionalBranch branch in conditional.Branches) { @@ -108,7 +110,64 @@ private void ProcessBranches(ConditionalBlock conditional, ConditionalBranch? ma // Remove the end marker TemplateElementHelper.SafeRemove(conditional.EndMarker); - // Matching branch content (if any) remains in the document + // Matching branch content (if any) remains in the document. + + // ECMA-376 §17.4.66: a must contain at least one block-level element and must end + // with a . Removing a branch can empty a cell whose entire content was the conditional + // block; top such cells back up so the produced OOXML stays valid. + EnsureCellsEndWithParagraph(affectedCells); + } + + /// + /// Collects the table cells that contain any of the conditional's markers or content elements. + /// Must be called before the tree is mutated so ancestor lookups still resolve. + /// + private static HashSet CollectAncestorCells(ConditionalBlock conditional) + { + HashSet cells = new HashSet(); + + void AddCellOf(OpenXmlElement? node) + { + TableCell? cell = node?.Ancestors().FirstOrDefault(); + if (cell is not null) + { + cells.Add(cell); + } + } + + foreach (ConditionalBranch branch in conditional.Branches) + { + AddCellOf(branch.Marker); + foreach (OpenXmlElement element in branch.ContentElements) + { + AddCellOf(element); + } + } + + AddCellOf(conditional.EndMarker); + + return cells; + } + + /// + /// Ensures every given table cell still ends with a paragraph, appending an empty one if needed. + /// Cells that were themselves removed (e.g. a table-row conditional) are skipped. + /// + private static void EnsureCellsEndWithParagraph(IEnumerable cells) + { + foreach (TableCell cell in cells) + { + // Skip cells that were removed along with their containing row. + if (cell.Parent is null) + { + continue; + } + + if (cell.LastChild is not Paragraph) + { + cell.AppendChild(new Paragraph()); + } + } } /// diff --git a/TriasDev.Templify/Visitors/LoopVisitor.cs b/TriasDev.Templify/Visitors/LoopVisitor.cs index edbea0f..b6f2d07 100644 --- a/TriasDev.Templify/Visitors/LoopVisitor.cs +++ b/TriasDev.Templify/Visitors/LoopVisitor.cs @@ -16,7 +16,6 @@ namespace TriasDev.Templify.Visitors; /// Expands loops by cloning content for each collection item. /// /// -/// This visitor wraps the loop expansion logic from LoopProcessor into the visitor pattern. /// Benefits: /// - Works with DocumentWalker for unified traversal /// - Context-aware (uses IEvaluationContext from Phase 1) diff --git a/TriasDev.Templify/Visitors/PlaceholderVisitor.cs b/TriasDev.Templify/Visitors/PlaceholderVisitor.cs index 4fdd26e..9d1b4b1 100644 --- a/TriasDev.Templify/Visitors/PlaceholderVisitor.cs +++ b/TriasDev.Templify/Visitors/PlaceholderVisitor.cs @@ -27,8 +27,7 @@ namespace TriasDev.Templify.Visitors; /// - Handles both global variables and loop-scoped variables /// /// Note: DocumentWalker calls VisitPlaceholder for EACH placeholder in a paragraph. -/// This visitor processes them individually, unlike DocumentBodyReplacer which -/// processes all placeholders in a paragraph at once. +/// This visitor processes them individually. /// internal sealed class PlaceholderVisitor : ITemplateElementVisitor { diff --git a/TriasDev.Templify/Visitors/TemplateElementHelper.cs b/TriasDev.Templify/Visitors/TemplateElementHelper.cs index 7a29835..9a286c1 100644 --- a/TriasDev.Templify/Visitors/TemplateElementHelper.cs +++ b/TriasDev.Templify/Visitors/TemplateElementHelper.cs @@ -14,7 +14,6 @@ namespace TriasDev.Templify.Visitors; /// This class consolidates duplicate implementations from: /// - ConditionalDetector.GetElementText() /// - LoopDetector.GetElementText() -/// - LoopProcessor.GetElementText() /// internal static class TemplateElementHelper { @@ -33,7 +32,6 @@ internal static class TemplateElementHelper /// This method replaces 3 duplicate implementations: /// - ConditionalDetector.GetElementText() (lines 189-207) /// - LoopDetector.GetElementText() (lines 150-168) - /// - LoopProcessor.GetElementText() (lines 548-566) /// public static string? GetElementText(OpenXmlElement element) { @@ -69,7 +67,7 @@ public static bool ContainsTemplateMarker(OpenXmlElement element) /// The element to remove. /// /// Prevents "element has no parent" exceptions when removing already-detached elements. - /// This is a common pattern in ConditionalProcessor and LoopProcessor where elements + /// This is a common pattern in the conditional and loop visitors where elements /// may have been removed by nested processing. /// public static void SafeRemove(OpenXmlElement element)