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
458 changes: 458 additions & 0 deletions TriasDev.Templify.Tests/Odt/OdtConditionalTests.cs

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions TriasDev.Templify.Tests/Odt/OdtLibreOfficeRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,48 @@ public void LibreOfficeGeneratedDocument_IsProcessed()
Assert.Equal("\tTabbed World", lines[1]);
}

[Fact]
public void Conditionals_RoundTripThroughLibreOffice()
{
LibreOfficeRunner.RequireExecutable();

OdtDocumentBuilder template = new OdtDocumentBuilder()
.AddParagraph("{{#if Premium}}")
.AddParagraph("Premium customer")
.AddParagraph("{{#else}}")
.AddParagraph("Standard customer")
.AddParagraph("{{/if}}")
.AddXml("<text:p>Dear {{#if IsMale}}Mr.{{#else}}<text:span text:style-name=\"T1\">Ms.</text:span>{{/if}} {{Name}}</text:p>")
.AddTable(new[] { "{{#if ShowRow}}" }, new[] { "Hidden row" }, new[] { "{{/if}}" }, new[] { "Kept row" })
.AddXml(
"<text:list><text:list-item><text:p>First</text:p></text:list-item>" +
"<text:list-item><text:p>{{#if ShowItem}}</text:p></text:list-item>" +
"<text:list-item><text:p>Hidden item</text:p></text:list-item>" +
"<text:list-item><text:p>{{/if}}</text:p></text:list-item>" +
"<text:list-item><text:p>Last</text:p></text:list-item></text:list>")
.AddParagraph("End");

byte[] output = ProcessToBytes(template, new Dictionary<string, object>
{
["Premium"] = false,
["IsMale"] = false,
["Name"] = "Smith",
["ShowRow"] = false,
["ShowItem"] = false,
});

string[] lines = LibreOfficeRunner.ConvertToTextLines(output);
string all = string.Join("\n", lines);
Assert.Equal("Standard customer", lines[0]);
Assert.Equal("Dear Ms. Smith", lines[1]);
Assert.Contains("Kept row", all, StringComparison.Ordinal);
Assert.DoesNotContain("Hidden", all, StringComparison.Ordinal);
Assert.DoesNotContain("{{", all, StringComparison.Ordinal);
Assert.Contains("First", all, StringComparison.Ordinal);
Assert.Contains("Last", all, StringComparison.Ordinal);
Assert.Equal("End", lines[^1]);
}

private static byte[] ProcessToBytes(OdtDocumentBuilder template, Dictionary<string, object> data)
{
OdtTemplateProcessor processor = new OdtTemplateProcessor(new PlaceholderReplacementOptions { Culture = CultureInfo.InvariantCulture });
Expand Down
9 changes: 5 additions & 4 deletions TriasDev.Templify.Tests/Odt/OdtPlaceholderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -554,13 +554,14 @@ public void NullValue_IsReplacedWithEmpty()
}

[Fact]
public void ConditionalAndLoopMarkers_AreLeftAsText_ForNow()
public void LoopMarkers_AreLeftAsText_ForNow()
{
(_, OdtDocumentVerifier output) = OdtTestHelper.Process(
new OdtDocumentBuilder().AddParagraph("{{#if Show}}{{Name}}{{/if}}"),
new Dictionary<string, object> { ["Show"] = true, ["Name"] = "N" });
new OdtDocumentBuilder().AddParagraph("{{#foreach Items}}{{Name}}{{/foreach}}"),
new Dictionary<string, object> { ["Items"] = new List<string> { "a" }, ["Name"] = "N" });

Assert.Equal("{{#if Show}}N{{/if}}", output.GetParagraphTexts()[0]);
// Marker paragraphs are not processed for placeholders, as in Word documents.
Assert.Equal("{{#foreach Items}}{{Name}}{{/foreach}}", output.GetParagraphTexts()[0]);
}

[Fact]
Expand Down
10 changes: 9 additions & 1 deletion TriasDev.Templify/Core/OdtTemplateProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ namespace TriasDev.Templify.Core;
/// sections, text boxes, footnotes and endnotes, and headers and footers. Placeholders split across
/// formatting spans are found; the replacement takes the formatting of the placeholder's first character.
/// Newlines in values become line breaks (<see cref="PlaceholderReplacementOptions.EnableNewlineSupport"/>).
/// Markdown in values is inserted as literal text, and conditional and loop markers are not evaluated yet.
/// </para>
/// <para>
/// Conditionals (<c>{{#if}}</c>, <c>{{#elseif}}</c>, <c>{{#else}}</c>, <c>{{/if}}</c>, with the full condition syntax)
/// work as block conditionals (markers in their own paragraphs), inline within one paragraph, over table rows (markers
/// in their own rows) and over list items (markers in their own items). Content left empty by a conditional gets an
/// empty paragraph (cells, text boxes, notes, headers and footers); empty list items, lists and tables are removed.
/// </para>
/// <para>
/// Markdown in values is inserted as literal text, and loop markers are not evaluated yet.
/// </para>
/// <para>
/// <see cref="PlaceholderReplacementOptions.UpdateFieldsOnOpen"/> and
Expand Down
50 changes: 50 additions & 0 deletions TriasDev.Templify/OpenDocument/OdtConditionalBlock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2026 TriasDev GmbH & Co. KG
// Licensed under the MIT License. See LICENSE file in the project root for full license information.

using System.Xml.Linq;

namespace TriasDev.Templify.OpenDocument;

/// <summary>
/// A branch of an OpenDocument conditional block: its condition (null for <c>{{#else}}</c>), its marker
/// element and the elements between its marker and the next marker.
/// </summary>
internal sealed record OdtConditionalBranch(string? ConditionExpression, IReadOnlyList<XElement> ContentElements, XElement Marker)
{
/// <summary>Gets whether this is the <c>{{#else}}</c> branch.</summary>
public bool IsElseBranch => ConditionExpression == null;
}

/// <summary>
/// A conditional block (<c>{{#if}}</c> … <c>{{/if}}</c>) over sibling OpenDocument elements
/// (paragraphs, or table rows for a table-row conditional). The counterpart of <c>ConditionalBlock</c>.
/// </summary>
internal sealed class OdtConditionalBlock
{
public OdtConditionalBlock(
IReadOnlyList<OdtConditionalBranch> branches,
XElement endMarker,
bool isTableRowConditional,
int nestingLevel)
{
Branches = branches;
EndMarker = endMarker;
IsTableRowConditional = isTableRowConditional;
NestingLevel = nestingLevel;
}

/// <summary>Gets the branches in order (if, elseif…, else).</summary>
public IReadOnlyList<OdtConditionalBranch> Branches { get; }

/// <summary>Gets the element holding <c>{{#if}}</c>.</summary>
public XElement StartMarker => Branches[0].Marker;

/// <summary>Gets the element holding <c>{{/if}}</c>.</summary>
public XElement EndMarker { get; }

/// <summary>Gets whether the markers are table rows.</summary>
public bool IsTableRowConditional { get; }

/// <summary>Gets the nesting level (0 for top level).</summary>
public int NestingLevel { get; }
}
Loading
Loading