Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -263,16 +262,13 @@ 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

**Placeholders:**
- `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
Expand Down
57 changes: 57 additions & 0 deletions TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,63 @@ private AbstractNum CreateNumberedListDefinition(int abstractNumId)
return abstractNum;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="rows">Number of rows.</param>
/// <param name="columns">Number of columns.</param>
/// <param name="cellParagraphsProvider">Returns the paragraph texts for cell (row, col). One paragraph per string.</param>
public DocumentBuilder AddTableWithCellParagraphs(
int rows,
int columns,
Func<int, int, string[]> cellParagraphsProvider)
{
Table table = new Table();

TableProperties tableProperties = new TableProperties();
TableBorders tableBorders = new TableBorders(
new TopBorder { Val = new EnumValue<BorderValues>(BorderValues.Single), Size = 4 },
new BottomBorder { Val = new EnumValue<BorderValues>(BorderValues.Single), Size = 4 },
new LeftBorder { Val = new EnumValue<BorderValues>(BorderValues.Single), Size = 4 },
new RightBorder { Val = new EnumValue<BorderValues>(BorderValues.Single), Size = 4 },
new InsideHorizontalBorder { Val = new EnumValue<BorderValues>(BorderValues.Single), Size = 4 },
new InsideVerticalBorder { Val = new EnumValue<BorderValues>(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;
}

/// <summary>
/// Adds a table with the specified number of rows and columns.
/// </summary>
Expand Down
48 changes: 48 additions & 0 deletions TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,54 @@ public List<List<string>> GetTableCellTexts(int tableIndex)
return result;
}

/// <summary>
/// Gets the number of paragraphs directly inside a specific table cell.
/// </summary>
public int GetTableCellParagraphCount(int tableIndex, int rowIndex, int columnIndex)
{
return GetTableCell(tableIndex, rowIndex, columnIndex).Elements<Paragraph>().Count();
}

/// <summary>
/// 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.
/// </summary>
public bool DoesTableCellEndWithParagraph(int tableIndex, int rowIndex, int columnIndex)
{
return GetTableCell(tableIndex, rowIndex, columnIndex).LastChild is Paragraph;
}

/// <summary>
/// Navigates to a specific table cell, validating all indexes.
/// </summary>
private TableCell GetTableCell(int tableIndex, int rowIndex, int columnIndex)
{
IEnumerable<Table> tables = _body.Elements<Table>();

if (tableIndex < 0 || tableIndex >= tables.Count())
{
throw new ArgumentOutOfRangeException(nameof(tableIndex));
}

Table table = tables.ElementAt(tableIndex);
List<TableRow> rows = table.Elements<TableRow>().ToList();

if (rowIndex < 0 || rowIndex >= rows.Count)
{
throw new ArgumentOutOfRangeException(nameof(rowIndex));
}

List<TableCell> cells = rows[rowIndex].Elements<TableCell>().ToList();

if (columnIndex < 0 || columnIndex >= cells.Count)
{
throw new ArgumentOutOfRangeException(nameof(columnIndex));
}

return cells[columnIndex];
}

/// <summary>
/// Gets the RunProperties from a specific cell in a table.
/// </summary>
Expand Down
174 changes: 174 additions & 0 deletions TriasDev.Templify.Tests/Integration/ConditionalTableCellTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 &lt;w:tc&gt;,
/// which is invalid OOXML (ECMA-376 §17.4.66 requires a cell to end with a paragraph).
/// </summary>
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<string, object> data = new Dictionary<string, object>
{
["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 <w:p> 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<string, object> data = new Dictionary<string, object>
{
["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<string, object> data = new Dictionary<string, object>
{
["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 <w:p> 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<string, object> data = new Dictionary<string, object>
{
["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 <w:p> element.");
}
}
}
}
7 changes: 7 additions & 0 deletions TriasDev.Templify/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading