diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a851fe..949c003 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- All header/footer types supported: Default, First Page, Even Page
- Same syntax and features as document body - no additional API calls needed
- Formatting is preserved in headers and footers
+- **Number and Currency Format Specifiers** - Format numeric values directly in placeholders (#22)
+ - `:currency` — locale-aware currency formatting (e.g., `$1,234.56` or `1.234,56 €`)
+ - `:number:FORMAT` — any .NET numeric format string (e.g., `:number:N2`, `:number:F3`, `:number:P`)
+ - Works with int, long, decimal, double, and float values
+ - Supports compound format specifiers in placeholder regex
+- **String Format Specifiers** - Transform string casing in placeholders (#22)
+ - `:uppercase` — convert to UPPERCASE
+ - `:lowercase` — convert to lowercase
+- **Date Format Specifiers** - Format dates directly in placeholders (#22)
+ - `:date:FORMAT` — any .NET date format string (e.g., `:date:yyyy-MM-dd`, `:date:MMMM d, yyyy`)
+ - Supports DateTime, DateTimeOffset, and ISO date string values
+ - Culture-aware month/day names
+- **GUI Culture Selector** - Dropdown to choose formatting culture (Invariant, en-US, de-DE, fr-FR, es-ES)
## [1.5.0] - 2026-02-13
diff --git a/CLAUDE.md b/CLAUDE.md
index 15e2d6d..12dfb3e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -294,6 +294,10 @@ The library uses a **visitor pattern** for processing Word documents, enabling:
- Nested: `{{Customer.Address.City}}`
- Array indexing: `{{Items[0].Name}}`
- Dictionary: `{{Settings[Theme]}}` or `{{Settings.Theme}}`
+- Currency format: `{{Amount:currency}}`
+- Number format: `{{Value:number:N2}}`, `{{Rate:number:F3}}`, `{{Pct:number:P}}`
+- String format: `{{Name:uppercase}}`, `{{Code:lowercase}}`
+- Date format: `{{OrderDate:date:yyyy-MM-dd}}`, `{{Date:date:MMMM d, yyyy}}`
### Conditional Syntax
```
diff --git a/README.md b/README.md
index de5f35b..4fd5593 100644
--- a/README.md
+++ b/README.md
@@ -189,6 +189,32 @@ To disable (for backward compatibility):
var options = new PlaceholderReplacementOptions { EnableNewlineSupport = false };
```
+### Format Specifiers
+
+Control how values are displayed using format specifiers:
+
+```
+{{Name:uppercase}} → ALICE JOHNSON
+{{Code:lowercase}} → abc-123
+{{Amount:currency}} → $1,234.57 (en-US) or 1.234,57 € (de-DE)
+{{Value:number:N2}} → 1,234.57
+{{Percentage:number:P}} → 12.34 %
+{{OrderDate:date:yyyy-MM-dd}} → 2024-01-15
+{{OrderDate:date:MMMM d, yyyy}} → January 15, 2024
+{{IsActive:checkbox}} → ☑ or ☐
+{{IsActive:yesno}} → Yes or No
+```
+
+All format specifiers are culture-aware:
+
+```csharp
+var options = new PlaceholderReplacementOptions
+{
+ Culture = new CultureInfo("de-DE") // Affects currency, numbers, dates, and localized text
+};
+var processor = new DocumentTemplateProcessor(options);
+```
+
### Standalone Condition Evaluation
Use Templify's condition engine without processing Word documents:
diff --git a/TriasDev.Templify.Gui/Models/CultureOption.cs b/TriasDev.Templify.Gui/Models/CultureOption.cs
new file mode 100644
index 0000000..15e43e2
--- /dev/null
+++ b/TriasDev.Templify.Gui/Models/CultureOption.cs
@@ -0,0 +1,14 @@
+// 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.Globalization;
+
+namespace TriasDev.Templify.Gui.Models;
+
+///
+/// Represents a culture option for the UI dropdown.
+///
+public record CultureOption(string DisplayName, CultureInfo Culture)
+{
+ public override string ToString() => DisplayName;
+}
diff --git a/TriasDev.Templify.Gui/Services/ITemplifyService.cs b/TriasDev.Templify.Gui/Services/ITemplifyService.cs
index 1d6029f..c4db674 100644
--- a/TriasDev.Templify.Gui/Services/ITemplifyService.cs
+++ b/TriasDev.Templify.Gui/Services/ITemplifyService.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
using System;
+using System.Globalization;
using System.Threading.Tasks;
using TriasDev.Templify.Core;
using TriasDev.Templify.Gui.Models;
@@ -19,11 +20,13 @@ 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).
+ /// Culture for formatting. Defaults to InvariantCulture.
/// Validation result with errors and warnings.
Task ValidateTemplateAsync(
string templatePath,
string? jsonPath = null,
- bool enableHtmlEntityReplacement = false);
+ bool enableHtmlEntityReplacement = false,
+ CultureInfo? culture = null);
///
/// Processes a template with JSON data and generates output.
@@ -32,6 +35,7 @@ Task ValidateTemplateAsync(
/// Path to JSON data file.
/// Path for the output file.
/// Enable HTML entity replacement (e.g., <br> to line break).
+ /// Culture for formatting. Defaults to InvariantCulture.
/// Optional progress reporter.
/// Processing result with statistics and any errors.
Task ProcessTemplateAsync(
@@ -39,5 +43,6 @@ Task ProcessTemplateAsync(
string jsonPath,
string outputPath,
bool enableHtmlEntityReplacement = false,
+ CultureInfo? culture = null,
IProgress? progress = null);
}
diff --git a/TriasDev.Templify.Gui/Services/TemplifyService.cs b/TriasDev.Templify.Gui/Services/TemplifyService.cs
index 62a2d4b..50ff2de 100644
--- a/TriasDev.Templify.Gui/Services/TemplifyService.cs
+++ b/TriasDev.Templify.Gui/Services/TemplifyService.cs
@@ -24,14 +24,16 @@ public class TemplifyService : ITemplifyService
public async Task ValidateTemplateAsync(
string templatePath,
string? jsonPath = null,
- bool enableHtmlEntityReplacement = false)
+ bool enableHtmlEntityReplacement = false,
+ CultureInfo? culture = null)
{
return await Task.Run(() =>
{
+ CultureInfo effectiveCulture = culture ?? CultureInfo.InvariantCulture;
PlaceholderReplacementOptions options = new PlaceholderReplacementOptions
{
MissingVariableBehavior = MissingVariableBehavior.LeaveUnchanged,
- Culture = CultureInfo.InvariantCulture,
+ Culture = effectiveCulture,
TextReplacements = enableHtmlEntityReplacement ? TextReplacements.HtmlEntities : null
};
@@ -63,6 +65,7 @@ public async Task ProcessTemplateAsync(
string jsonPath,
string outputPath,
bool enableHtmlEntityReplacement = false,
+ CultureInfo? culture = null,
IProgress? progress = null)
{
return await Task.Run(() =>
@@ -83,10 +86,11 @@ public async Task ProcessTemplateAsync(
progress?.Report(0.3);
// Configure options with optional HTML entity replacement
+ CultureInfo effectiveCulture = culture ?? CultureInfo.InvariantCulture;
PlaceholderReplacementOptions options = new PlaceholderReplacementOptions
{
MissingVariableBehavior = MissingVariableBehavior.LeaveUnchanged,
- Culture = CultureInfo.InvariantCulture,
+ Culture = effectiveCulture,
TextReplacements = enableHtmlEntityReplacement ? TextReplacements.HtmlEntities : null
};
diff --git a/TriasDev.Templify.Gui/ViewModels/MainWindowViewModel.cs b/TriasDev.Templify.Gui/ViewModels/MainWindowViewModel.cs
index 4bf14c4..2d68bc8 100644
--- a/TriasDev.Templify.Gui/ViewModels/MainWindowViewModel.cs
+++ b/TriasDev.Templify.Gui/ViewModels/MainWindowViewModel.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@@ -56,6 +57,24 @@ public partial class MainWindowViewModel : ViewModelBase
[ObservableProperty]
private bool _enableHtmlEntityReplacement;
+ ///
+ /// Available cultures for formatting.
+ ///
+ public ObservableCollection AvailableCultures { get; } = new(
+ [
+ new CultureOption("Invariant", CultureInfo.InvariantCulture),
+ new CultureOption("English (US)", new CultureInfo("en-US")),
+ new CultureOption("German (DE)", new CultureInfo("de-DE")),
+ new CultureOption("French (FR)", new CultureInfo("fr-FR")),
+ new CultureOption("Spanish (ES)", new CultureInfo("es-ES")),
+ ]);
+
+ ///
+ /// Gets or sets the selected culture for formatting.
+ ///
+ [ObservableProperty]
+ private CultureOption _selectedCulture = null!;
+
///
/// Stores the last processing result to enable warning report generation.
///
@@ -69,6 +88,7 @@ public MainWindowViewModel(
{
_templifyService = templifyService;
_fileDialogService = fileDialogService;
+ _selectedCulture = AvailableCultures[0];
}
[RelayCommand]
@@ -121,7 +141,8 @@ private async Task ValidateTemplateAsync()
ValidationResult validation = await _templifyService.ValidateTemplateAsync(
TemplatePath,
JsonPath,
- EnableHtmlEntityReplacement);
+ EnableHtmlEntityReplacement,
+ SelectedCulture.Culture);
if (validation.IsValid)
{
@@ -196,6 +217,7 @@ private async Task ProcessTemplateAsync()
JsonPath,
OutputPath,
EnableHtmlEntityReplacement,
+ SelectedCulture.Culture,
progressReporter);
// Store for warning report generation
@@ -282,6 +304,7 @@ private void Clear()
StatusMessage = "Ready";
Progress = 0;
LastProcessingResult = null;
+ SelectedCulture = AvailableCultures[0];
}
[RelayCommand(CanExecute = nameof(CanGenerateWarningReport))]
diff --git a/TriasDev.Templify.Gui/Views/MainWindow.axaml b/TriasDev.Templify.Gui/Views/MainWindow.axaml
index e2513f5..1113119 100644
--- a/TriasDev.Templify.Gui/Views/MainWindow.axaml
+++ b/TriasDev.Templify.Gui/Views/MainWindow.axaml
@@ -64,10 +64,17 @@
-
+
+
+
+
+
diff --git a/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs b/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs
index b05c908..d9ceadb 100644
--- a/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs
+++ b/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs
@@ -426,6 +426,327 @@ public void ProcessTemplate_WithCaseInsensitiveFormat_Works()
Assert.Equal(3, checkboxCount);
}
+ #region Number Format Integration Tests
+
+ [Fact]
+ public void ProcessTemplate_WithCurrencyFormat_ReplacesWithCurrencyString()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Total: {{Amount:currency}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var options = new PlaceholderReplacementOptions { Culture = new CultureInfo("en-US") };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary { ["Amount"] = 1234.56m };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Total: $1,234.56", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithNumberFormatN2_ReplacesWithFormattedNumber()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Value: {{Value:number:N2}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var options = new PlaceholderReplacementOptions { Culture = new CultureInfo("en-US") };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary { ["Value"] = 1234.5678m };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Value: 1,234.57", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithCurrencyFormatAndGermanCulture_ReplacesWithEuroFormatting()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Betrag: {{Amount:currency}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var germanCulture = new CultureInfo("de-DE");
+ var options = new PlaceholderReplacementOptions { Culture = germanCulture };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary { ["Amount"] = 1234.56m };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("1.234,56", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithNumberFormatInLoop_ReplacesAllItems()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach Items}}");
+ builder.AddParagraph("- {{Name}}: {{Price:currency}}");
+ builder.AddParagraph("{{/foreach}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var options = new PlaceholderReplacementOptions { Culture = new CultureInfo("en-US") };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary
+ {
+ ["Items"] = new[]
+ {
+ new { Name = "Widget", Price = 9.99m },
+ new { Name = "Gadget", Price = 19.99m }
+ }
+ };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Widget: $9.99", result);
+ Assert.Contains("Gadget: $19.99", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithMixedBooleanAndNumberFormats_ReplacesAll()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Active: {{IsActive:checkbox}}, Total: {{Amount:currency}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var options = new PlaceholderReplacementOptions
+ {
+ Culture = new CultureInfo("en-US"),
+ BooleanFormatterRegistry = new BooleanFormatterRegistry(CultureInfo.InvariantCulture)
+ };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary
+ {
+ ["IsActive"] = true,
+ ["Amount"] = 42.50m
+ };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Active: ☑", result);
+ Assert.Contains("Total: $42.50", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithNestedPropertyAndCurrencyFormat_ReplacesCorrectly()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Order total: {{Order.Total:currency}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var options = new PlaceholderReplacementOptions { Culture = new CultureInfo("en-US") };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary
+ {
+ ["Order"] = new { Total = 99.95m }
+ };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Order total: $99.95", result);
+ }
+
+ #endregion
+
+ #region String Format Integration Tests
+
+ [Fact]
+ public void ProcessTemplate_WithUppercaseFormat_ReplacesWithUppercase()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Name: {{Name:uppercase}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var processor = CreateInvariantProcessor();
+ var data = new Dictionary { ["Name"] = "alice johnson" };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Name: ALICE JOHNSON", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithLowercaseFormat_ReplacesWithLowercase()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Code: {{Code:lowercase}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var processor = CreateInvariantProcessor();
+ var data = new Dictionary { ["Code"] = "ABC-123-XYZ" };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Code: abc-123-xyz", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithStringFormatInLoop_ReplacesAllItems()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("{{#foreach Items}}");
+ builder.AddParagraph("- {{Name:uppercase}}");
+ builder.AddParagraph("{{/foreach}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var processor = CreateInvariantProcessor();
+ var data = new Dictionary
+ {
+ ["Items"] = new[]
+ {
+ new { Name = "widget" },
+ new { Name = "gadget" }
+ }
+ };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("WIDGET", result);
+ Assert.Contains("GADGET", result);
+ }
+
+ #endregion
+
+ #region Date Format Integration Tests
+
+ [Fact]
+ public void ProcessTemplate_WithDateFormat_ReplacesWithFormattedDate()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Date: {{OrderDate:date:yyyy-MM-dd}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var options = new PlaceholderReplacementOptions { Culture = new CultureInfo("en-US") };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary { ["OrderDate"] = new DateTime(2024, 1, 15) };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Date: 2024-01-15", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithLongDateFormat_ReplacesWithFormattedDate()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Date: {{OrderDate:date:MMMM d, yyyy}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var options = new PlaceholderReplacementOptions { Culture = new CultureInfo("en-US") };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary { ["OrderDate"] = new DateTime(2024, 1, 15) };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Date: January 15, 2024", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithDateFormatAndGermanCulture_ReplacesWithLocalizedDate()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Datum: {{OrderDate:date:dd. MMMM yyyy}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var germanCulture = new CultureInfo("de-DE");
+ var options = new PlaceholderReplacementOptions { Culture = germanCulture };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary { ["OrderDate"] = new DateTime(2024, 1, 15) };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Datum: 15. Januar 2024", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithStringDateAndDateFormat_ParsesAndFormats()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Date: {{OrderDate:date:dd.MM.yyyy}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var processor = CreateInvariantProcessor();
+ var data = new Dictionary { ["OrderDate"] = "01/15/2024" };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Date: 15.01.2024", result);
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithAllFormatTypes_ReplacesAll()
+ {
+ // Arrange
+ DocumentBuilder builder = new DocumentBuilder();
+ builder.AddParagraph("Name: {{Name:uppercase}}, Active: {{IsActive:checkbox}}, Total: {{Amount:currency}}, Date: {{OrderDate:date:yyyy-MM-dd}}");
+ using MemoryStream templateStream = builder.ToStream();
+
+ var options = new PlaceholderReplacementOptions
+ {
+ Culture = new CultureInfo("en-US"),
+ BooleanFormatterRegistry = new BooleanFormatterRegistry(CultureInfo.InvariantCulture)
+ };
+ var processor = new DocumentTemplateProcessor(options);
+ var data = new Dictionary
+ {
+ ["Name"] = "alice",
+ ["IsActive"] = true,
+ ["Amount"] = 42.50m,
+ ["OrderDate"] = new DateTime(2024, 1, 15)
+ };
+
+ // Act
+ string result = ProcessTemplate(templateStream, data, processor);
+
+ // Assert
+ Assert.Contains("Name: ALICE", result);
+ Assert.Contains("Active: ☑", result);
+ Assert.Contains("Total: $42.50", result);
+ Assert.Contains("Date: 2024-01-15", result);
+ }
+
+ #endregion
+
#region Helper Methods
///
diff --git a/TriasDev.Templify.Tests/PlaceholderFinderTests.cs b/TriasDev.Templify.Tests/PlaceholderFinderTests.cs
index 5667323..2a58a37 100644
--- a/TriasDev.Templify.Tests/PlaceholderFinderTests.cs
+++ b/TriasDev.Templify.Tests/PlaceholderFinderTests.cs
@@ -408,4 +408,117 @@ public void FindPlaceholders_WithBuiltInFormats_ParsesCorrectly(string text, str
}
#endregion
+
+ #region Compound Format Specifier Tests
+
+ [Fact]
+ public void FindPlaceholders_WithCurrencyFormat_ParsesFormat()
+ {
+ // Arrange
+ string text = "{{Amount:currency}}";
+
+ // Act
+ List matches = _finder.FindPlaceholders(text).ToList();
+
+ // Assert
+ Assert.Single(matches);
+ Assert.Equal("Amount", matches[0].VariableName);
+ Assert.Equal("currency", matches[0].Format);
+ }
+
+ [Fact]
+ public void FindPlaceholders_WithCompoundNumberFormat_ParsesFormat()
+ {
+ // Arrange
+ string text = "{{Amount:number:N2}}";
+
+ // Act
+ List matches = _finder.FindPlaceholders(text).ToList();
+
+ // Assert
+ Assert.Single(matches);
+ Assert.Equal("Amount", matches[0].VariableName);
+ Assert.Equal("number:N2", matches[0].Format);
+ }
+
+ [Fact]
+ public void FindPlaceholders_WithCompoundNumberFormatF3_ParsesFormat()
+ {
+ // Arrange
+ string text = "{{Rate:number:F3}}";
+
+ // Act
+ List matches = _finder.FindPlaceholders(text).ToList();
+
+ // Assert
+ Assert.Single(matches);
+ Assert.Equal("Rate", matches[0].VariableName);
+ Assert.Equal("number:F3", matches[0].Format);
+ }
+
+ [Fact]
+ public void FindPlaceholders_WithNestedPropertyAndCurrencyFormat_ParsesBoth()
+ {
+ // Arrange
+ string text = "{{Order.Total:currency}}";
+
+ // Act
+ List matches = _finder.FindPlaceholders(text).ToList();
+
+ // Assert
+ Assert.Single(matches);
+ Assert.Equal("Order.Total", matches[0].VariableName);
+ Assert.Equal("currency", matches[0].Format);
+ }
+
+ #endregion
+
+ #region Date Format Specifier Tests
+
+ [Fact]
+ public void FindPlaceholders_WithDateFormat_ParsesFormat()
+ {
+ // Arrange
+ string text = "{{OrderDate:date:yyyy-MM-dd}}";
+
+ // Act
+ List matches = _finder.FindPlaceholders(text).ToList();
+
+ // Assert
+ Assert.Single(matches);
+ Assert.Equal("OrderDate", matches[0].VariableName);
+ Assert.Equal("date:yyyy-MM-dd", matches[0].Format);
+ }
+
+ [Fact]
+ public void FindPlaceholders_WithDateFormatWithSpaces_ParsesFormat()
+ {
+ // Arrange
+ string text = "{{OrderDate:date:MMMM d, yyyy}}";
+
+ // Act
+ List matches = _finder.FindPlaceholders(text).ToList();
+
+ // Assert
+ Assert.Single(matches);
+ Assert.Equal("OrderDate", matches[0].VariableName);
+ Assert.Equal("date:MMMM d, yyyy", matches[0].Format);
+ }
+
+ [Fact]
+ public void FindPlaceholders_WithNestedPropertyAndDateFormat_ParsesBoth()
+ {
+ // Arrange
+ string text = "{{Order.Date:date:dd.MM.yyyy}}";
+
+ // Act
+ List matches = _finder.FindPlaceholders(text).ToList();
+
+ // Assert
+ Assert.Single(matches);
+ Assert.Equal("Order.Date", matches[0].VariableName);
+ Assert.Equal("date:dd.MM.yyyy", matches[0].Format);
+ }
+
+ #endregion
}
diff --git a/TriasDev.Templify.Tests/ValueConverterTests.cs b/TriasDev.Templify.Tests/ValueConverterTests.cs
index 079cfb7..6d74036 100644
--- a/TriasDev.Templify.Tests/ValueConverterTests.cs
+++ b/TriasDev.Templify.Tests/ValueConverterTests.cs
@@ -447,4 +447,342 @@ public void ConvertToString_WithBooleanAndCaseVariantFormat_IsCaseInsensitive(st
}
#endregion
+
+ #region Number Format Specifier Tests
+
+ [Theory]
+ [InlineData(1234.567, "currency", "$1,234.57")]
+ [InlineData(42, "currency", "$42.00")]
+ [InlineData(42L, "currency", "$42.00")]
+ [InlineData(42.0, "currency", "$42.00")]
+ [InlineData(42.0f, "currency", "$42.00")]
+ public void ConvertToString_WithNumericAndCurrencyFormat_ReturnsCurrencyString(object value, string format, string expected)
+ {
+ // Act
+ string result = ConvertToString(value, new CultureInfo("en-US"), format, null);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithCurrencyFormatAndGermanCulture_ReturnsEuroFormatting()
+ {
+ // Arrange
+ var culture = new CultureInfo("de-DE");
+
+ // Act
+ string result = ConvertToString(1234.56m, culture, "currency", null);
+
+ // Assert
+ Assert.Contains("1.234,56", result);
+ }
+
+ [Theory]
+ [InlineData("CURRENCY")]
+ [InlineData("Currency")]
+ [InlineData("currency")]
+ public void ConvertToString_WithCurrencyFormatCaseInsensitive_ReturnsCurrencyString(string format)
+ {
+ // Act
+ string result = ConvertToString(42m, new CultureInfo("en-US"), format, null);
+
+ // Assert
+ Assert.Equal("$42.00", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNumberFormatN2_ReturnsFormattedNumber()
+ {
+ // Act
+ string result = ConvertToString(1234.5678m, new CultureInfo("en-US"), "number:N2", null);
+
+ // Assert
+ Assert.Equal("1,234.57", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNumberFormatN0_ReturnsFormattedNumber()
+ {
+ // Act
+ string result = ConvertToString(1234, new CultureInfo("en-US"), "number:N0", null);
+
+ // Assert
+ Assert.Equal("1,234", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNumberFormatF3_ReturnsFormattedNumber()
+ {
+ // Act
+ string result = ConvertToString(3.14159, new CultureInfo("en-US"), "number:F3", null);
+
+ // Assert
+ Assert.Equal("3.142", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNumberFormatP_ReturnsPercentage()
+ {
+ // Act
+ string result = ConvertToString(0.1234m, new CultureInfo("en-US"), "number:P", null);
+
+ // Assert
+ Assert.Contains("12.34", result);
+ Assert.Contains("%", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNumberFormatC_ReturnsCurrency()
+ {
+ // Act
+ string result = ConvertToString(42m, new CultureInfo("en-US"), "number:C", null);
+
+ // Assert
+ Assert.Equal("$42.00", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithStringAndCurrencyFormat_IgnoresFormat()
+ {
+ // Act
+ string result = ConvertToString("text", new CultureInfo("en-US"), "currency", null);
+
+ // Assert
+ Assert.Equal("text", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNullAndCurrencyFormat_ReturnsEmptyString()
+ {
+ // Act
+ string result = ConvertToString(null, new CultureInfo("en-US"), "currency", null);
+
+ // Assert
+ Assert.Equal(string.Empty, result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithInvalidNumberFormat_FallsThrough()
+ {
+ // Act
+ string result = ConvertToString(42m, new CultureInfo("en-US"), "number:XYZ", null);
+
+ // Assert — should not throw, falls through to default
+ Assert.NotEmpty(result);
+ }
+
+ #endregion
+
+ #region String Format Specifier Tests
+
+ [Theory]
+ [InlineData("hello world", "uppercase", "HELLO WORLD")]
+ [InlineData("HELLO WORLD", "lowercase", "hello world")]
+ [InlineData("Mixed Case", "uppercase", "MIXED CASE")]
+ [InlineData("Mixed Case", "lowercase", "mixed case")]
+ public void ConvertToString_WithStringFormat_ReturnsFormattedString(string value, string format, string expected)
+ {
+ // Act
+ string result = ConvertToString(value, CultureInfo.InvariantCulture, format, null);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithEmptyStringAndUppercase_ReturnsEmpty()
+ {
+ // Act
+ string result = ConvertToString("", CultureInfo.InvariantCulture, "uppercase", null);
+
+ // Assert
+ Assert.Equal("", result);
+ }
+
+ [Theory]
+ [InlineData("UPPERCASE")]
+ [InlineData("Uppercase")]
+ [InlineData("uppercase")]
+ public void ConvertToString_WithUppercaseCaseInsensitive_Works(string format)
+ {
+ // Act
+ string result = ConvertToString("test", CultureInfo.InvariantCulture, format, null);
+
+ // Assert
+ Assert.Equal("TEST", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNonStringAndUppercase_IgnoresFormat()
+ {
+ // Act
+ string result = ConvertToString(42, CultureInfo.InvariantCulture, "uppercase", null);
+
+ // Assert
+ Assert.Equal("42", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNullAndUppercase_ReturnsEmpty()
+ {
+ // Act
+ string result = ConvertToString(null, CultureInfo.InvariantCulture, "uppercase", null);
+
+ // Assert
+ Assert.Equal(string.Empty, result);
+ }
+
+ #endregion
+
+ #region Date Format Specifier Tests
+
+ [Fact]
+ public void ConvertToString_WithDateTimeAndDateFormat_ReturnsFormattedDate()
+ {
+ // Arrange
+ var date = new DateTime(2024, 1, 15);
+
+ // Act
+ string result = ConvertToString(date, new CultureInfo("en-US"), "date:yyyy-MM-dd", null);
+
+ // Assert
+ Assert.Equal("2024-01-15", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithDateTimeAndLongDateFormat_ReturnsFormattedDate()
+ {
+ // Arrange
+ var date = new DateTime(2024, 1, 15);
+
+ // Act
+ string result = ConvertToString(date, new CultureInfo("en-US"), "date:MMMM d, yyyy", null);
+
+ // Assert
+ Assert.Equal("January 15, 2024", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithDateTimeAndGermanCulture_ReturnsLocalizedDate()
+ {
+ // Arrange
+ var date = new DateTime(2024, 1, 15);
+
+ // Act
+ string result = ConvertToString(date, new CultureInfo("de-DE"), "date:dd. MMMM yyyy", null);
+
+ // Assert
+ Assert.Equal("15. Januar 2024", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithStringDateAndDateFormat_ParsesAndFormats()
+ {
+ // Act
+ string result = ConvertToString("2024-01-15", CultureInfo.InvariantCulture, "date:yyyy-MM-dd", null);
+
+ // Assert
+ Assert.Equal("2024-01-15", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithDateTimeOffsetAndDateFormat_ReturnsFormattedDate()
+ {
+ // Arrange
+ var date = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero);
+
+ // Act
+ string result = ConvertToString(date, new CultureInfo("en-US"), "date:yyyy-MM-dd", null);
+
+ // Assert
+ Assert.Equal("2024-01-15", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithDateFormatDotSeparated_ReturnsFormattedDate()
+ {
+ // Arrange
+ var date = new DateTime(2024, 1, 15);
+
+ // Act
+ string result = ConvertToString(date, CultureInfo.InvariantCulture, "date:dd.MM.yyyy", null);
+
+ // Assert
+ Assert.Equal("15.01.2024", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNonDateAndDateFormat_IgnoresFormat()
+ {
+ // Act
+ string result = ConvertToString(42, CultureInfo.InvariantCulture, "date:yyyy-MM-dd", null);
+
+ // Assert
+ Assert.Equal("42", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithNullAndDateFormat_ReturnsEmpty()
+ {
+ // Act
+ string result = ConvertToString(null, CultureInfo.InvariantCulture, "date:yyyy-MM-dd", null);
+
+ // Assert
+ Assert.Equal(string.Empty, result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithInvalidDateFormat_FallsThrough()
+ {
+ // Arrange
+ var date = new DateTime(2024, 1, 15);
+
+ // Act — should not throw
+ string result = ConvertToString(date, CultureInfo.InvariantCulture, "date:QQQQQ", null);
+
+ // Assert — falls through gracefully (may produce unexpected output but no exception)
+ Assert.NotEmpty(result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithUnparseableStringAndDateFormat_FallsThrough()
+ {
+ // Act
+ string result = ConvertToString("not a date", CultureInfo.InvariantCulture, "date:yyyy-MM-dd", null);
+
+ // Assert — string value falls through to default
+ Assert.Equal("not a date", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithIsoDateString_ParsesReliably()
+ {
+ // ISO format should parse reliably regardless of culture
+ string result = ConvertToString("2024-01-15", new CultureInfo("de-DE"), "date:dd.MM.yyyy", null);
+
+ Assert.Equal("15.01.2024", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithIsoDateTimeWithTimezone_PreservesTimezone()
+ {
+ // DateTimeOffset string should preserve timezone info
+ var dto = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.FromHours(2));
+
+ string result = ConvertToString(dto, CultureInfo.InvariantCulture, "date:yyyy-MM-dd HH:mm zzz", null);
+
+ Assert.Equal("2024-01-15 10:30 +02:00", result);
+ }
+
+ [Fact]
+ public void ConvertToString_WithStringDateTimeWithTimezone_PreservesTimezone()
+ {
+ // String with timezone offset should preserve timezone info
+ string result = ConvertToString("2024-01-15T10:30:00+02:00", CultureInfo.InvariantCulture, "date:yyyy-MM-dd HH:mm zzz", null);
+
+ Assert.Equal("2024-01-15 10:30 +02:00", result);
+ }
+
+ #endregion
}
diff --git a/TriasDev.Templify/Examples.md b/TriasDev.Templify/Examples.md
index 047e360..e01710b 100644
--- a/TriasDev.Templify/Examples.md
+++ b/TriasDev.Templify/Examples.md
@@ -2096,7 +2096,7 @@ var optionsGB = new PlaceholderReplacementOptions
- International reports → Use InvariantCulture
- Multi-national documents → Consider using multiple templates or custom formatting
-5. **Handle currency symbols separately**: Culture only affects number formatting, not currency symbols. Include currency symbols in your template or data.
+5. **Use `:currency` for locale-aware currency formatting**: `{{Amount:currency}}` automatically applies the culture's currency symbol and number format (e.g., `$1,234.56` for en-US, `1.234,56 €` for de-DE). Use `:number:N2` or other format strings for plain number formatting without currency symbols.
---
diff --git a/TriasDev.Templify/Placeholders/PlaceholderFinder.cs b/TriasDev.Templify/Placeholders/PlaceholderFinder.cs
index dae4e2b..3e10f15 100644
--- a/TriasDev.Templify/Placeholders/PlaceholderFinder.cs
+++ b/TriasDev.Templify/Placeholders/PlaceholderFinder.cs
@@ -19,9 +19,9 @@ public sealed class PlaceholderFinder
// - Loop metadata: @index, @first, @last, @count
// - Current item: . or this (for primitive collections)
// - Expression: (var1 and var2), (not IsActive), (Count > 0), ((var1 or var2) and var3)
- // Optional format specifier: :checkbox, :yesno, :checkmark, etc.
+ // Optional format specifier: :checkbox, :yesno, :currency, :number:N2, :date:yyyy-MM-dd, etc.
private static readonly Regex _placeholderPattern = new(
- @"\{\{(\.|this|@?[\w\.\[\]]+|\([^\}]+\))(?::(\w+))?\}\}",
+ @"\{\{(\.|this|@?[\w\.\[\]]+|\([^\}]+\))(?::(\w+(?::[^\}]+)?))?\}\}",
RegexOptions.Compiled);
///
diff --git a/TriasDev.Templify/Placeholders/ValueConverter.cs b/TriasDev.Templify/Placeholders/ValueConverter.cs
index a269a04..a80d351 100644
--- a/TriasDev.Templify/Placeholders/ValueConverter.cs
+++ b/TriasDev.Templify/Placeholders/ValueConverter.cs
@@ -43,6 +43,32 @@ public static string ConvertToString(object? value, CultureInfo culture, string?
// Fall through to default formatting if format not found
}
+ // Handle string formatting with format specifier
+ if (value is string strValue && !string.IsNullOrWhiteSpace(format))
+ {
+ if (string.Equals(format, "uppercase", StringComparison.OrdinalIgnoreCase))
+ {
+ return strValue.ToUpper(culture);
+ }
+
+ if (string.Equals(format, "lowercase", StringComparison.OrdinalIgnoreCase))
+ {
+ return strValue.ToLower(culture);
+ }
+ }
+
+ // Handle number formatting with format specifier
+ if (!string.IsNullOrWhiteSpace(format) && IsNumeric(value) && TryFormatNumber(value!, culture, format!, out string? numberResult))
+ {
+ return numberResult!;
+ }
+
+ // Handle date formatting with format specifier
+ if (!string.IsNullOrWhiteSpace(format) && TryFormatDate(value, culture, format!, out string? dateResult))
+ {
+ return dateResult!;
+ }
+
// Default conversion without format
return value switch
{
@@ -59,4 +85,94 @@ public static string ConvertToString(object? value, CultureInfo culture, string?
_ => value.ToString() ?? string.Empty
};
}
+
+ private static bool IsNumeric(object? value)
+ {
+ return value is decimal or double or float or int or long;
+ }
+
+ private static bool TryFormatNumber(object value, CultureInfo culture, string format, out string? result)
+ {
+ result = null;
+
+ try
+ {
+ if (string.Equals(format, "currency", StringComparison.OrdinalIgnoreCase))
+ {
+ if (value is IFormattable currencyFormattable)
+ {
+ result = currencyFormattable.ToString("C", culture);
+ return true;
+ }
+ }
+
+ if (format.StartsWith("number:", StringComparison.OrdinalIgnoreCase) && format.Length > 7)
+ {
+ string numberFormat = format[7..];
+ if (value is IFormattable formattable)
+ {
+ result = formattable.ToString(numberFormat, culture);
+ return true;
+ }
+ }
+ }
+ catch (Exception ex) when (ex is FormatException or OverflowException)
+ {
+ // Invalid format string or numeric overflow — fall through to default conversion
+ }
+
+ return false;
+ }
+
+ private static bool TryFormatDate(object? value, CultureInfo culture, string format, out string? result)
+ {
+ result = null;
+
+ if (!format.StartsWith("date:", StringComparison.OrdinalIgnoreCase) || format.Length <= 5)
+ {
+ return false;
+ }
+
+ string dateFormat = format[5..];
+
+ try
+ {
+ // Use DateTimeOffset to preserve timezone information when available
+ DateTimeOffset? dateTimeOffset = value switch
+ {
+ DateTimeOffset dto => dto,
+ DateTime dt => new DateTimeOffset(dt),
+ string s when TryParseDateTime(s, culture, out DateTimeOffset parsed) => parsed,
+ _ => null
+ };
+
+ if (dateTimeOffset.HasValue)
+ {
+ result = dateTimeOffset.Value.ToString(dateFormat, culture);
+ return true;
+ }
+ }
+ catch (FormatException)
+ {
+ // Invalid format string — fall through to default conversion
+ }
+
+ return false;
+ }
+
+ ///
+ /// Tries to parse a date string, first with InvariantCulture (for ISO formats),
+ /// then with the specified culture as a fallback.
+ ///
+ private static bool TryParseDateTime(string s, CultureInfo culture, out DateTimeOffset parsed)
+ {
+ // Try InvariantCulture first for reliable ISO date parsing (e.g., "2024-01-15", "2024-01-15T10:30:00+02:00")
+ if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed))
+ {
+ return true;
+ }
+
+ // Fall back to the specified culture for locale-specific formats (e.g., "15.01.2024" with de-DE)
+ return DateTimeOffset.TryParse(s, culture, DateTimeStyles.None, out parsed);
+ }
}
diff --git a/docs/for-template-authors/format-specifiers.md b/docs/for-template-authors/format-specifiers.md
index 8e673ea..517133f 100644
--- a/docs/for-template-authors/format-specifiers.md
+++ b/docs/for-template-authors/format-specifiers.md
@@ -1,11 +1,15 @@
# Format Specifiers Guide
-Format specifiers allow you to control how boolean values are displayed in your generated documents. Instead of showing "True" or "False", you can display checkboxes, Yes/No text, checkmarks, and more.
+Format specifiers allow you to control how values are displayed in your generated documents. You can format booleans as checkboxes or Yes/No text, transform string casing, display numbers with specific decimal places, format currency values according to locale, and apply custom date formats.
## Table of Contents
- [Quick Start](#quick-start)
- [Available Format Specifiers](#available-format-specifiers)
+ - [String Formatters](#string-formatters)
+ - [Boolean Formatters](#boolean-formatters)
+ - [Number and Currency Formatters](#number-and-currency-formatters)
+ - [Date Formatters](#date-formatters)
- [Using Format Specifiers](#using-format-specifiers)
- [Localization Support](#localization-support)
- [Custom Formatters](#custom-formatters)
@@ -14,7 +18,7 @@ Format specifiers allow you to control how boolean values are displayed in your
## Quick Start
-Add a format specifier to any boolean placeholder using the `:format` syntax:
+Add a format specifier to any placeholder using the `:format` syntax:
**Template:**
```
@@ -43,7 +47,57 @@ User Status: ☑
## Available Format Specifiers
-### checkbox
+### String Formatters
+
+#### uppercase
+
+Converts a string value to UPPERCASE.
+
+**Example:**
+```
+Customer: {{CustomerName:uppercase}}
+```
+
+**JSON:**
+```json
+{
+ "CustomerName": "alice johnson"
+}
+```
+
+**Output:**
+```
+Customer: ALICE JOHNSON
+```
+
+#### lowercase
+
+Converts a string value to lowercase.
+
+**Example:**
+```
+Code: {{ProductCode:lowercase}}
+```
+
+**JSON:**
+```json
+{
+ "ProductCode": "ABC-123-XYZ"
+}
+```
+
+**Output:**
+```
+Code: abc-123-xyz
+```
+
+**Notes:**
+- String formatters only apply to string values. Non-string values are rendered normally.
+- Case conversion respects the configured culture (e.g., Turkish locale handles `I`/`i` correctly).
+
+### Boolean Formatters
+
+#### checkbox
Displays a checked or unchecked checkbox symbol.
| Value | Output |
@@ -56,7 +110,7 @@ Displays a checked or unchecked checkbox symbol.
Task completed: {{IsCompleted:checkbox}}
```
-### yesno
+#### yesno
Displays "Yes" or "No" text.
| Value | Output |
@@ -69,7 +123,7 @@ Displays "Yes" or "No" text.
Approved: {{IsApproved:yesno}}
```
-### checkmark
+#### checkmark
Displays a checkmark or X symbol.
| Value | Output |
@@ -82,7 +136,7 @@ Displays a checkmark or X symbol.
Valid: {{IsValid:checkmark}}
```
-### truefalse
+#### truefalse
Displays "True" or "False" text (explicit default).
| Value | Output |
@@ -95,7 +149,7 @@ Displays "True" or "False" text (explicit default).
Debug mode: {{DebugEnabled:truefalse}}
```
-### onoff
+#### onoff
Displays "On" or "Off" text.
| Value | Output |
@@ -108,7 +162,7 @@ Displays "On" or "Off" text.
Power: {{PowerStatus:onoff}}
```
-### enabled
+#### enabled
Displays "Enabled" or "Disabled" text.
| Value | Output |
@@ -121,7 +175,7 @@ Displays "Enabled" or "Disabled" text.
Feature flag: {{NewFeature:enabled}}
```
-### active
+#### active
Displays "Active" or "Inactive" text.
| Value | Output |
@@ -134,6 +188,121 @@ Displays "Active" or "Inactive" text.
Account status: {{AccountStatus:active}}
```
+### Number and Currency Formatters
+
+#### currency
+
+Formats a number as currency using the configured culture's currency symbol and format.
+
+| Culture | Input | Output |
+|---------|-------|--------|
+| en-US | 1234.56 | $1,234.56 |
+| de-DE | 1234.56 | 1.234,56 € |
+| fr-FR | 1234.56 | 1 234,56 € |
+
+**Example:**
+```
+Total: {{Amount:currency}}
+```
+
+**JSON:**
+```json
+{
+ "Amount": 1234.56
+}
+```
+
+**Output (en-US culture):**
+```
+Total: $1,234.56
+```
+
+#### number:FORMAT
+
+Formats a number using a .NET format string. The format string follows the colon after `number`.
+
+| Specifier | Description | Input | Output |
+|-----------|-------------|-------|--------|
+| `:number:N2` | Number with 2 decimal places | 1234.5678 | 1,234.57 |
+| `:number:N0` | Number with no decimals | 1234.5 | 1,235 |
+| `:number:F3` | Fixed-point with 3 decimals | 3.14159 | 3.142 |
+| `:number:P` | Percentage | 0.1234 | 12.34 % |
+| `:number:C` | Currency (same as `:currency`) | 42 | $42.00 |
+
+**Example:**
+```
+Value: {{Price:number:N2}}
+Rate: {{InterestRate:number:F3}}
+Progress: {{Completion:number:P}}
+```
+
+**JSON:**
+```json
+{
+ "Price": 1234.5678,
+ "InterestRate": 3.14159,
+ "Completion": 0.85
+}
+```
+
+**Output (en-US culture):**
+```
+Value: 1,234.57
+Rate: 3.142
+Progress: 85.00 %
+```
+
+**Notes:**
+- Number formatters only apply to numeric values (int, long, decimal, double, float)
+- Non-numeric values with a number format specifier are rendered normally (format is ignored)
+- Invalid format strings are handled gracefully — the value falls through to default formatting
+- Format specifier names are case-insensitive: `:currency`, `:CURRENCY`, and `:Currency` all work
+
+### Date Formatters
+
+#### date:FORMAT
+
+Formats date values using a .NET date format string. The format string follows the colon after `date`.
+
+| Specifier | Description | Input | Output (en-US) |
+|-----------|-------------|-------|----------------|
+| `:date:yyyy-MM-dd` | ISO date | 2024-01-15 | 2024-01-15 |
+| `:date:dd.MM.yyyy` | European date | 2024-01-15 | 15.01.2024 |
+| `:date:MMMM d, yyyy` | Long date | 2024-01-15 | January 15, 2024 |
+| `:date:dd. MMMM yyyy` | German long date (de-DE) | 2024-01-15 | 15. Januar 2024 |
+| `:date:yyyy` | Year only | 2024-01-15 | 2024 |
+
+**Example:**
+```
+Order Date: {{OrderDate:date:MMMM d, yyyy}}
+Due Date: {{DueDate:date:dd.MM.yyyy}}
+```
+
+**JSON:**
+```json
+{
+ "OrderDate": "2024-01-15",
+ "DueDate": "2024-02-15"
+}
+```
+
+**Output (en-US culture):**
+```
+Order Date: January 15, 2024
+Due Date: 15.01.2024
+```
+
+**Supported value types:**
+- `DateTime` objects
+- `DateTimeOffset` objects
+- Date strings (parsed automatically, e.g., `"2024-01-15"`, `"01/15/2024"`)
+
+**Notes:**
+- Month and day names are localized based on the configured culture
+- Non-date values with a date format specifier are rendered normally (format is ignored)
+- Unparseable date strings are rendered as-is
+- Invalid format strings are handled gracefully
+
## Using Format Specifiers
### Basic Usage
@@ -762,9 +931,13 @@ var processor = new DocumentTemplateProcessor(options);
## Summary
-Format specifiers provide a powerful way to control boolean value presentation in your documents:
+Format specifiers provide a powerful way to control value presentation in your documents:
-- ✅ 7 built-in formatters (checkbox, yesno, checkmark, truefalse, onoff, enabled, active)
+- ✅ String formatters (`:uppercase`, `:lowercase`)
+- ✅ 7 built-in boolean formatters (checkbox, yesno, checkmark, truefalse, onoff, enabled, active)
+- ✅ Currency formatting with locale support (`:currency`)
+- ✅ Flexible number formatting with .NET format strings (`:number:N2`, `:number:F3`, `:number:P`)
+- ✅ Date formatting with .NET format strings (`:date:yyyy-MM-dd`, `:date:MMMM d, yyyy`)
- ✅ Automatic localization support
- ✅ Custom formatter registration
- ✅ Works with nested properties, arrays, loops, and conditionals