diff --git a/TriasDev.Templify.Tests/Odt/OdtContainerTests.cs b/TriasDev.Templify.Tests/Odt/OdtContainerTests.cs new file mode 100644 index 0000000..e161f7d --- /dev/null +++ b/TriasDev.Templify.Tests/Odt/OdtContainerTests.cs @@ -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; + +/// +/// Template constructs inside nested containers (text boxes, notes, sections, nested lists, headers and +/// footers) and the uniqueness of names and ids after loop cloning. +/// +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> Items(params string[] names) => + names.Select(n => new Dictionary { ["Name"] = n }).ToList(); + + [Fact] + public void LoopCloningFramesTablesSectionsAndNotes_MakesNamesUnique() + { + OdtDocumentBuilder template = new OdtDocumentBuilder() + .AddParagraph("{{#foreach Items}}") + .AddXml( + "{{Name}}in box {{Name}}" + + "1note {{Name}}") + .AddXml("{{Name}}") + .AddXml("section {{Name}}") + .AddParagraph("{{/foreach}}"); + + (_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary { ["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 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("") + .AddParagraph("{{#foreach Items}}") + .AddXml("") + .AddParagraph("{{/foreach}}"); + + (_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary { ["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("") + .AddHeaderXml(""); + + (_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary()); + + 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( + "" + + "{{#foreach Items}}- {{Name}}{{/foreach}}" + + ""); + + (_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary { ["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( + "Texti" + + "{{#foreach Items}}src {{Name}}{{/foreach}}" + + ""); + + (_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary { ["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( + "" + + "{{#foreach Items}}{{Name}}{{/foreach}}" + + ""); + + (_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary { ["Items"] = new List() }); + + 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("{{#foreach Items}}s {{Name}}{{/foreach}}"); + + (_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary { ["Items"] = Items("a", "b") }); + + Assert.Equal(new[] { "s a", "s b" }, output.GetParagraphTexts()); + } + + [Fact] + public void NestedListItemLoopInsideListItem_IsExpanded() + { + Dictionary data = new Dictionary + { + ["Groups"] = new List> + { + new() { ["Name"] = "G1", ["Items"] = Items("a", "b") }, + new() { ["Name"] = "G2", ["Items"] = Items("c") }, + }, + }; + + OdtDocumentBuilder template = new OdtDocumentBuilder().AddXml( + "" + + "{{#foreach group in Groups}}" + + "{{group.Name}}" + + "{{#foreach item in group.Items}}" + + "{{group.Name}}.{{item.Name}}" + + "{{/foreach}}" + + "" + + "{{/foreach}}" + + ""); + + (_, 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( + "" + + "{{#foreach Items}}" + + "f {{Name}}" + + "{{/foreach}}" + + ""); + + (_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary { ["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("{{#if Show}}x{{/if}}"); + + (_, OdtDocumentVerifier output) = OdtTestHelper.Process(template, new Dictionary { ["Show"] = false }); + + XElement header = output.StylesXml!.Descendants(OdtDocumentVerifier.Style + "header").Single(); + Assert.Single(header.Elements()); + Assert.Equal(_text + "p", header.Elements().Single().Name); + } +} diff --git a/TriasDev.Templify.Tests/Odt/OdtLibreOfficeRoundTripTests.cs b/TriasDev.Templify.Tests/Odt/OdtLibreOfficeRoundTripTests.cs index fafa1f8..86c5108 100644 --- a/TriasDev.Templify.Tests/Odt/OdtLibreOfficeRoundTripTests.cs +++ b/TriasDev.Templify.Tests/Odt/OdtLibreOfficeRoundTripTests.cs @@ -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( + "{{Name}}" + + "box {{Name}}") + .AddXml("cell {{Name}}") + .AddXml("section {{Name}}") + .AddParagraph("{{/foreach}}"); + + byte[] output = ProcessToBytes(template, new Dictionary + { + ["Items"] = new List> { 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 data) { OdtTemplateProcessor processor = new OdtTemplateProcessor(new PlaceholderReplacementOptions { Culture = CultureInfo.InvariantCulture }); diff --git a/TriasDev.Templify/OpenDocument/OdtTemplateEngine.cs b/TriasDev.Templify/OpenDocument/OdtTemplateEngine.cs index 254cb7a..82c2179 100644 --- a/TriasDev.Templify/OpenDocument/OdtTemplateEngine.cs +++ b/TriasDev.Templify/OpenDocument/OdtTemplateEngine.cs @@ -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 headersAndFooters = styles?.Root != null + ? GetHeadersAndFooters(styles.Root).ToList() + : new List(); + 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 roots = new List { body }; + roots.AddRange(headersAndFooters); + OdtUniqueNames.EnsureUnique(roots); } /// diff --git a/TriasDev.Templify/OpenDocument/OdtUniqueNames.cs b/TriasDev.Templify/OpenDocument/OdtUniqueNames.cs new file mode 100644 index 0000000..7d3eb47 --- /dev/null +++ b/TriasDev.Templify/OpenDocument/OdtUniqueNames.cs @@ -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; + +/// +/// Makes object names and ids unique after template processing. The OpenDocument counterpart of +/// DrawingIdAllocator. +/// +/// +/// +/// 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 xml:id values make the document invalid. +/// +/// +/// The pass runs once over the body and the headers and footers (content.xml and styles.xml +/// 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 xml:id values are removed from the later +/// occurrences, since they only link RDF metadata, which belongs to the original. +/// +/// +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"; + + /// + /// Renames duplicate frame/shape, table and section names and note ids, and removes duplicate xml:ids. + /// + public static void EnsureUnique(IReadOnlyList roots) + { + List 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 xmlIds = new HashSet(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 elements, Func select) + { + List attributes = elements.Select(select).OfType().ToList(); + HashSet used = new HashSet(attributes.Select(a => a.Value), StringComparer.Ordinal); + HashSet seen = new HashSet(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; + } + } +} diff --git a/docs/superpowers/specs/2026-09-26-odt-support-design.md b/docs/superpowers/specs/2026-09-26-odt-support-design.md index 21267e2..fd7b9f5 100644 --- a/docs/superpowers/specs/2026-09-26-odt-support-design.md +++ b/docs/superpowers/specs/2026-09-26-odt-support-design.md @@ -118,7 +118,7 @@ This mirrors `DocumentWalker.WalkElements`/`WalkRows`, including the #140 rule: | `OdtConditionalDetector`, `OdtConditionalBlock` | block and table-row conditionals over `XElement` siblings. They reuse `ConditionalPatterns` and have the same errors and messages as `ConditionalDetector`. | 2 | | `OdtLoopDetector`, `OdtLoopBlock` | body, table-row and list-item loops. They reuse the `LoopDetector` patterns and name validation. | 3 | | `OdtLoopDetector`, `OdtLoopBlock` (row loops) | a repeated row is cloned and removed as a unit, and covered cells are cloned with their row (no separate component needed) | 3 | -| `OdtDrawingNames` | makes `draw:name` of frames and shapes unique after cloning (the counterpart of `DrawingIdAllocator`, #178). Duplicate names make LibreOffice rename objects on load and break references. | 5 | +| `OdtUniqueNames` | makes `draw:name` of frames and shapes, `table:name`, the `text:name` of sections and the `text:id` of notes unique after cloning, and drops duplicate `xml:id`s (the counterpart of `DrawingIdAllocator`, #178). Duplicate names make LibreOffice rename objects on load and break references. | 4 | | `OdtTextStyles` | the automatic-style registry per part (`content.xml` and `styles.xml` each have their own). It creates or reuses `T…` text styles for bold, italic, strikethrough and their combinations. | 5 | | `OdtTemplateValidator` | the ODT counterpart of `TemplateValidator` / `ScopedVariableValidator` | 5 | @@ -176,8 +176,8 @@ The maintainer's list names "internal document abstraction" as step 1. Because o | 1 | This spec. `OdtPackage`, `OdtParagraphTextModel`, `OdtParagraphTextRewriter`, `OdtTemplateEngine` with placeholders everywhere (body, headings, tables, lists, sections, notes, text boxes, headers/footers), and the public `OdtTemplateProcessor` (process overloads). | | 2 | Conditionals: block (paragraph-level, across any block containers), inline (same paragraph, including elseif/else and nesting), table-row, and **list-item** (see 8.1), with the same warnings and errors. Cells, notes, text boxes and headers/footers that end up empty get an empty `text:p`. A list item left without content, and a list, table or header-row group left without items or rows, is removed. | | 3 | Loops: body loops with implicit and named iteration variables, metadata (`@index`, `@number`, `@first`, `@last`, `@count`), nested loops, null items and missing or null collections with warnings, and `WarnOnEmptyLoopCollections`. Table-row loops and conditionals, with repeated rows expanded and covered cells. | -| 4 | Container integrity: list-item loops and conditionals, loops and conditionals in headers/footers, notes, text boxes and sections, empty-container fixes (a table without rows is removed, and so on), and `text:section` name uniqueness after cloning. | -| 5 | Markdown via automatic styles, `EnableMarkdown` and `:raw`. `draw:name` uniqueness for cloned frames. `ValidateTemplate` for ODT. `DocumentProperties` → `meta.xml`. | +| 4 | Container integrity: loops and conditionals in headers/footers, notes, text boxes, sections and nested lists, and empty-container fixes. List-item loops and conditionals shipped earlier, in PRs 2 and 3. **Name uniqueness after cloning** (`OdtUniqueNames`) covers `draw:name` of frames and shapes, `table:name`, the `text:name` of sections and the `text:id` of notes. The first occurrence keeps its name, and duplicates get `_2`, `_3`, … Duplicate `xml:id`s are removed from the later copies. Body and headers/footers share one name space. This was moved forward from PR 5. | +| 5 | Markdown via automatic styles, `EnableMarkdown` and `:raw`. `ValidateTemplate` for ODT. `DocumentProperties` → `meta.xml`. | | later (lead) | The universal facade, docs and examples, converter and GUI support, and an optional LibreOffice CI job. | ## 8. Known limitations and open questions (conservative defaults chosen) @@ -196,3 +196,4 @@ In Word a list item is just a numbered paragraph, so markers can be list paragra - The thumbnail (`Thumbnails/thumbnail.png`) is copied unchanged. LibreOffice regenerates it on save. - Indexes (table of contents) are not regenerated. Placeholders in their cached body are replaced like normal text. - `.fodt` is not supported (a failed result with a clear message). +- Bookmark and annotation names cloned by loops are not renamed. They come in start/end pairs that would have to be renamed consistently. LibreOffice tolerates duplicates by renaming them on load. The same applies to DOCX, where loop-cloned bookmarks are not renamed either.