From 18a275fcbf691f003b87f52c4117a5895e2f9ffc Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Fri, 6 Mar 2026 08:52:12 +0100 Subject: [PATCH 1/4] feat: add number and currency format specifiers (#22) Add :currency and :number:FORMAT format specifiers for numeric values. - Update PlaceholderFinder regex to support compound specifiers (e.g., number:N2) - Add number formatting logic to ValueConverter (currency, IFormattable) - Add culture selector dropdown to GUI app - Update documentation: README, CLAUDE.md, CHANGELOG, format-specifiers guide --- CHANGELOG.md | 6 + CLAUDE.md | 2 + README.md | 22 +++ TriasDev.Templify.Gui/Models/CultureOption.cs | 23 +++ .../Services/ITemplifyService.cs | 7 +- .../Services/TemplifyService.cs | 10 +- .../ViewModels/MainWindowViewModel.cs | 25 +++- TriasDev.Templify.Gui/Views/MainWindow.axaml | 9 +- .../FormatSpecifierIntegrationTests.cs | 141 ++++++++++++++++++ .../PlaceholderFinderTests.cs | 64 ++++++++ .../ValueConverterTests.cs | 126 ++++++++++++++++ TriasDev.Templify/Examples.md | 2 +- .../Placeholders/PlaceholderFinder.cs | 4 +- .../Placeholders/ValueConverter.cs | 41 +++++ .../for-template-authors/format-specifiers.md | 96 ++++++++++-- 15 files changed, 559 insertions(+), 19 deletions(-) create mode 100644 TriasDev.Templify.Gui/Models/CultureOption.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a851fe..ce243fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ 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 +- **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..bceffc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -294,6 +294,8 @@ 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}}` ### Conditional Syntax ``` diff --git a/README.md b/README.md index de5f35b..f08dcd8 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,28 @@ To disable (for backward compatibility): var options = new PlaceholderReplacementOptions { EnableNewlineSupport = false }; ``` +### Number and Currency Formatting + +Format numbers and currencies directly in placeholders using format specifiers: + +``` +{{Amount:currency}} → $1,234.57 (en-US) or 1.234,57 € (de-DE) +{{Value:number:N2}} → 1,234.57 +{{Rate:number:F3}} → 3.142 +{{Percentage:number:P}} → 12.34 % +{{Order.Total:currency}} → Works with nested properties +``` + +The `:currency` specifier uses the configured culture's currency format. The `:number:FORMAT` specifier accepts any .NET standard or custom numeric format string. + +```csharp +var options = new PlaceholderReplacementOptions +{ + Culture = new CultureInfo("de-DE") // Formats numbers/currency for German locale +}; +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..b115ce0 --- /dev/null +++ b/TriasDev.Templify.Gui/Models/CultureOption.cs @@ -0,0 +1,23 @@ +// 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 class CultureOption +{ + public string DisplayName { get; } + public CultureInfo Culture { get; } + + public CultureOption(string displayName, CultureInfo culture) + { + DisplayName = displayName; + Culture = 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..b576673 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..a5ac7cc 100644 --- a/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs +++ b/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs @@ -426,6 +426,147 @@ 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 Helper Methods /// diff --git a/TriasDev.Templify.Tests/PlaceholderFinderTests.cs b/TriasDev.Templify.Tests/PlaceholderFinderTests.cs index 5667323..2c848ce 100644 --- a/TriasDev.Templify.Tests/PlaceholderFinderTests.cs +++ b/TriasDev.Templify.Tests/PlaceholderFinderTests.cs @@ -408,4 +408,68 @@ 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 } diff --git a/TriasDev.Templify.Tests/ValueConverterTests.cs b/TriasDev.Templify.Tests/ValueConverterTests.cs index 079cfb7..ae746fa 100644 --- a/TriasDev.Templify.Tests/ValueConverterTests.cs +++ b/TriasDev.Templify.Tests/ValueConverterTests.cs @@ -447,4 +447,130 @@ 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 } 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..65b1574 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, :checkmark, :currency, :number:N2, etc. private static readonly Regex _placeholderPattern = new( - @"\{\{(\.|this|@?[\w\.\[\]]+|\([^\}]+\))(?::(\w+))?\}\}", + @"\{\{(\.|this|@?[\w\.\[\]]+|\([^\}]+\))(?::(\w+(?::\w+)?))?}\}", RegexOptions.Compiled); /// diff --git a/TriasDev.Templify/Placeholders/ValueConverter.cs b/TriasDev.Templify/Placeholders/ValueConverter.cs index a269a04..3f33dc4 100644 --- a/TriasDev.Templify/Placeholders/ValueConverter.cs +++ b/TriasDev.Templify/Placeholders/ValueConverter.cs @@ -43,6 +43,12 @@ public static string ConvertToString(object? value, CultureInfo culture, string? // Fall through to default formatting if format not found } + // Handle number formatting with format specifier + if (!string.IsNullOrWhiteSpace(format) && IsNumeric(value) && TryFormatNumber(value!, culture, format!, out string? numberResult)) + { + return numberResult!; + } + // Default conversion without format return value switch { @@ -59,4 +65,39 @@ 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)) + { + result = Convert.ToDecimal(value, culture).ToString("C", culture); + return true; + } + + if (format.StartsWith("number:", StringComparison.OrdinalIgnoreCase) && format.Length > 7) + { + string numberFormat = format.Substring(7); + if (value is IFormattable formattable) + { + result = formattable.ToString(numberFormat, culture); + return true; + } + } + } + catch (FormatException) + { + // Invalid format string — fall through to default conversion + } + + return false; + } } diff --git a/docs/for-template-authors/format-specifiers.md b/docs/for-template-authors/format-specifiers.md index 8e673ea..330ad68 100644 --- a/docs/for-template-authors/format-specifiers.md +++ b/docs/for-template-authors/format-specifiers.md @@ -1,11 +1,13 @@ # 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, display numbers with specific decimal places, and format currency values according to locale. ## Table of Contents - [Quick Start](#quick-start) - [Available Format Specifiers](#available-format-specifiers) + - [Boolean Formatters](#boolean-formatters) + - [Number and Currency Formatters](#number-and-currency-formatters) - [Using Format Specifiers](#using-format-specifiers) - [Localization Support](#localization-support) - [Custom Formatters](#custom-formatters) @@ -43,7 +45,9 @@ User Status: ☑ ## Available Format Specifiers -### checkbox +### Boolean Formatters + +#### checkbox Displays a checked or unchecked checkbox symbol. | Value | Output | @@ -56,7 +60,7 @@ Displays a checked or unchecked checkbox symbol. Task completed: {{IsCompleted:checkbox}} ``` -### yesno +#### yesno Displays "Yes" or "No" text. | Value | Output | @@ -69,7 +73,7 @@ Displays "Yes" or "No" text. Approved: {{IsApproved:yesno}} ``` -### checkmark +#### checkmark Displays a checkmark or X symbol. | Value | Output | @@ -82,7 +86,7 @@ Displays a checkmark or X symbol. Valid: {{IsValid:checkmark}} ``` -### truefalse +#### truefalse Displays "True" or "False" text (explicit default). | Value | Output | @@ -95,7 +99,7 @@ Displays "True" or "False" text (explicit default). Debug mode: {{DebugEnabled:truefalse}} ``` -### onoff +#### onoff Displays "On" or "Off" text. | Value | Output | @@ -108,7 +112,7 @@ Displays "On" or "Off" text. Power: {{PowerStatus:onoff}} ``` -### enabled +#### enabled Displays "Enabled" or "Disabled" text. | Value | Output | @@ -121,7 +125,7 @@ Displays "Enabled" or "Disabled" text. Feature flag: {{NewFeature:enabled}} ``` -### active +#### active Displays "Active" or "Inactive" text. | Value | Output | @@ -134,6 +138,76 @@ 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 + ## Using Format Specifiers ### Basic Usage @@ -762,9 +836,11 @@ 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) +- ✅ 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`) - ✅ Automatic localization support - ✅ Custom formatter registration - ✅ Works with nested properties, arrays, loops, and conditionals From fa4d371033a76b71c8a1adcf19c812c2eddffd00 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Fri, 6 Mar 2026 08:56:42 +0100 Subject: [PATCH 2/4] feat: add string and date format specifiers (#22) Add :uppercase/:lowercase string formatters and :date:FORMAT date formatter. - String formatters: culture-aware ToUpper/ToLower for string values - Date formatter: supports DateTime, DateTimeOffset, and parseable date strings - Update regex to allow non-word characters in compound format (e.g., date:MMMM d, yyyy) - Update documentation: README, CLAUDE.md, CHANGELOG, format-specifiers guide --- CHANGELOG.md | 7 + CLAUDE.md | 2 + README.md | 16 +- .../FormatSpecifierIntegrationTests.cs | 180 +++++++++++++++++ .../PlaceholderFinderTests.cs | 49 +++++ .../ValueConverterTests.cs | 183 ++++++++++++++++++ .../Placeholders/PlaceholderFinder.cs | 4 +- .../Placeholders/ValueConverter.cs | 55 ++++++ .../for-template-authors/format-specifiers.md | 101 +++++++++- 9 files changed, 587 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce243fe..949c003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `: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 bceffc4..12dfb3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -296,6 +296,8 @@ The library uses a **visitor pattern** for processing Word documents, enabling: - 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 f08dcd8..4fd5593 100644 --- a/README.md +++ b/README.md @@ -189,24 +189,28 @@ To disable (for backward compatibility): var options = new PlaceholderReplacementOptions { EnableNewlineSupport = false }; ``` -### Number and Currency Formatting +### Format Specifiers -Format numbers and currencies directly in placeholders using 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 -{{Rate:number:F3}} → 3.142 {{Percentage:number:P}} → 12.34 % -{{Order.Total:currency}} → Works with nested properties +{{OrderDate:date:yyyy-MM-dd}} → 2024-01-15 +{{OrderDate:date:MMMM d, yyyy}} → January 15, 2024 +{{IsActive:checkbox}} → ☑ or ☐ +{{IsActive:yesno}} → Yes or No ``` -The `:currency` specifier uses the configured culture's currency format. The `:number:FORMAT` specifier accepts any .NET standard or custom numeric format string. +All format specifiers are culture-aware: ```csharp var options = new PlaceholderReplacementOptions { - Culture = new CultureInfo("de-DE") // Formats numbers/currency for German locale + Culture = new CultureInfo("de-DE") // Affects currency, numbers, dates, and localized text }; var processor = new DocumentTemplateProcessor(options); ``` diff --git a/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs b/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs index a5ac7cc..d9ceadb 100644 --- a/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs +++ b/TriasDev.Templify.Tests/Integration/FormatSpecifierIntegrationTests.cs @@ -567,6 +567,186 @@ public void ProcessTemplate_WithNestedPropertyAndCurrencyFormat_ReplacesCorrectl #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 2c848ce..2a58a37 100644 --- a/TriasDev.Templify.Tests/PlaceholderFinderTests.cs +++ b/TriasDev.Templify.Tests/PlaceholderFinderTests.cs @@ -472,4 +472,53 @@ public void FindPlaceholders_WithNestedPropertyAndCurrencyFormat_ParsesBoth() } #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 ae746fa..5ae31f6 100644 --- a/TriasDev.Templify.Tests/ValueConverterTests.cs +++ b/TriasDev.Templify.Tests/ValueConverterTests.cs @@ -573,4 +573,187 @@ public void ConvertToString_WithInvalidNumberFormat_FallsThrough() } #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); + } + + #endregion } diff --git a/TriasDev.Templify/Placeholders/PlaceholderFinder.cs b/TriasDev.Templify/Placeholders/PlaceholderFinder.cs index 65b1574..6839546 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, :currency, :number:N2, etc. + // Optional format specifier: :checkbox, :yesno, :currency, :number:N2, :date:yyyy-MM-dd, etc. private static readonly Regex _placeholderPattern = new( - @"\{\{(\.|this|@?[\w\.\[\]]+|\([^\}]+\))(?::(\w+(?::\w+)?))?}\}", + @"\{\{(\.|this|@?[\w\.\[\]]+|\([^\}]+\))(?::(\w+(?::[^\}]+)?))?}\}", RegexOptions.Compiled); /// diff --git a/TriasDev.Templify/Placeholders/ValueConverter.cs b/TriasDev.Templify/Placeholders/ValueConverter.cs index 3f33dc4..f738fbc 100644 --- a/TriasDev.Templify/Placeholders/ValueConverter.cs +++ b/TriasDev.Templify/Placeholders/ValueConverter.cs @@ -43,12 +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 { @@ -100,4 +120,39 @@ private static bool TryFormatNumber(object value, CultureInfo culture, string fo 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.Substring(5); + + try + { + DateTime? dateTime = value switch + { + DateTime dt => dt, + DateTimeOffset dto => dto.DateTime, + string s when DateTime.TryParse(s, culture, DateTimeStyles.None, out DateTime parsed) => parsed, + _ => null + }; + + if (dateTime.HasValue) + { + result = dateTime.Value.ToString(dateFormat, culture); + return true; + } + } + catch (FormatException) + { + // Invalid format string — fall through to default conversion + } + + return false; + } } diff --git a/docs/for-template-authors/format-specifiers.md b/docs/for-template-authors/format-specifiers.md index 330ad68..517133f 100644 --- a/docs/for-template-authors/format-specifiers.md +++ b/docs/for-template-authors/format-specifiers.md @@ -1,13 +1,15 @@ # Format Specifiers Guide -Format specifiers allow you to control how values are displayed in your generated documents. You can format booleans as checkboxes or Yes/No text, display numbers with specific decimal places, and format currency values according to locale. +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) @@ -16,7 +18,7 @@ Format specifiers allow you to control how values are displayed in your generate ## 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:** ``` @@ -45,6 +47,54 @@ User Status: ☑ ## Available Format Specifiers +### 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 @@ -208,6 +258,51 @@ Progress: 85.00 % - 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 @@ -838,9 +933,11 @@ var processor = new DocumentTemplateProcessor(options); Format specifiers provide a powerful way to control value presentation in your documents: +- ✅ 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 From ad8c871cb147af5cadca2083bdbaaf2b226a24f0 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Fri, 6 Mar 2026 09:15:12 +0100 Subject: [PATCH 3/4] fix: improve format specifier robustness (#22) - Use DateTimeOffset for date formatting to preserve timezone info - Parse date strings with InvariantCulture first for reliable ISO support - Use InvariantCulture for numeric conversion in currency formatting - Escape trailing } in placeholder regex for consistency - Simplify CultureOption to a record, use range syntax, catch OverflowException --- TriasDev.Templify.Gui/Models/CultureOption.cs | 11 +----- .../ValueConverterTests.cs | 29 ++++++++++++++ .../Placeholders/PlaceholderFinder.cs | 2 +- .../Placeholders/ValueConverter.cs | 39 +++++++++++++------ 4 files changed, 59 insertions(+), 22 deletions(-) diff --git a/TriasDev.Templify.Gui/Models/CultureOption.cs b/TriasDev.Templify.Gui/Models/CultureOption.cs index b115ce0..15e43e2 100644 --- a/TriasDev.Templify.Gui/Models/CultureOption.cs +++ b/TriasDev.Templify.Gui/Models/CultureOption.cs @@ -8,16 +8,7 @@ namespace TriasDev.Templify.Gui.Models; /// /// Represents a culture option for the UI dropdown. /// -public class CultureOption +public record CultureOption(string DisplayName, CultureInfo Culture) { - public string DisplayName { get; } - public CultureInfo Culture { get; } - - public CultureOption(string displayName, CultureInfo culture) - { - DisplayName = displayName; - Culture = culture; - } - public override string ToString() => DisplayName; } diff --git a/TriasDev.Templify.Tests/ValueConverterTests.cs b/TriasDev.Templify.Tests/ValueConverterTests.cs index 5ae31f6..6d74036 100644 --- a/TriasDev.Templify.Tests/ValueConverterTests.cs +++ b/TriasDev.Templify.Tests/ValueConverterTests.cs @@ -755,5 +755,34 @@ public void ConvertToString_WithUnparseableStringAndDateFormat_FallsThrough() 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/Placeholders/PlaceholderFinder.cs b/TriasDev.Templify/Placeholders/PlaceholderFinder.cs index 6839546..3e10f15 100644 --- a/TriasDev.Templify/Placeholders/PlaceholderFinder.cs +++ b/TriasDev.Templify/Placeholders/PlaceholderFinder.cs @@ -21,7 +21,7 @@ public sealed class PlaceholderFinder // - Expression: (var1 and var2), (not IsActive), (Count > 0), ((var1 or var2) and var3) // 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 f738fbc..f002fce 100644 --- a/TriasDev.Templify/Placeholders/ValueConverter.cs +++ b/TriasDev.Templify/Placeholders/ValueConverter.cs @@ -99,13 +99,13 @@ private static bool TryFormatNumber(object value, CultureInfo culture, string fo { if (string.Equals(format, "currency", StringComparison.OrdinalIgnoreCase)) { - result = Convert.ToDecimal(value, culture).ToString("C", culture); + result = Convert.ToDecimal(value, CultureInfo.InvariantCulture).ToString("C", culture); return true; } if (format.StartsWith("number:", StringComparison.OrdinalIgnoreCase) && format.Length > 7) { - string numberFormat = format.Substring(7); + string numberFormat = format[7..]; if (value is IFormattable formattable) { result = formattable.ToString(numberFormat, culture); @@ -113,9 +113,9 @@ private static bool TryFormatNumber(object value, CultureInfo culture, string fo } } } - catch (FormatException) + catch (Exception ex) when (ex is FormatException or OverflowException) { - // Invalid format string — fall through to default conversion + // Invalid format string or numeric overflow — fall through to default conversion } return false; @@ -130,21 +130,22 @@ private static bool TryFormatDate(object? value, CultureInfo culture, string for return false; } - string dateFormat = format.Substring(5); + string dateFormat = format[5..]; try { - DateTime? dateTime = value switch + // Use DateTimeOffset to preserve timezone information when available + DateTimeOffset? dateTimeOffset = value switch { - DateTime dt => dt, - DateTimeOffset dto => dto.DateTime, - string s when DateTime.TryParse(s, culture, DateTimeStyles.None, out DateTime parsed) => parsed, + DateTimeOffset dto => dto, + DateTime dt => new DateTimeOffset(dt), + string s when TryParseDateTime(s, culture, out DateTimeOffset parsed) => parsed, _ => null }; - if (dateTime.HasValue) + if (dateTimeOffset.HasValue) { - result = dateTime.Value.ToString(dateFormat, culture); + result = dateTimeOffset.Value.ToString(dateFormat, culture); return true; } } @@ -155,4 +156,20 @@ string s when DateTime.TryParse(s, culture, DateTimeStyles.None, out DateTime pa 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); + } } From a25cc4125daefa4c800b0d127180b38542a1ac90 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Fri, 6 Mar 2026 09:43:13 +0100 Subject: [PATCH 4/4] fix: address PR review feedback for format specifiers (#22) - Use IFormattable for currency formatting instead of Convert.ToDecimal - Update culture selector tooltip to reflect all affected formatting --- TriasDev.Templify.Gui/Views/MainWindow.axaml | 2 +- TriasDev.Templify/Placeholders/ValueConverter.cs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/TriasDev.Templify.Gui/Views/MainWindow.axaml b/TriasDev.Templify.Gui/Views/MainWindow.axaml index b576673..1113119 100644 --- a/TriasDev.Templify.Gui/Views/MainWindow.axaml +++ b/TriasDev.Templify.Gui/Views/MainWindow.axaml @@ -73,7 +73,7 @@ + ToolTip.Tip="Culture used for number, currency, date formatting and string casing"/> diff --git a/TriasDev.Templify/Placeholders/ValueConverter.cs b/TriasDev.Templify/Placeholders/ValueConverter.cs index f002fce..a80d351 100644 --- a/TriasDev.Templify/Placeholders/ValueConverter.cs +++ b/TriasDev.Templify/Placeholders/ValueConverter.cs @@ -99,8 +99,11 @@ private static bool TryFormatNumber(object value, CultureInfo culture, string fo { if (string.Equals(format, "currency", StringComparison.OrdinalIgnoreCase)) { - result = Convert.ToDecimal(value, CultureInfo.InvariantCulture).ToString("C", culture); - return true; + if (value is IFormattable currencyFormattable) + { + result = currencyFormattable.ToString("C", culture); + return true; + } } if (format.StartsWith("number:", StringComparison.OrdinalIgnoreCase) && format.Length > 7)