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)