From ccd92a81f482d7dcf5a41c04509f111da704534e Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Tue, 23 Dec 2025 18:50:38 +0100 Subject: [PATCH 1/4] feat: add text replacement lookup table for HTML entities (#64) Add configurable text replacement feature to transform HTML entities and custom patterns in template values before Word processing. - Add TextReplacements static class with HtmlEntities preset - Add TextReplacements property to PlaceholderReplacementOptions - Integrate text replacement into PlaceholderVisitor pipeline - Support
,
,
,  , <, >, &, ", ' - Allow custom user-defined replacement mappings ADR: Chose lookup table approach over HTML parser for simplicity, flexibility (custom patterns), predictability, and extensibility. Paired tags like text can use existing markdown support. Closes #64 --- .../TextReplacementsIntegrationTests.cs | 517 ++++++++++++++++++ .../Replacements/TextReplacementsTests.cs | 313 +++++++++++ .../Core/PlaceholderReplacementOptions.cs | 37 ++ .../Replacements/TextReplacements.cs | 100 ++++ .../Visitors/PlaceholderVisitor.cs | 5 + 5 files changed, 972 insertions(+) create mode 100644 TriasDev.Templify.Tests/Integration/TextReplacementsIntegrationTests.cs create mode 100644 TriasDev.Templify.Tests/Replacements/TextReplacementsTests.cs create mode 100644 TriasDev.Templify/Replacements/TextReplacements.cs diff --git a/TriasDev.Templify.Tests/Integration/TextReplacementsIntegrationTests.cs b/TriasDev.Templify.Tests/Integration/TextReplacementsIntegrationTests.cs new file mode 100644 index 0000000..a642801 --- /dev/null +++ b/TriasDev.Templify.Tests/Integration/TextReplacementsIntegrationTests.cs @@ -0,0 +1,517 @@ +// 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.Wordprocessing; +using TriasDev.Templify.Core; +using TriasDev.Templify.Tests.Helpers; +using TriasDev.Templify.Replacements; + +namespace TriasDev.Templify.Tests.Integration; + +/// +/// Integration tests for text replacement functionality. +/// Tests that HTML entities and custom text patterns in variable values +/// are correctly transformed before being inserted into Word documents. +/// +public sealed class TextReplacementsIntegrationTests +{ + [Fact] + public void ProcessTemplate_HtmlLineBreak_RendersAsWordBreak() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "Line 1
Line 2" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(1, result.ReplacementCount); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + string text = verifier.GetParagraphText(0); + Assert.Equal("Line 1Line 2", text); // Text without break, concatenated + + // Verify Break element exists + Paragraph paragraph = verifier.GetParagraph(0); + List breaks = paragraph.Descendants().ToList(); + Assert.Single(breaks); + } + + [Theory] + [InlineData("
")] + [InlineData("
")] + [InlineData("
")] + [InlineData("
")] + [InlineData("
")] + [InlineData("
")] + public void ProcessTemplate_AllLineBreakVariations_RenderAsWordBreaks(string brTag) + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = $"Before{brTag}After" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + Paragraph paragraph = verifier.GetParagraph(0); + List breaks = paragraph.Descendants().ToList(); + Assert.Single(breaks); + + string text = verifier.GetParagraphText(0); + Assert.Equal("BeforeAfter", text); + } + + [Fact] + public void ProcessTemplate_MultipleHtmlLineBreaks_RendersMultipleBreaks() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "Line 1
Line 2
Line 3
Line 4" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + Paragraph paragraph = verifier.GetParagraph(0); + List breaks = paragraph.Descendants().ToList(); + Assert.Equal(3, breaks.Count); + } + + [Fact] + public void ProcessTemplate_HtmlEntities_ReplacedCorrectly() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "5 < 10 & 10 > 5" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + string text = verifier.GetParagraphText(0); + Assert.Equal("5 < 10 & 10 > 5", text); + } + + [Fact] + public void ProcessTemplate_NonBreakingSpace_ReplacedCorrectly() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "Hello World" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + string text = verifier.GetParagraphText(0); + Assert.Equal("Hello\u00A0World", text); + } + + [Fact] + public void ProcessTemplate_QuotesAndApostrophes_ReplacedCorrectly() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "He said "Hello" and 'Goodbye'" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + string text = verifier.GetParagraphText(0); + Assert.Equal("He said \"Hello\" and 'Goodbye'", text); + } + + [Fact] + public void ProcessTemplate_WithoutTextReplacements_HtmlTagsRemainLiteral() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "Line 1
Line 2" + }; + + // No TextReplacements option set + 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); + string text = verifier.GetParagraphText(0); + Assert.Equal("Line 1
Line 2", text); //
remains as literal text + + // No Break elements should exist + Paragraph paragraph = verifier.GetParagraph(0); + List breaks = paragraph.Descendants().ToList(); + Assert.Empty(breaks); + } + + [Fact] + public void ProcessTemplate_CustomReplacements_WorkCorrectly() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "Welcome to COMPANY_NAME! Contact us at SUPPORT_EMAIL." + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = new Dictionary + { + ["COMPANY_NAME"] = "Acme Corp", + ["SUPPORT_EMAIL"] = "support@acme.com" + } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + string text = verifier.GetParagraphText(0); + Assert.Equal("Welcome to Acme Corp! Contact us at support@acme.com.", text); + } + + [Fact] + public void ProcessTemplate_CombinedPresetAndCustomReplacements_WorkCorrectly() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "COMPANY_NAME & Partners
Contact: SUPPORT_EMAIL" + }; + + // Combine HtmlEntities preset with custom replacements + var replacements = new Dictionary(TextReplacements.HtmlEntities) + { + ["COMPANY_NAME"] = "Acme Corp", + ["SUPPORT_EMAIL"] = "support@acme.com" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = replacements + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + string text = verifier.GetParagraphText(0); + Assert.Equal("Acme Corp & PartnersContact: support@acme.com", text); + + // Verify Break element from
+ Paragraph paragraph = verifier.GetParagraph(0); + List breaks = paragraph.Descendants().ToList(); + Assert.Single(breaks); + } + + [Fact] + public void ProcessTemplate_HtmlBreaksWithMarkdown_BothWorkTogether() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "**Bold line**
*Italic line*" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + Paragraph paragraph = verifier.GetParagraph(0); + + // Verify break exists + List breaks = paragraph.Descendants().ToList(); + Assert.Single(breaks); + + // Verify bold formatting + List runs = verifier.GetRuns(0); + Run? boldRun = runs.FirstOrDefault(r => r.InnerText == "Bold line"); + Assert.NotNull(boldRun); + Assert.NotNull(boldRun.RunProperties?.GetFirstChild()); + + // Verify italic formatting + Run? italicRun = runs.FirstOrDefault(r => r.InnerText == "Italic line"); + Assert.NotNull(italicRun); + Assert.NotNull(italicRun.RunProperties?.GetFirstChild()); + } + + [Fact] + public void ProcessTemplate_HtmlBreaksInLoop_WorksCorrectly() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach Items}}"); + builder.AddParagraph("{{.}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Items"] = new List + { + "Item 1
Detail 1", + "Item 2
Detail 2" + } + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + + // Each paragraph should have a break + Paragraph para0 = verifier.GetParagraph(0); + Assert.Single(para0.Descendants()); + + Paragraph para1 = verifier.GetParagraph(1); + Assert.Single(para1.Descendants()); + } + + [Fact] + public void ProcessTemplate_PreservesFormatting_WithHtmlReplacements() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + RunProperties boldFormatting = DocumentBuilder.CreateFormatting(bold: true); + builder.AddParagraph("{{Message}}", boldFormatting); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Message"] = "Line 1
Line 2" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List runs = verifier.GetRuns(0); + + // All text runs should be bold + Assert.All( + runs.Where(r => !string.IsNullOrEmpty(r.InnerText)), + run => + { + RunProperties? props = run.RunProperties; + Assert.NotNull(props); + Assert.NotNull(props.GetFirstChild()); + }); + } + + [Fact] + public void ProcessTemplate_MixedHtmlAndNativeNewlines_BothWork() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{Message}}"); + + MemoryStream templateStream = builder.ToStream(); + + // Mix of HTML
and native \n newlines + Dictionary data = new Dictionary + { + ["Message"] = "HTML break
Native newline\nAnother line" + }; + + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + TextReplacements = TextReplacements.HtmlEntities + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + Paragraph paragraph = verifier.GetParagraph(0); + List breaks = paragraph.Descendants().ToList(); + Assert.Equal(2, breaks.Count); // 1 from
, 1 from \n + } +} diff --git a/TriasDev.Templify.Tests/Replacements/TextReplacementsTests.cs b/TriasDev.Templify.Tests/Replacements/TextReplacementsTests.cs new file mode 100644 index 0000000..d9f7510 --- /dev/null +++ b/TriasDev.Templify.Tests/Replacements/TextReplacementsTests.cs @@ -0,0 +1,313 @@ +// 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.Replacements; + +namespace TriasDev.Templify.Tests.Replacements; + +public class TextReplacementsTests +{ + #region HtmlEntities Preset Tests + + [Fact] + public void HtmlEntities_ContainsExpectedReplacements() + { + // Act + var entities = TextReplacements.HtmlEntities; + + // Assert + Assert.Contains("
", entities.Keys); + Assert.Contains("
", entities.Keys); + Assert.Contains("
", entities.Keys); + Assert.Contains(" ", entities.Keys); + Assert.Contains("<", entities.Keys); + Assert.Contains(">", entities.Keys); + Assert.Contains("&", entities.Keys); + Assert.Contains(""", entities.Keys); + Assert.Contains("'", entities.Keys); + } + + [Theory] + [InlineData("
", "\n")] + [InlineData("
", "\n")] + [InlineData("
", "\n")] + [InlineData("
", "\n")] + [InlineData("
", "\n")] + [InlineData("
", "\n")] + public void HtmlEntities_LineBreakVariations_MapToNewline(string input, string expected) + { + // Act + var entities = TextReplacements.HtmlEntities; + + // Assert + Assert.Equal(expected, entities[input]); + } + + [Fact] + public void HtmlEntities_Nbsp_MapsToNonBreakingSpace() + { + // Act + var entities = TextReplacements.HtmlEntities; + + // Assert + Assert.Equal("\u00A0", entities[" "]); + } + + [Theory] + [InlineData("<", "<")] + [InlineData(">", ">")] + [InlineData("&", "&")] + [InlineData(""", "\"")] + [InlineData("'", "'")] + public void HtmlEntities_CommonEntities_MapCorrectly(string input, string expected) + { + // Act + var entities = TextReplacements.HtmlEntities; + + // Assert + Assert.Equal(expected, entities[input]); + } + + #endregion + + #region Apply Method Tests + + [Fact] + public void Apply_NullInput_ReturnsNull() + { + // Arrange + string? input = null; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input!, replacements); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Apply_EmptyInput_ReturnsEmpty() + { + // Arrange + string input = ""; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("", result); + } + + [Fact] + public void Apply_NullReplacements_ReturnsInputUnchanged() + { + // Arrange + string input = "Hello
World"; + Dictionary? replacements = null; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("Hello
World", result); + } + + [Fact] + public void Apply_EmptyReplacements_ReturnsInputUnchanged() + { + // Arrange + string input = "Hello
World"; + var replacements = new Dictionary(); + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("Hello
World", result); + } + + [Fact] + public void Apply_NoMatchingPatterns_ReturnsInputUnchanged() + { + // Arrange + string input = "Hello World"; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("Hello World", result); + } + + [Fact] + public void Apply_SingleLineBreak_ReplacesCorrectly() + { + // Arrange + string input = "Hello
World"; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("Hello\nWorld", result); + } + + [Fact] + public void Apply_MultipleLineBreaks_ReplacesAll() + { + // Arrange + string input = "Line1
Line2
Line3
Line4"; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("Line1\nLine2\nLine3\nLine4", result); + } + + [Fact] + public void Apply_HtmlEntities_ReplacesAllCorrectly() + { + // Arrange + string input = "5 < 10 & 10 > 5"; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("5 < 10 & 10 > 5", result); + } + + [Fact] + public void Apply_MixedContent_ReplacesAllPatterns() + { + // Arrange + string input = "Hello World
<test>"; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("Hello\u00A0World\n", result); + } + + [Fact] + public void Apply_QuotesAndApostrophes_ReplacesCorrectly() + { + // Arrange + string input = "He said "Hello" and 'Goodbye'"; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("He said \"Hello\" and 'Goodbye'", result); + } + + [Fact] + public void Apply_CaseSensitiveLineBreaks_UppercaseReplaced() + { + // Arrange - HtmlEntities includes uppercase variants + string input = "Hello
World
Test
End"; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("Hello\nWorld\nTest\nEnd", result); + } + + [Fact] + public void Apply_CustomReplacements_WorksCorrectly() + { + // Arrange + string input = "Hello COMPANY_NAME, welcome to PRODUCT!"; + var replacements = new Dictionary + { + ["COMPANY_NAME"] = "Acme Corp", + ["PRODUCT"] = "Templify" + }; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("Hello Acme Corp, welcome to Templify!", result); + } + + [Fact] + public void Apply_CombinedPresetAndCustom_WorksCorrectly() + { + // Arrange + string input = "Hello
COMPANY_NAME"; + var replacements = new Dictionary(TextReplacements.HtmlEntities) + { + ["COMPANY_NAME"] = "Acme Corp" + }; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("Hello\nAcme Corp", result); + } + + [Fact] + public void Apply_ChainedReplacements_RequiresExplicitPattern() + { + // Arrange - Test that replacements are done in a single pass + // The input "&nbsp;" does NOT contain literal " " substring, + // so   replacement won't match until & is replaced first. + // Since we do a single pass, the result depends on enumeration order. + string input = "&nbsp;"; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert - With single-pass replacement: + // - "&" matches and gets replaced with "&", resulting in " " + // - The iteration continues but   was already checked, so no further replacement + // This is expected behavior - use explicit patterns for chained replacements + Assert.Equal(" ", result); + } + + [Fact] + public void Apply_ConsecutiveEntities_ReplacesAll() + { + // Arrange + string input = "<>&"; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("<>&", result); + } + + [Fact] + public void Apply_OnlyEntity_ReplacesCorrectly() + { + // Arrange + string input = " "; + var replacements = TextReplacements.HtmlEntities; + + // Act + string result = TextReplacements.Apply(input, replacements); + + // Assert + Assert.Equal("\u00A0", result); + } + + #endregion +} diff --git a/TriasDev.Templify/Core/PlaceholderReplacementOptions.cs b/TriasDev.Templify/Core/PlaceholderReplacementOptions.cs index 7181b80..06f6995 100644 --- a/TriasDev.Templify/Core/PlaceholderReplacementOptions.cs +++ b/TriasDev.Templify/Core/PlaceholderReplacementOptions.cs @@ -3,6 +3,7 @@ using System.Globalization; using TriasDev.Templify.Formatting; +using TriasDev.Templify.Replacements; namespace TriasDev.Templify.Core; @@ -45,6 +46,42 @@ public sealed class PlaceholderReplacementOptions /// public bool WarnOnEmptyLoopCollections { get; init; } = true; + /// + /// Gets or initializes a dictionary of text replacements to apply to variable values before processing. + /// Use this to convert HTML entities, custom placeholders, or other text patterns. + /// Default is null (no replacements). + /// + /// + /// + /// Replacements are applied after value conversion but before newline and markdown processing. + /// This allows HTML line breaks (e.g., <br>) to be converted to \n, which then gets + /// processed into Word line breaks. + /// + /// + /// Use the built-in preset for common HTML entities: + /// + /// + /// var options = new PlaceholderReplacementOptions + /// { + /// TextReplacements = TextReplacements.HtmlEntities + /// }; + /// + /// + /// Or define custom replacements: + /// + /// + /// var options = new PlaceholderReplacementOptions + /// { + /// TextReplacements = new Dictionary<string, string> + /// { + /// ["<br>"] = "\n", + /// ["COMPANY_NAME"] = "Acme Corp" + /// } + /// }; + /// + /// + public Dictionary? TextReplacements { get; init; } + /// /// Creates a new instance of with default settings. /// diff --git a/TriasDev.Templify/Replacements/TextReplacements.cs b/TriasDev.Templify/Replacements/TextReplacements.cs new file mode 100644 index 0000000..efd1158 --- /dev/null +++ b/TriasDev.Templify/Replacements/TextReplacements.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2025 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Replacements; + +/// +/// Provides text replacement functionality and built-in replacement presets. +/// +/// +/// +/// ADR: Lookup Table vs HTML Parser +/// +/// +/// Context: Need to transform HTML-like strings in template values to Word-compatible output. +/// +/// +/// Decision: Lookup Table approach using simple string-to-string replacement dictionary. +/// +/// +/// Rationale: +/// +/// Simplicity: No complex HTML parsing required, just string.Replace() operations +/// Flexibility: Users can define custom mappings beyond HTML +/// Predictable: No edge cases from malformed HTML +/// Extensible: Easy to add new replacements without code changes +/// Sufficient: Paired tags like <b>text</b> can use existing markdown support +/// +/// +/// +/// Trade-offs: +/// +/// Cannot support opening/closing tag pairs - users should use markdown instead +/// Each variation needs explicit entry (e.g., <br>, <br/>, <br /> are separate) +/// +/// +/// +public static class TextReplacements +{ + /// + /// Built-in preset for common HTML entities and line break tags. + /// Converts HTML tags and entities to their Word-compatible equivalents. + /// + /// + /// Includes: + /// + /// <br>, <br/>, <br /> → newline (\n) + /// &nbsp; → non-breaking space (U+00A0) + /// &lt; → < + /// &gt; → > + /// &amp; → & + /// &quot; → " + /// &apos; → ' + /// + /// + public static Dictionary HtmlEntities { get; } = new Dictionary + { + // Line break variations + ["
"] = "\n", + ["
"] = "\n", + ["
"] = "\n", + ["
"] = "\n", + ["
"] = "\n", + ["
"] = "\n", + + // Common HTML entities + [" "] = "\u00A0", // Non-breaking space + ["<"] = "<", + [">"] = ">", + ["&"] = "&", + ["""] = "\"", + ["'"] = "'", + }; + + /// + /// Applies text replacements to the input string. + /// + /// The input string to transform. + /// Dictionary of text replacements to apply. If null or empty, returns input unchanged. + /// The transformed string with all replacements applied. + /// + /// Replacements are applied in dictionary enumeration order. + /// For predictable behavior with overlapping patterns, consider using an ordered dictionary + /// or applying replacements in a specific sequence. + /// + public static string Apply(string input, Dictionary? replacements) + { + if (string.IsNullOrEmpty(input) || replacements == null || replacements.Count == 0) + { + return input; + } + + string result = input; + foreach (KeyValuePair replacement in replacements) + { + result = result.Replace(replacement.Key, replacement.Value); + } + + return result; + } +} diff --git a/TriasDev.Templify/Visitors/PlaceholderVisitor.cs b/TriasDev.Templify/Visitors/PlaceholderVisitor.cs index a4ab121..3e62751 100644 --- a/TriasDev.Templify/Visitors/PlaceholderVisitor.cs +++ b/TriasDev.Templify/Visitors/PlaceholderVisitor.cs @@ -9,6 +9,7 @@ using TriasDev.Templify.Loops; using TriasDev.Templify.Markdown; using TriasDev.Templify.Placeholders; +using TriasDev.Templify.Replacements; using TriasDev.Templify.Utilities; namespace TriasDev.Templify.Visitors; @@ -136,6 +137,10 @@ public void VisitPlaceholder(PlaceholderMatch placeholder, Paragraph paragraph, _options.Culture, placeholder.Format, _options.BooleanFormatterRegistry); + + // Apply text replacements (e.g., HTML entities to Word-compatible text) + replacementValue = TextReplacements.Apply(replacementValue, _options.TextReplacements); + ReplacePlaceholderInParagraph(paragraph, placeholder, replacementValue); _replacementCount++; } From 4446ba5cb8278a99e65dc3e7c7c020e3e381ee88 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Tue, 23 Dec 2025 19:00:29 +0100 Subject: [PATCH 2/4] feat(gui): add HTML entity replacement option to GUI Add checkbox in GUI to enable HTML entity replacement, allowing users to convert HTML tags (
,  , etc.) to Word equivalents. - Add EnableHtmlEntityReplacement property to MainWindowViewModel - Add checkbox to MainWindow.axaml with tooltip - Update ITemplifyService interface with enableHtmlEntityReplacement param - Update TemplifyService to use TextReplacements.HtmlEntities when enabled - Add user documentation to placeholders.md --- .../Services/ITemplifyService.cs | 8 +- .../Services/TemplifyService.cs | 15 +++- .../ViewModels/MainWindowViewModel.cs | 14 +++- TriasDev.Templify.Gui/Views/MainWindow.axaml | 19 +++-- docs/for-template-authors/placeholders.md | 77 +++++++++++++++++++ 5 files changed, 121 insertions(+), 12 deletions(-) diff --git a/TriasDev.Templify.Gui/Services/ITemplifyService.cs b/TriasDev.Templify.Gui/Services/ITemplifyService.cs index c418e1e..1d6029f 100644 --- a/TriasDev.Templify.Gui/Services/ITemplifyService.cs +++ b/TriasDev.Templify.Gui/Services/ITemplifyService.cs @@ -18,8 +18,12 @@ public interface ITemplifyService /// /// Path to the template file (.docx). /// Optional path to JSON data file for validation. + /// Enable HTML entity replacement (e.g., <br> to line break). /// Validation result with errors and warnings. - Task ValidateTemplateAsync(string templatePath, string? jsonPath = null); + Task ValidateTemplateAsync( + string templatePath, + string? jsonPath = null, + bool enableHtmlEntityReplacement = false); /// /// Processes a template with JSON data and generates output. @@ -27,11 +31,13 @@ public interface ITemplifyService /// Path to the template file (.docx). /// Path to JSON data file. /// Path for the output file. + /// Enable HTML entity replacement (e.g., <br> to line break). /// Optional progress reporter. /// Processing result with statistics and any errors. Task ProcessTemplateAsync( string templatePath, string jsonPath, string outputPath, + bool enableHtmlEntityReplacement = false, IProgress? progress = null); } diff --git a/TriasDev.Templify.Gui/Services/TemplifyService.cs b/TriasDev.Templify.Gui/Services/TemplifyService.cs index 8069ef7..62a2d4b 100644 --- a/TriasDev.Templify.Gui/Services/TemplifyService.cs +++ b/TriasDev.Templify.Gui/Services/TemplifyService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using TriasDev.Templify.Core; using TriasDev.Templify.Gui.Models; +using TriasDev.Templify.Replacements; using TriasDev.Templify.Utilities; namespace TriasDev.Templify.Gui.Services; @@ -20,14 +21,18 @@ public class TemplifyService : ITemplifyService /// /// Validates a template file with optional JSON data. /// - public async Task ValidateTemplateAsync(string templatePath, string? jsonPath = null) + public async Task ValidateTemplateAsync( + string templatePath, + string? jsonPath = null, + bool enableHtmlEntityReplacement = false) { return await Task.Run(() => { PlaceholderReplacementOptions options = new PlaceholderReplacementOptions { MissingVariableBehavior = MissingVariableBehavior.LeaveUnchanged, - Culture = CultureInfo.InvariantCulture + Culture = CultureInfo.InvariantCulture, + TextReplacements = enableHtmlEntityReplacement ? TextReplacements.HtmlEntities : null }; DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); @@ -57,6 +62,7 @@ public async Task ProcessTemplateAsync( string templatePath, string jsonPath, string outputPath, + bool enableHtmlEntityReplacement = false, IProgress? progress = null) { return await Task.Run(() => @@ -76,11 +82,12 @@ public async Task ProcessTemplateAsync( progress?.Report(0.3); - // Validate template first + // Configure options with optional HTML entity replacement PlaceholderReplacementOptions options = new PlaceholderReplacementOptions { MissingVariableBehavior = MissingVariableBehavior.LeaveUnchanged, - Culture = CultureInfo.InvariantCulture + Culture = CultureInfo.InvariantCulture, + TextReplacements = enableHtmlEntityReplacement ? TextReplacements.HtmlEntities : null }; DocumentTemplateProcessor processor = new DocumentTemplateProcessor(options); diff --git a/TriasDev.Templify.Gui/ViewModels/MainWindowViewModel.cs b/TriasDev.Templify.Gui/ViewModels/MainWindowViewModel.cs index 5a5a027..32ef7d6 100644 --- a/TriasDev.Templify.Gui/ViewModels/MainWindowViewModel.cs +++ b/TriasDev.Templify.Gui/ViewModels/MainWindowViewModel.cs @@ -48,6 +48,14 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private ObservableCollection _results = new(); + /// + /// Gets or sets whether HTML entity replacement is enabled. + /// When enabled, HTML entities like <br>, &nbsp;, etc. are converted + /// to their Word equivalents before processing. + /// + [ObservableProperty] + private bool _enableHtmlEntityReplacement; + public MainWindowViewModel( ITemplifyService templifyService, IFileDialogService fileDialogService) @@ -103,7 +111,10 @@ private async Task ValidateTemplateAsync() try { - ValidationResult validation = await _templifyService.ValidateTemplateAsync(TemplatePath, JsonPath); + ValidationResult validation = await _templifyService.ValidateTemplateAsync( + TemplatePath, + JsonPath, + EnableHtmlEntityReplacement); if (validation.IsValid) { @@ -177,6 +188,7 @@ private async Task ProcessTemplateAsync() TemplatePath, JsonPath, OutputPath, + EnableHtmlEntityReplacement, progressReporter); if (result.Success) diff --git a/TriasDev.Templify.Gui/Views/MainWindow.axaml b/TriasDev.Templify.Gui/Views/MainWindow.axaml index d61c6cf..a2a7baa 100644 --- a/TriasDev.Templify.Gui/Views/MainWindow.axaml +++ b/TriasDev.Templify.Gui/Views/MainWindow.axaml @@ -15,7 +15,7 @@ - + - + + + + + + - +