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
187 changes: 187 additions & 0 deletions TriasDev.Templify.Tests/Odt/OdtContainerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// 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;
using TriasDev.Templify.Tests.Helpers;

namespace TriasDev.Templify.Tests.Odt;

/// <summary>
/// Template constructs inside nested containers (text boxes, notes, sections, nested lists, headers and
/// footers) and the uniqueness of names and ids after loop cloning.
/// </summary>
public sealed class OdtContainerTests
{
private static readonly XNamespace _text = OdtDocumentVerifier.Text;
private static readonly XNamespace _table = OdtDocumentVerifier.Table;
private static readonly XNamespace _draw = OdtDocumentVerifier.Draw;

private static List<Dictionary<string, object>> Items(params string[] names) =>
names.Select(n => new Dictionary<string, object> { ["Name"] = n }).ToList();

[Fact]
public void LoopCloningFramesTablesSectionsAndNotes_MakesNamesUnique()
{
OdtDocumentBuilder template = new OdtDocumentBuilder()
.AddParagraph("{{#foreach Items}}")
.AddXml(
"<text:p>{{Name}}<draw:frame draw:name=\"Box\" text:anchor-type=\"as-char\"><draw:text-box><text:p>in box {{Name}}</text:p></draw:text-box></draw:frame>" +
"<text:note text:id=\"ftn1\" text:note-class=\"footnote\"><text:note-citation>1</text:note-citation><text:note-body><text:p>note {{Name}}</text:p></text:note-body></text:note></text:p>")
.AddXml("<table:table table:name=\"Prices\"><table:table-column/><table:table-row><table:table-cell><text:p>{{Name}}</text:p></table:table-cell></table:table-row></table:table>")
.AddXml("<text:section text:name=\"Details\"><text:p xml:id=\"p1\">section {{Name}}</text:p></text:section>")
.AddParagraph("{{/foreach}}");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary<string, object> { ["Items"] = Items("a", "b", "c") });

Assert.Equal(new[] { "Box", "Box_2", "Box_3" }, output.Body.Descendants(_draw + "frame").Select(f => (string)f.Attribute(_draw + "name")!));
Assert.Equal(new[] { "Prices", "Prices_2", "Prices_3" }, output.Body.Descendants(_table + "table").Select(t => (string)t.Attribute(_table + "name")!));
Assert.Equal(new[] { "Details", "Details_2", "Details_3" }, output.Body.Descendants(_text + "section").Select(s => (string)s.Attribute(_text + "name")!));
Assert.Equal(new[] { "ftn1", "ftn1_2", "ftn1_3" }, output.Body.Descendants(_text + "note").Select(n => (string)n.Attribute(_text + "id")!));
Assert.Single(output.Body.Descendants(), e => e.Attribute(XNamespace.Xml + "id") != null);

List<string> texts = output.GetParagraphTexts();
Assert.Contains("in box b", texts);
Assert.Contains("note c", texts);
Assert.Contains("section a", texts);
}

[Fact]
public void UniqueNames_AvoidExistingNames()
{
OdtDocumentBuilder template = new OdtDocumentBuilder()
.AddXml("<text:p><draw:frame draw:name=\"F_2\"/></text:p>")
.AddParagraph("{{#foreach Items}}")
.AddXml("<text:p><draw:frame draw:name=\"F\"/></text:p>")
.AddParagraph("{{/foreach}}");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary<string, object> { ["Items"] = Items("a", "b") });

Assert.Equal(new[] { "F_2", "F", "F_3" }, output.Body.Descendants(_draw + "frame").Select(f => (string)f.Attribute(_draw + "name")!));
}

[Fact]
public void FramesInHeaderAndBody_ShareOneNameSpace()
{
OdtDocumentBuilder template = new OdtDocumentBuilder()
.AddXml("<text:p><draw:frame draw:name=\"Logo\"/></text:p>")
.AddHeaderXml("<text:p><draw:frame draw:name=\"Logo\"/></text:p>");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary<string, object>());

Assert.Equal("Logo", (string?)output.Body.Descendants(_draw + "frame").Single().Attribute(_draw + "name"));
Assert.Equal("Logo_2", (string?)output.StylesXml!.Descendants(_draw + "frame").Single().Attribute(_draw + "name"));
}

[Fact]
public void LoopInsideTextBox_IsExpanded()
{
OdtDocumentBuilder template = new OdtDocumentBuilder().AddXml(
"<text:p><draw:frame draw:name=\"F\" text:anchor-type=\"paragraph\"><draw:text-box>" +
"<text:p>{{#foreach Items}}</text:p><text:p>- {{Name}}</text:p><text:p>{{/foreach}}</text:p>" +
"</draw:text-box></draw:frame></text:p>");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary<string, object> { ["Items"] = Items("a", "b") });

XElement textBox = output.Body.Descendants(_draw + "text-box").Single();
Assert.Equal(new[] { "- a", "- b" }, textBox.Elements(_text + "p").Select(OdtDocumentVerifier.RenderText));
}

[Fact]
public void LoopInsideFootnote_IsExpanded()
{
OdtDocumentBuilder template = new OdtDocumentBuilder().AddXml(
"<text:p>Text<text:note text:id=\"n\" text:note-class=\"endnote\"><text:note-citation>i</text:note-citation><text:note-body>" +
"<text:p>{{#foreach Items}}</text:p><text:p>src {{Name}}</text:p><text:p>{{/foreach}}</text:p>" +
"</text:note-body></text:note></text:p>");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary<string, object> { ["Items"] = Items("a", "b") });

XElement noteBody = output.Body.Descendants(_text + "note-body").Single();
Assert.Equal(new[] { "src a", "src b" }, noteBody.Elements(_text + "p").Select(OdtDocumentVerifier.RenderText));
}

[Fact]
public void EmptyLoopInsideTextBox_LeavesEmptyParagraph()
{
OdtDocumentBuilder template = new OdtDocumentBuilder().AddXml(
"<text:p><draw:frame draw:name=\"F\"><draw:text-box>" +
"<text:p>{{#foreach Items}}</text:p><text:p>{{Name}}</text:p><text:p>{{/foreach}}</text:p>" +
"</draw:text-box></draw:frame></text:p>");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary<string, object> { ["Items"] = new List<object>() });

XElement textBox = output.Body.Descendants(_draw + "text-box").Single();
Assert.Single(textBox.Elements(_text + "p"));
}

[Fact]
public void LoopInsideSection_AndSectionInsideLoop_AreProcessed()
{
OdtDocumentBuilder template = new OdtDocumentBuilder()
.AddXml("<text:section text:name=\"S\"><text:p>{{#foreach Items}}</text:p><text:p>s {{Name}}</text:p><text:p>{{/foreach}}</text:p></text:section>");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary<string, object> { ["Items"] = Items("a", "b") });

Assert.Equal(new[] { "s a", "s b" }, output.GetParagraphTexts());
}

[Fact]
public void NestedListItemLoopInsideListItem_IsExpanded()
{
Dictionary<string, object> data = new Dictionary<string, object>
{
["Groups"] = new List<Dictionary<string, object>>
{
new() { ["Name"] = "G1", ["Items"] = Items("a", "b") },
new() { ["Name"] = "G2", ["Items"] = Items("c") },
},
};

OdtDocumentBuilder template = new OdtDocumentBuilder().AddXml(
"<text:list>" +
"<text:list-item><text:p>{{#foreach group in Groups}}</text:p></text:list-item>" +
"<text:list-item><text:p>{{group.Name}}</text:p><text:list>" +
"<text:list-item><text:p>{{#foreach item in group.Items}}</text:p></text:list-item>" +
"<text:list-item><text:p>{{group.Name}}.{{item.Name}}</text:p></text:list-item>" +
"<text:list-item><text:p>{{/foreach}}</text:p></text:list-item>" +
"</text:list></text:list-item>" +
"<text:list-item><text:p>{{/foreach}}</text:p></text:list-item>" +
"</text:list>");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, data);

Assert.Equal(new[] { "G1", "G1.a", "G1.b", "G2", "G2.c" }, output.GetParagraphTexts());
Assert.Equal(2, output.Body.Element(_text + "list")!.Elements(_text + "list-item").Count());
}

[Fact]
public void TableRowLoopInFooter_IsExpanded()
{
OdtDocumentBuilder template = new OdtDocumentBuilder()
.AddParagraph("Body")
.AddFooterXml(
"<table:table table:name=\"FooterTable\"><table:table-column/>" +
"<table:table-row><table:table-cell><text:p>{{#foreach Items}}</text:p></table:table-cell></table:table-row>" +
"<table:table-row><table:table-cell><text:p>f {{Name}}</text:p></table:table-cell></table:table-row>" +
"<table:table-row><table:table-cell><text:p>{{/foreach}}</text:p></table:table-cell></table:table-row>" +
"</table:table>");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary<string, object> { ["Items"] = Items("a", "b") });

Assert.Equal(new[] { "f a", "f b" }, output.GetFooterTexts());
}

[Fact]
public void ConditionalRemovingWholeHeaderContent_LeavesEmptyParagraph()
{
OdtDocumentBuilder template = new OdtDocumentBuilder()
.AddParagraph("Body")
.AddHeaderXml("<text:p>{{#if Show}}</text:p><table:table><table:table-column/><table:table-row><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row></table:table><text:p>{{/if}}</text:p>");

(_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary<string, object> { ["Show"] = false });

XElement header = output.StylesXml!.Descendants(OdtDocumentVerifier.Style + "header").Single();
Assert.Single(header.Elements());
Assert.Equal(_text + "p", header.Elements().Single().Name);
}
}
36 changes: 36 additions & 0 deletions TriasDev.Templify.Tests/Odt/OdtLibreOfficeRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,42 @@ public void Loops_RoundTripThroughLibreOffice()
Assert.Equal("End", lines[^1]);
}

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

OdtDocumentBuilder template = new OdtDocumentBuilder()
.AddParagraph("{{#foreach Items}}")
.AddXml(
"<text:p>{{Name}}<draw:frame draw:name=\"Box\" text:anchor-type=\"as-char\" svg:width=\"4cm\" svg:height=\"1cm\">" +
"<draw:text-box><text:p>box {{Name}}</text:p></draw:text-box></draw:frame></text:p>")
.AddXml("<table:table table:name=\"Prices\"><table:table-column/><table:table-row><table:table-cell><text:p>cell {{Name}}</text:p></table:table-cell></table:table-row></table:table>")
.AddXml("<text:section text:name=\"Details\"><text:p>section {{Name}}</text:p></text:section>")
.AddParagraph("{{/foreach}}");

byte[] output = ProcessToBytes(template, new Dictionary<string, object>
{
["Items"] = new List<Dictionary<string, object>> { new() { ["Name"] = "A" }, new() { ["Name"] = "B" } },
});

// Let LibreOffice load and save the document again: it keeps unique names and renames duplicates.
OdtDocumentVerifier resaved = new OdtDocumentVerifier(LibreOfficeRunner.Convert(output, "odt", "odt", "odt"));
Assert.Equal(
new[] { "Box", "Box_2" },
resaved.Body.Descendants(OdtDocumentVerifier.Draw + "frame").Select(f => (string)f.Attribute(OdtDocumentVerifier.Draw + "name")!));
Assert.Equal(
new[] { "Prices", "Prices_2" },
resaved.Body.Descendants(OdtDocumentVerifier.Table + "table").Select(t => (string)t.Attribute(OdtDocumentVerifier.Table + "name")!));
Assert.Equal(
new[] { "Details", "Details_2" },
resaved.Body.Descendants(OdtDocumentVerifier.Text + "section").Select(s => (string)s.Attribute(OdtDocumentVerifier.Text + "name")!));

string all = string.Join("\n", LibreOfficeRunner.ConvertToTextLines(output));
Assert.Contains("cell B", all, StringComparison.Ordinal);
Assert.Contains("section B", all, StringComparison.Ordinal);
}

private static byte[] ProcessToBytes(OdtDocumentBuilder template, Dictionary<string, object> data)
{
OdtTemplateProcessor processor = new OdtTemplateProcessor(new PlaceholderReplacementOptions { Culture = CultureInfo.InvariantCulture });
Expand Down
15 changes: 10 additions & 5 deletions TriasDev.Templify/OpenDocument/OdtTemplateEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,18 @@ public void Process(OdtPackage package, IEvaluationContext context)
ProcessBlocks(body.Elements().ToList(), context);

XDocument? styles = package.GetXml(OdtPackage.StylesEntry);
if (styles?.Root != null)
List<XElement> headersAndFooters = styles?.Root != null
? GetHeadersAndFooters(styles.Root).ToList()
: new List<XElement>();
foreach (XElement headerOrFooter in headersAndFooters)
{
foreach (XElement headerOrFooter in GetHeadersAndFooters(styles.Root))
{
ProcessContainer(headerOrFooter, context);
}
ProcessContainer(headerOrFooter, context);
}

// Loop cloning copies frame, table and section names and note ids; make them unique again.
List<XElement> roots = new List<XElement> { body };
roots.AddRange(headersAndFooters);
OdtUniqueNames.EnsureUnique(roots);
}

/// <summary>
Expand Down
85 changes: 85 additions & 0 deletions TriasDev.Templify/OpenDocument/OdtUniqueNames.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// 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.Globalization;
using System.Xml.Linq;

namespace TriasDev.Templify.OpenDocument;

/// <summary>
/// Makes object names and ids unique after template processing. The OpenDocument counterpart of
/// <c>DrawingIdAllocator</c>.
/// </summary>
/// <remarks>
/// <para>
/// Cloning loop content copies every frame, shape, table, section and note together with its name or id.
/// ODF requires these to be unique: LibreOffice renames duplicate frames and tables on load (breaking
/// references to them), and duplicate <c>xml:id</c> values make the document invalid.
/// </para>
/// <para>
/// The pass runs once over the body and the headers and footers (<c>content.xml</c> and <c>styles.xml</c>
/// share one name space) and changes only duplicates, so the first occurrence (usually the one the template
/// author named and references) keeps its name. Duplicate <c>xml:id</c> values are removed from the later
/// occurrences, since they only link RDF metadata, which belongs to the original.
/// </para>
/// </remarks>
internal static class OdtUniqueNames
{
private static readonly XName _xmlId = XNamespace.Xml + "id";
private static readonly XName _drawName = OdfNames.Draw + "name";
private static readonly XName _tableName = OdfNames.Table + "name";
private static readonly XName _textName = OdfNames.Text + "name";
private static readonly XName _textId = OdfNames.Text + "id";

/// <summary>
/// Renames duplicate frame/shape, table and section names and note ids, and removes duplicate xml:ids.
/// </summary>
public static void EnsureUnique(IReadOnlyList<XElement> roots)
{
List<XElement> elements = roots.SelectMany(r => r.DescendantsAndSelf()).ToList();

Rename(elements, e => e.Name.Namespace == OdfNames.Draw ? e.Attribute(_drawName) : null);
Rename(elements, e => e.Name == OdfNames.TableElement ? e.Attribute(_tableName) : null);
Rename(elements, e => e.Name == OdfNames.Section ? e.Attribute(_textName) : null);
Rename(elements, e => e.Name == OdfNames.Note ? e.Attribute(_textId) : null);

HashSet<string> xmlIds = new HashSet<string>(StringComparer.Ordinal);
foreach (XElement element in elements)
{
XAttribute? id = element.Attribute(_xmlId);
if (id != null && !xmlIds.Add(id.Value))
{
id.Remove();
}
}
}

private static void Rename(List<XElement> elements, Func<XElement, XAttribute?> select)
{
List<XAttribute> attributes = elements.Select(select).OfType<XAttribute>().ToList();
HashSet<string> used = new HashSet<string>(attributes.Select(a => a.Value), StringComparer.Ordinal);
HashSet<string> seen = new HashSet<string>(StringComparer.Ordinal);

foreach (XAttribute attribute in attributes)
{
if (seen.Add(attribute.Value))
{
continue;
}

string baseName = attribute.Value;
int counter = 2;
string candidate;
do
{
candidate = baseName + "_" + counter.ToString(CultureInfo.InvariantCulture);
counter++;
}
while (used.Contains(candidate));

used.Add(candidate);
seen.Add(candidate);
attribute.Value = candidate;
}
}
}
Loading
Loading