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>, , 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 @@
-
+
-
+
+
+
+
+
+
-
+
@@ -74,7 +81,7 @@
-
+
@@ -85,7 +92,7 @@
-
-
+
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..13e5804
--- /dev/null
+++ b/TriasDev.Templify.Tests/Replacements/TextReplacementsTests.cs
@@ -0,0 +1,331 @@
+// 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);
+ 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("'", "'")]
+ [InlineData("—", "\u2014")]
+ [InlineData("–", "\u2013")]
+ 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_Dashes_ReplacesCorrectly()
+ {
+ // Arrange
+ string input = "Contact us — we're here to help!";
+ var replacements = TextReplacements.HtmlEntities;
+
+ // Act
+ string? result = TextReplacements.Apply(input, replacements);
+
+ // Assert
+ Assert.Equal("Contact us\u00A0\u2014\u00A0we're here to help!", 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 " " 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 = " ";
+ 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..3b4e34b 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 IReadOnlyDictionary? 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..74dcdce
--- /dev/null
+++ b/TriasDev.Templify/Replacements/TextReplacements.cs
@@ -0,0 +1,107 @@
+// Copyright (c) 2025 TriasDev GmbH & Co. KG
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+
+using System.Collections.ObjectModel;
+
+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)
+ /// - → non-breaking space (U+00A0)
+ /// - < → <
+ /// - > → >
+ /// - & → &
+ /// - " → "
+ /// - ' → '
+ /// - — → em dash (—)
+ /// - – → en dash (–)
+ ///
+ ///
+ public static IReadOnlyDictionary HtmlEntities { get; } = new ReadOnlyDictionary(
+ new Dictionary
+ {
+ // Line break variations (lowercase and uppercase)
+ ["
"] = "\n",
+ ["
"] = "\n",
+ ["
"] = "\n",
+ ["
"] = "\n",
+ ["
"] = "\n",
+ ["
"] = "\n",
+
+ // Common HTML entities (lowercase only - per HTML spec, entities are case-sensitive)
+ [" "] = "\u00A0", // Non-breaking space
+ ["<"] = "<",
+ [">"] = ">",
+ ["&"] = "&",
+ ["""] = "\"",
+ ["'"] = "'",
+ ["—"] = "\u2014", // Em dash (—)
+ ["–"] = "\u2013", // En dash (–)
+ });
+
+ ///
+ /// Applies text replacements to the input string.
+ ///
+ /// The input string to transform. If null, returns null.
+ /// Dictionary of text replacements to apply. If null or empty, returns input unchanged.
+ /// The transformed string with all replacements applied, or null if input was null.
+ ///
+ /// 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, IReadOnlyDictionary? 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..d075c83 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,11 @@ public void VisitPlaceholder(PlaceholderMatch placeholder, Paragraph paragraph,
_options.Culture,
placeholder.Format,
_options.BooleanFormatterRegistry);
+
+ // Apply text replacements (e.g., HTML entities to Word-compatible text)
+ // Note: Apply returns null only if input is null, which won't happen here
+ replacementValue = TextReplacements.Apply(replacementValue, _options.TextReplacements)!;
+
ReplacePlaceholderInParagraph(paragraph, placeholder, replacementValue);
_replacementCount++;
}
diff --git a/docs/for-template-authors/placeholders.md b/docs/for-template-authors/placeholders.md
index 335c867..38d51a0 100644
--- a/docs/for-template-authors/placeholders.md
+++ b/docs/for-template-authors/placeholders.md
@@ -543,6 +543,87 @@ Second line.
All newline formats are supported: `\n` (Unix/Linux/macOS), `\r\n` (Windows), `\r` (legacy Mac).
+## HTML Entity Replacement
+
+If your data comes from web applications or APIs, it may contain HTML tags and entities. Templify can automatically convert these to their Word equivalents when enabled.
+
+### Enabling HTML Entity Replacement
+
+In the **Templify GUI**, check the **"Enable HTML Entity Replacement"** checkbox before processing.
+
+In code, use the `TextReplacements` option:
+
+```csharp
+var options = new PlaceholderReplacementOptions
+{
+ TextReplacements = TextReplacements.HtmlEntities
+};
+```
+
+### Supported HTML Tags and Entities
+
+| HTML | Converted To | Description |
+|------|--------------|-------------|
+| `
` | Line break | HTML line break |
+| `
` | Line break | Self-closing line break |
+| `
` | Line break | Self-closing with space |
+| ` ` | Non-breaking space | Keeps words together |
+| `<` | `<` | Less than |
+| `>` | `>` | Greater than |
+| `&` | `&` | Ampersand |
+| `"` | `"` | Double quote |
+| `'` | `'` | Single quote |
+| `—` | `—` | Em dash |
+| `–` | `–` | En dash |
+
+> **Note:** The `
` tag also supports uppercase variants (`
`, `
`, `
`). However, HTML entities like ` ` are case-sensitive per the HTML specification and only lowercase versions are supported.
+
+### Example
+
+**JSON (with HTML from a web form):**
+```json
+{
+ "Description": "Product features:
- Fast
- Reliable
- Easy to use",
+ "Price": "Starting at <$100",
+ "Note": "Contact us at info@company.com — we're here to help!"
+}
+```
+
+**Template:**
+```
+{{Description}}
+
+Price: {{Price}}
+
+{{Note}}
+```
+
+**Output (with HTML Entity Replacement enabled):**
+```
+Product features:
+- Fast
+- Reliable
+- Easy to use
+
+Price: Starting at <$100
+
+Contact us at info@company.com — we're here to help!
+```
+
+### When to Use HTML Entity Replacement
+
+Enable this option when your data:
+- Comes from web forms or CMS systems
+- Contains HTML line breaks (`
`)
+- Includes HTML-encoded special characters (`<`, `>`, `&`)
+- Was exported from web applications
+
+### Limitations
+
+- Only simple tags and entities are supported (see table above)
+- Paired tags like `text` are **not** converted — use markdown (`**text**`) instead
+- This is an opt-in feature (disabled by default) to avoid unexpected changes
+
**Combining with Markdown:**
You can use both markdown formatting and line breaks together: