diff --git a/Radzen.Blazor.Tests/Spreadsheet/FormatCellsDialogTests.cs b/Radzen.Blazor.Tests/Spreadsheet/FormatCellsDialogTests.cs index 7d0c42e8e..9f68216e5 100644 --- a/Radzen.Blazor.Tests/Spreadsheet/FormatCellsDialogTests.cs +++ b/Radzen.Blazor.Tests/Spreadsheet/FormatCellsDialogTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Bunit; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -19,7 +20,8 @@ public class FormatCellsDialogTests : TestContext return RenderComponent(parameters => parameters .Add(p => p.SampleValue, sampleValue ?? 1234.5) .Add(p => p.ValueType, valueType) - .Add(p => p.CurrentFormat, currentFormat)); + .Add(p => p.CurrentFormat, currentFormat) + .Add(p => p.Culture, CultureInfo.InvariantCulture)); } [Fact] diff --git a/Radzen.Blazor.Tests/Spreadsheet/FormulaLocalizerTests.cs b/Radzen.Blazor.Tests/Spreadsheet/FormulaLocalizerTests.cs new file mode 100644 index 000000000..b36f3440b --- /dev/null +++ b/Radzen.Blazor.Tests/Spreadsheet/FormulaLocalizerTests.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using Xunit; + +using Radzen.Documents.Spreadsheet; +namespace Radzen.Blazor.Spreadsheet.Tests; + +public class FormulaLocalizerTests +{ + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + private static readonly CultureInfo Brazilian = CultureInfo.GetCultureInfo("pt-BR"); + private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en-US"); + + [Theory] + [InlineData("=SUM(A1,B1)", "=SUM(A1;B1)")] + [InlineData("=A1*1.5", "=A1*1,5")] + [InlineData("=.5", "=,5")] + [InlineData("=A1-.5", "=A1-,5")] + [InlineData("=5.5%", "=5,5%")] + [InlineData("=1.5E+3", "=1,5E+3")] + [InlineData("=IF(D6>1000,-100.5,0)", "=IF(D6>1000;-100,5;0)")] + [InlineData("=INDEX(I42:I46, MATCH(104, H42:H46, 0))", "=INDEX(I42:I46; MATCH(104; H42:H46; 0))")] + [InlineData("=SUM(A1,.5)", "=SUM(A1;,5)")] + public void ToLocalized_ConvertsSeparatorsForGerman(string invariant, string localized) + { + Assert.Equal(localized, FormulaLocalizer.ToLocalized(invariant, German)); + } + + [Theory] + [InlineData("=SUM(A1;B1)", "=SUM(A1,B1)")] + [InlineData("=A1*1,5", "=A1*1.5")] + [InlineData("=,5", "=.5")] + [InlineData("=A1-,5", "=A1-.5")] + [InlineData("=5,5%", "=5.5%")] + [InlineData("=1,5E+3", "=1.5E+3")] + [InlineData("=SUM(A1;,5)", "=SUM(A1,.5)")] + public void ToInvariant_ConvertsSeparatorsForGerman(string localized, string invariant) + { + Assert.Equal(invariant, FormulaLocalizer.ToInvariant(localized, German)); + } + + [Theory] + [InlineData("=SUM(A1,A2)", "=SUM(A1,A2)")] + [InlineData("=SUM(A1, 5)", "=SUM(A1, 5)")] + [InlineData("=SUM(A1:B2,5)", "=SUM(A1:B2,5)")] + public void ToInvariant_LenientCommaIsSeparatorWhereItCannotBeDecimal(string localized, string invariant) + { + Assert.Equal(invariant, FormulaLocalizer.ToInvariant(localized, German)); + } + + [Fact] + public void ToInvariant_DigitAdjacentCommaBindsAsDecimal() + { + Assert.Equal("=SUM(1.5)", FormulaLocalizer.ToInvariant("=SUM(1,5)", German)); + } + + [Theory] + [InlineData("=IF(A1>1,\"a,b\",\"c;d\")", "=IF(A1>1;\"a,b\";\"c;d\")")] + [InlineData("='My,Sheet'!B2+1.5", "='My,Sheet'!B2+1,5")] + public void Transform_LeavesStringAndSheetLiteralsUntouched(string invariant, string localized) + { + Assert.Equal(localized, FormulaLocalizer.ToLocalized(invariant, German)); + Assert.Equal(invariant, FormulaLocalizer.ToInvariant(localized, German)); + } + + [Fact] + public void Transform_LeavesBracketedSegmentsUntouched() + { + var formula = "=SUM(Table[[Col1],[Col2]])"; + Assert.Equal(formula, FormulaLocalizer.ToLocalized(formula, German)); + Assert.Equal(formula, FormulaLocalizer.ToInvariant(formula, German)); + } + + [Fact] + public void Transform_LeavesArrayLiteralsUntouched() + { + var formula = "={1,2,3}"; + Assert.Same(formula, FormulaLocalizer.ToLocalized(formula, German)); + Assert.Same(formula, FormulaLocalizer.ToInvariant(formula, German)); + } + + [Fact] + public void ToLocalized_ReturnsSameReferenceForDotDecimalCultures() + { + var formula = "=SUM(A1,B1)"; + Assert.Same(formula, FormulaLocalizer.ToLocalized(formula, English)); + Assert.Same(formula, FormulaLocalizer.ToLocalized(formula, CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData("=SUM(A1,B1)")] + [InlineData("=A1*1.5")] + [InlineData("=IF(D6>1000,-100,0)")] + [InlineData("=VLOOKUP(103, H42:J46, 2, FALSE)")] + [InlineData("=ROUND(AVERAGE(E2:E9), 1)")] + [InlineData("=AGGREGATE(14,6,A1:A11,3)")] + [InlineData("=CONCAT(\"a\",\"b\",1.5)")] + [InlineData("=AVERAGEIF(A1:A2,\">1\")")] + [InlineData("=TEXT(A1,\"#,##0.00\")")] + [InlineData("=SUM(1, 2, 3) + IF(2>1, 10, 20)")] + public void RoundTrip_IsIdentityOverFormulaCorpus(string invariant) + { + Assert.Equal(invariant, FormulaLocalizer.ToInvariant(FormulaLocalizer.ToLocalized(invariant, German), German)); + Assert.Equal(invariant, FormulaLocalizer.ToInvariant(FormulaLocalizer.ToLocalized(invariant, Brazilian), Brazilian)); + } + + [Theory] + [InlineData("=SUM(1,5,2,5)")] + [InlineData("=SUM(1,5E3,10)")] + public void MalformedLocalizedInput_ProducesErrorTreeNotThrow(string localized) + { + var invariant = FormulaLocalizer.ToInvariant(localized, German); + var tree = FormulaParser.Parse(invariant); + Assert.NotEmpty(tree.Errors); + Assert.NotNull(tree.Root); + } + + [Fact] + public void MalformedInvariantFormula_ProducesErrorTreeNotThrow() + { + var tree = FormulaParser.Parse("=1.5.2"); + Assert.NotEmpty(tree.Errors); + Assert.NotNull(tree.Root); + } +} diff --git a/Radzen.Blazor.Tests/Spreadsheet/SpreadsheetCultureTests.cs b/Radzen.Blazor.Tests/Spreadsheet/SpreadsheetCultureTests.cs new file mode 100644 index 000000000..ed4317339 --- /dev/null +++ b/Radzen.Blazor.Tests/Spreadsheet/SpreadsheetCultureTests.cs @@ -0,0 +1,359 @@ +using System; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Text.RegularExpressions; +using Xunit; + +using Radzen.Documents.Spreadsheet; +namespace Radzen.Blazor.Spreadsheet.Tests; + +public class SpreadsheetCultureTests +{ + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + private static readonly CultureInfo Brazilian = CultureInfo.GetCultureInfo("pt-BR"); + private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR"); + + private static Worksheet CreateSheet(CultureInfo culture) + { + var wb = new Workbook(); + var sheet = wb.AddSheet("Sheet1", 20, 10); + wb.Culture = culture; + return sheet; + } + + [Theory] + [InlineData("10,50", 10.5)] + [InlineData("1.234,56", 1234.56)] + [InlineData("1.234", 1234d)] + public void SetValue_ParsesNumbersWithGermanCulture(string text, double expected) + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].SetValue(text); + + Assert.Equal(CellDataType.Number, sheet.Cells["A1"].ValueType); + Assert.Equal(expected, sheet.Cells["A1"].Value); + } + + [Fact] + public void SetValue_ParsesGermanDateDespiteGroupSeparatorCollision() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].SetValue("17.08.2024"); + + Assert.Equal(CellDataType.Date, sheet.Cells["A1"].ValueType); + Assert.Equal(new DateTime(2024, 8, 17), sheet.Cells["A1"].Value); + } + + [Fact] + public void SetValue_ParsesGermanShortDate() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].SetValue("12.11"); + + Assert.Equal(CellDataType.Date, sheet.Cells["A1"].ValueType); + var date = Assert.IsType(sheet.Cells["A1"].Value); + Assert.Equal(11, date.Month); + Assert.Equal(12, date.Day); + } + + [Fact] + public void SetValue_InvariantDateStillParsesUnderGermanCulture() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].SetValue("08/17/2024"); + + Assert.Equal(CellDataType.Date, sheet.Cells["A1"].ValueType); + Assert.Equal(new DateTime(2024, 8, 17), sheet.Cells["A1"].Value); + } + + [Fact] + public void SetValue_AmbiguousDateFollowsTheCulture() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].SetValue("01/02/2024"); + + Assert.Equal(new DateTime(2024, 2, 1), sheet.Cells["A1"].Value); + } + + [Theory] + [InlineData("10,50")] + [InlineData("1.234,56")] + public void SetValueGetValue_RoundTripsNumbersUnderGermanCulture(string text) + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].SetValue(text); + var first = sheet.Cells["A1"].Value; + + sheet.Cells["A1"].SetValue(sheet.Cells["A1"].GetValue()); + + Assert.Equal(first, sheet.Cells["A1"].Value); + Assert.Equal(CellDataType.Number, sheet.Cells["A1"].ValueType); + } + + [Theory] + [InlineData("de-DE", "17.08.2024")] + [InlineData("pt-BR", "17/08/2024")] + public void SetValueGetValue_RoundTripsDates(string cultureName, string text) + { + var sheet = CreateSheet(CultureInfo.GetCultureInfo(cultureName)); + sheet.Cells["A1"].SetValue(text); + Assert.Equal(CellDataType.Date, sheet.Cells["A1"].ValueType); + var first = sheet.Cells["A1"].Value; + + sheet.Cells["A1"].SetValue(sheet.Cells["A1"].GetValue()); + + Assert.Equal(first, sheet.Cells["A1"].Value); + Assert.Equal(CellDataType.Date, sheet.Cells["A1"].ValueType); + } + + [Fact] + public void GetValue_FormatsNumberWithWorkbookCulture() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].Value = 10.5; + + Assert.Equal("10,5", sheet.Cells["A1"].GetValue()); + } + + [Fact] + public void FreshWorkbook_DefaultsToCurrentCulture() + { + Assert.Same(CultureInfo.CurrentCulture, new Workbook().Culture); + } + + [Fact] + public void InvariantCulture_ReproducesLegacyParsing() + { + var sheet = CreateSheet(CultureInfo.InvariantCulture); + sheet.Cells["A1"].SetValue("10,50"); + + Assert.Equal(CellDataType.Number, sheet.Cells["A1"].ValueType); + Assert.Equal(1050d, sheet.Cells["A1"].Value); + } + + [Fact] + public void PublicCellDataConstructor_StaysInvariant() + { + var data = new CellData("10,50"); + + Assert.Equal(CellDataType.Number, data.Type); + Assert.Equal(1050d, data.Value); + } + + [Fact] + public void Editor_RoundTripsEditUnderGermanCulture() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].Value = 10.5; + + var editor = new Editor(sheet); + editor.StartEdit(CellRef.Parse("A1"), sheet.Cells["A1"].GetValue()); + + Assert.False(editor.HasChanges); + + editor.Value = "1.234,56"; + Assert.True(editor.Accept()); + Assert.Equal(1234.56, sheet.Cells["A1"].Value); + } + + [Fact] + public void Editor_LocalizedFormulaRoundTripsWithoutSpuriousChanges() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].SetValue("=SUM(B1;1,5)"); + + Assert.Equal("=SUM(B1,1.5)", sheet.Cells["A1"].Formula); + Assert.Equal("=SUM(B1;1,5)", sheet.Cells["A1"].GetValue()); + + var editor = new Editor(sheet); + editor.StartEdit(CellRef.Parse("A1"), sheet.Cells["A1"].GetValue()); + Assert.False(editor.HasChanges); + } + + [Fact] + public void Editor_LenientCommaFormulaCanonicalizesOnCommit() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].SetValue("=SUM(B1,B2)"); + + Assert.Equal("=SUM(B1,B2)", sheet.Cells["A1"].Formula); + Assert.Equal("=SUM(B1;B2)", sheet.Cells["A1"].GetValue()); + } + + [Fact] + public void Clipboard_RoundTripsUnderGermanCulture() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].Value = 10.5; + sheet.Cells["A2"].SetValue("17.08.2024"); + sheet.Cells["A3"].SetValue("=SUM(A1;1,5)"); + + var text = sheet.GetDelimitedString(RangeRef.Parse("A1:A3")); + sheet.InsertDelimitedString(CellRef.Parse("C1"), text); + + Assert.Equal(10.5, sheet.Cells["C1"].Value); + Assert.Equal(sheet.Cells["A2"].Value, sheet.Cells["C2"].Value); + Assert.Equal("=SUM(A1,1.5)", sheet.Cells["C3"].Formula); + } + + [Fact] + public void NumberFormat_RendersWithCultureSeparators() + { + Assert.Equal("1.234,50", NumberFormat.Apply("#,##0.00", 1234.5, CellDataType.Number, German)); + Assert.Equal("1,234.50", NumberFormat.Apply("#,##0.00", 1234.5, CellDataType.Number)); + } + + [Fact] + public void NumberFormat_RendersMultiCharGroupSeparator() + { + var expected = 1234.5.ToString("#,##0.00", French); + Assert.Equal(expected, NumberFormat.Apply("#,##0.00", 1234.5, CellDataType.Number, French)); + } + + [Fact] + public void NumberFormat_RendersGermanMonthNames() + { + var date = new DateTime(2024, 3, 17); + var text = NumberFormat.Apply("dd-mmm-yyyy", date, CellDataType.Date, German); + + Assert.Equal($"17-{date.ToString("MMM", German)}-2024", text); + Assert.Equal("17-Mar-2024", NumberFormat.Apply("dd-mmm-yyyy", date, CellDataType.Date)); + } + + [Fact] + public void NumberFormat_ScientificUsesCultureDecimalSeparator() + { + Assert.Equal("1,23E+03", NumberFormat.Apply("0.00E+00", 1234.5, CellDataType.Number, German)); + } + + [Fact] + public void Ingestion_ParsesInvariantRegardlessOfWorkbookCulture() + { + using var stream = new MemoryStream(); + + var source = new Workbook(); + var sourceSheet = source.AddSheet("Sheet1", 5, 5); + sourceSheet.Cells["A1"].Value = 10.5; + sourceSheet.Cells["A2"].SetValue("hello"); + source.SaveToStream(stream); + + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = German; + + try + { + stream.Position = 0; + var loaded = Workbook.LoadFromStream(stream); + + Assert.Equal(10.5, loaded.Sheets[0].Cells["A1"].Value); + Assert.Equal(CellDataType.Number, loaded.Sheets[0].Cells["A1"].ValueType); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Fact] + public void XlsxRoundTrip_IsPristineUnderGermanCulture() + { + var wb = new Workbook(); + var sheet = wb.AddSheet("Sheet1", 10, 5); + sheet.Cells["A1"].Value = 1234.56; + sheet.Cells["A1"].Format.NumberFormat = "#,##0.00"; + sheet.Cells["A2"].SetValue("17.08.2024"); + sheet.Cells["A3"].Formula = "=SUM(A1,1.5)"; + wb.Culture = German; + + using var germanStream = new MemoryStream(); + wb.SaveToStream(germanStream); + + wb.Culture = CultureInfo.InvariantCulture; + using var invariantStream = new MemoryStream(); + wb.SaveToStream(invariantStream); + + // The archive embeds timestamps/uids; culture independence is about the sheet XML. + Assert.Equal(ReadSheetXml(invariantStream), ReadSheetXml(germanStream)); + + germanStream.Position = 0; + var reloaded = Workbook.LoadFromStream(germanStream); + + Assert.Equal(1234.56, reloaded.Sheets[0].Cells["A1"].Value); + Assert.Equal("=SUM(A1,1.5)", reloaded.Sheets[0].Cells["A3"].Formula); + } + + private static string ReadSheetXml(MemoryStream stream) + { + stream.Position = 0; + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + using var reader = new StreamReader(archive.GetEntry("xl/worksheets/sheet1.xml")!.Open()); + // The worksheet element carries a random per-save uid. + return Regex.Replace(reader.ReadToEnd(), "uid=\"\\{[0-9A-F-]+\\}\"", "uid=\"\""); + } + + [Fact] + public void ValidationList_ItemsAndValidationShareTheWorkbookCulture() + { + var sheet = CreateSheet(German); + sheet.Cells["B1"].Value = 1.5; + sheet.Cells["B2"].Value = 2.5; + + var rule = new DataValidationRule + { + Type = DataValidationType.List, + Formula1 = "=B1:B2" + }; + + var items = rule.GetListItems(sheet); + Assert.Contains("1,5", items); + + sheet.Cells["A1"].SetValue("1,5"); + sheet.Validation.Add(RangeRef.Parse("A1:A1"), rule); + Assert.True(rule.Validate(sheet.Cells["A1"])); + } + + [Fact] + public void Autofill_PlainCopyDoesNotReinferStringType() + { + var sheet = CreateSheet(German); + // Text under invariant inference; a Date if re-inferred under de-DE. + sheet.Cells["A1"].SetValueInvariant("31.12.2024"); + Assert.Equal(CellDataType.String, sheet.Cells["A1"].ValueType); + + var command = new AutofillCommand(sheet, RangeRef.Parse("A1:A1"), RangeRef.Parse("A1:A3"), AutofillDirection.Down); + command.Execute(); + + Assert.Equal(CellDataType.String, sheet.Cells["A3"].ValueType); + Assert.Equal("31.12.2024", sheet.Cells["A3"].Value); + } + + [Fact] + public void HyperlinkCommand_UndoRestoresExactCellData() + { + var sheet = CreateSheet(German); + sheet.Cells["A1"].SetValueInvariant("31.12.2024"); + var before = sheet.Cells["A1"].Data; + + var command = new HyperlinkCommand(sheet, CellRef.Parse("A1"), new Hyperlink { Url = "https://radzen.com", Text = "Radzen" }); + command.Execute(); + Assert.Equal("Radzen", sheet.Cells["A1"].Value); + + command.Unexecute(); + + Assert.Same(before, sheet.Cells["A1"].Data); + Assert.Equal(CellDataType.String, sheet.Cells["A1"].ValueType); + } + + [Fact] + public void FormatParseCache_StaysBounded() + { + for (var i = 0; i < 600; i++) + { + NumberFormat.Apply($"\"c{i}\"0.00", 1.5, CellDataType.Number); + } + + // Bounded, not exact: the capacity guard admits a small race overshoot. + Assert.InRange(NumberFormatParser.CacheCount, 0, 520); + } +} diff --git a/Radzen.Blazor.Tests/SpreadsheetTests.cs b/Radzen.Blazor.Tests/SpreadsheetTests.cs index 5015c4cd5..4d5524123 100644 --- a/Radzen.Blazor.Tests/SpreadsheetTests.cs +++ b/Radzen.Blazor.Tests/SpreadsheetTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Bunit; @@ -32,6 +33,47 @@ public class SpreadsheetTests return wb; } + [Fact] + public void CultureParameter_StampsWorkbookAndRerendersFormattedCells() + { + using var ctx = CreateContext(); + var wb = NewWorkbook(); + var sheet = wb.Sheets[0]; + sheet.Cells[0, 0].Value = 1234.5; + sheet.Cells[0, 0].Format.NumberFormat = "#,##0.00"; + + var c = ctx.RenderComponent(p => p + .Add(x => x.Workbook, wb) + .Add(x => x.Culture, CultureInfo.InvariantCulture)); + + Assert.Same(CultureInfo.InvariantCulture, wb.Culture); + Assert.Contains("1,234.50", c.Markup); + + var german = CultureInfo.GetCultureInfo("de-DE"); + c.SetParametersAndRender(p => p.Add(x => x.Culture, german)); + + Assert.Same(german, wb.Culture); + Assert.Contains("1.234,50", c.Markup); + } + + [Fact] + public void HandSetWorkbookCulture_SurvivesParameterSets() + { + using var ctx = CreateContext(); + var wb = NewWorkbook(); + + var c = ctx.RenderComponent(p => p + .Add(x => x.Workbook, wb) + .Add(x => x.Culture, CultureInfo.InvariantCulture)); + + var german = CultureInfo.GetCultureInfo("de-DE"); + wb.Culture = german; + + c.SetParametersAndRender(p => p.Add(x => x.ReadOnly, true)); + + Assert.Same(german, wb.Culture); + } + // ExecuteAsync triggers cell-change StateHasChanged callbacks that have to run on // the Blazor dispatcher. Wrap with InvokeAsync so tests can call the API freely. private static async Task Run(IRenderedComponent c, ICommand command) @@ -874,7 +916,9 @@ public class SpreadsheetTests sheet.Cells[0, 0].Value = 39448d; sheet.Cells[0, 0].Format.NumberFormat = "0.00"; - var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb)); + var c = ctx.RenderComponent(p => p + .Add(x => x.Workbook, wb) + .Add(x => x.Culture, CultureInfo.InvariantCulture)); await AutoFit(c, 0); diff --git a/Radzen.Blazor/Documents/Spreadsheet/Cell.cs b/Radzen.Blazor/Documents/Spreadsheet/Cell.cs index 9e9a90609..152151d16 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Cell.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Cell.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; namespace Radzen.Documents.Spreadsheet; @@ -114,10 +115,17 @@ public class Cell /// /// Gets the text displayed in the cell: the number format applied to the value, or the value as a string. + /// Rendered with the workbook culture. /// - public string? GetDisplayText() + public string? GetDisplayText() => FormatDisplayText(Culture); + + /// + /// Formats the display text with the specified culture. The XLSX writer measures autofit widths + /// with the invariant culture so saved files are host-independent. + /// + internal string? FormatDisplayText(CultureInfo culture) { - return NumberFormat.Apply(format?.NumberFormat, Value, ValueType) ?? Value?.ToString(); + return NumberFormat.Apply(format?.NumberFormat, Value, ValueType, culture) ?? FormatValue(culture); } internal Format? GetEffectiveFormat() @@ -162,13 +170,27 @@ public class Cell { return; } - Data = new CellData(value); + Data = new CellData(value, Culture); QuotePrefix = false; Worksheet.OnCellValueChanged(this); } } + private CultureInfo Culture => Worksheet.Culture; + + /// + /// Sets the cell value with invariant type inference - file content is canonical and must parse + /// the same on every host. + /// + internal void SetValueInvariant(string? value) + { + Data = new CellData(value); + QuotePrefix = false; + + Worksheet.OnCellValueChanged(this); + } + /// /// Gets the value of the cell as a string, or the formula if it exists. /// @@ -176,7 +198,7 @@ public class Cell { if (!string.IsNullOrEmpty(Formula)) { - return Formula; + return FormulaLocalizer.ToLocalized(Formula, Culture); } var text = GetValueAsString(); @@ -184,15 +206,19 @@ public class Cell } /// - /// Gets the value of the cell as a string. + /// Gets the value of the cell as a string, formatted with the workbook culture so that the + /// text round-trips through under the same culture. /// - public string? GetValueAsString() + public string? GetValueAsString() => FormatValue(Culture); + + private string? FormatValue(CultureInfo culture) { return Value switch { null => null, CellError error => error.ToString(), string str => str, + IFormattable formattable => formattable.ToString(null, culture), _ => Value.ToString() }; } @@ -216,7 +242,7 @@ public class Cell } else if (value?.StartsWith('=') == true && value != "=") { - Formula = value; + Formula = FormulaLocalizer.ToInvariant(value, Culture); } else { diff --git a/Radzen.Blazor/Documents/Spreadsheet/CellData.cs b/Radzen.Blazor/Documents/Spreadsheet/CellData.cs index 824f7ef30..f206ca6e2 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/CellData.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/CellData.cs @@ -100,9 +100,14 @@ public class CellData : IComparable, IComparable public CellDataType Type { get; } /// - /// Creates a new instance of CellData with the specified data. + /// Creates a new instance of CellData with the specified data. String values are type-inferred + /// using the invariant culture. /// - public CellData(object? data) + public CellData(object? data) : this(data, CultureInfo.InvariantCulture) + { + } + + internal CellData(object? data, CultureInfo culture) { if (data is null) { @@ -117,7 +122,7 @@ public class CellData : IComparable, IComparable if (valType == typeof(string) || (isNullable && nullableType == typeof(string))) { - var converted = TryConvertFromString(data.ToString(), out var convertedData, out var valueType); + var converted = TryConvertFromString(data.ToString(), culture, out var convertedData, out var valueType); if (converted) { Value = convertedData; @@ -137,6 +142,9 @@ public class CellData : IComparable, IComparable } internal static bool TryConvertFromString(string? value, out object? converted, out CellDataType? valueType) + => TryConvertFromString(value, CultureInfo.InvariantCulture, out converted, out valueType); + + internal static bool TryConvertFromString(string? value, CultureInfo culture, out object? converted, out CellDataType? valueType) { if (value is null) { @@ -145,14 +153,27 @@ public class CellData : IComparable, IComparable return false; } - if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var valNum)) + var canBeDate = CanBeDate(value); + + // Where the group separator doubles as the date separator (de-DE ".") 17.08.2024 would parse + // as the number 17082024, so explicit day-month dates are tried first (Excel semantics). + if (canBeDate && GroupSeparatorCollidesWithDateSeparator(culture) && + value.Contains(culture.DateTimeFormat.DateSeparator, StringComparison.Ordinal) && + TryParseDayMonthDate(value, culture, out var collisionDate)) + { + valueType = CellDataType.Date; + converted = collisionDate; + return true; + } + + if (double.TryParse(value, NumberStyles.Any, culture, out var valNum)) { converted = valNum; valueType = CellDataType.Number; return true; } - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var valDate)) + if (canBeDate && TryParseDate(value, culture, out var valDate)) { valueType = CellDataType.Date; converted = valDate; @@ -171,6 +192,68 @@ public class CellData : IComparable, IComparable return false; } + // "/" is the custom-format placeholder for the culture's date separator. Lenient TryParse would + // steal grouped numbers like "1.234" as month-year dates. + private static readonly string[] dayMonthPatterns = ["d/M/yyyy", "d/M/yy", "d/M"]; + + private static bool TryParseDayMonthDate(string value, CultureInfo culture, out DateTime date) + => DateTime.TryParseExact(value, dayMonthPatterns, culture, DateTimeStyles.None, out date); + + private static bool TryParseDate(string value, CultureInfo culture, out DateTime date) + { + if (DateTime.TryParse(value, culture, DateTimeStyles.None, out date)) + { + return true; + } + + // Rescue canonical/US date strings the culture rejects (e.g. 08/17/2024 under de-DE). + return !culture.Equals(CultureInfo.InvariantCulture) && + DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out date); + } + + // Skips the expensive DateTime.TryParse for plain text on bulk parsing paths. + private static bool CanBeDate(string value) + { + var hasDigit = false; + var hasSeparatorOrLetter = false; + + foreach (var ch in value) + { + if (char.IsAsciiDigit(ch)) + { + hasDigit = true; + } + else if (ch is '/' or '-' or '.' or ':' or ',' || char.IsLetter(ch)) + { + hasSeparatorOrLetter = true; + } + + if (hasDigit && hasSeparatorOrLetter) + { + return true; + } + } + + return false; + } + + private sealed record SeparatorCollision(CultureInfo Culture, bool Collides); + + private static SeparatorCollision? separatorCollision; + + private static bool GroupSeparatorCollidesWithDateSeparator(CultureInfo culture) + { + var cached = separatorCollision; + + if (cached is null || !ReferenceEquals(cached.Culture, culture)) + { + cached = new(culture, culture.NumberFormat.NumberGroupSeparator == culture.DateTimeFormat.DateSeparator); + separatorCollision = cached; + } + + return cached.Collides; + } + internal bool TryGetInt(out int value, bool allowBooleans = true, bool nonNumericTextAsZero = false) { value = 0; diff --git a/Radzen.Blazor/Documents/Spreadsheet/CsvReader.cs b/Radzen.Blazor/Documents/Spreadsheet/CsvReader.cs index 7e3ad8048..04ae56c69 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/CsvReader.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/CsvReader.cs @@ -137,8 +137,7 @@ static class CsvReader if (options.ParseValues) { - // Value setter goes through CellData, which auto-detects numeric/date/boolean. - cell.Value = raw; + cell.SetValueInvariant(raw); } else { diff --git a/Radzen.Blazor/Documents/Spreadsheet/DataValidationRule.cs b/Radzen.Blazor/Documents/Spreadsheet/DataValidationRule.cs index 982910ba9..edb754d47 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/DataValidationRule.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/DataValidationRule.cs @@ -167,7 +167,7 @@ public class DataValidationRule : ICellValidator { if (sheet.Cells.TryGet(row, col, out var cell) && cell.Value is not null) { - items.Add(cell.Value.ToString() ?? ""); + items.Add(cell.GetValueAsString() ?? ""); } else { @@ -251,7 +251,8 @@ public class DataValidationRule : ICellValidator private bool ValidateList(Cell cell) { var items = GetListItems(cell.Worksheet); - var cellValue = cell.Value?.ToString() ?? ""; + // Must match the culture used to render the list items in GetListItems. + var cellValue = cell.GetValueAsString() ?? ""; return items.Any(item => string.Equals(item, cellValue, StringComparison.OrdinalIgnoreCase)); } diff --git a/Radzen.Blazor/Documents/Spreadsheet/FormulaLocalizer.cs b/Radzen.Blazor/Documents/Spreadsheet/FormulaLocalizer.cs new file mode 100644 index 000000000..e96375d61 --- /dev/null +++ b/Radzen.Blazor/Documents/Spreadsheet/FormulaLocalizer.cs @@ -0,0 +1,215 @@ +using System; +using System.Globalization; +using System.Text; + +namespace Radzen.Documents.Spreadsheet; + +#nullable enable + +/// +/// Converts formulas between the canonical invariant form (stored in and XLSX) +/// and the localized form users type in comma-decimal cultures (";" separators, "," decimals) - +/// the Excel FormulaLocal semantics. A pure text transform; the engine and storage stay invariant. +/// +internal static class FormulaLocalizer +{ + /// + /// Converts a canonical invariant formula to the localized form for the specified culture. + /// + internal static string ToLocalized(string formula, CultureInfo culture) + { + if (!TryGetLocalSyntax(formula, culture, out var localDecimal, out var localSeparator)) + { + return formula; + } + + return Transform(formula, separator: ',', toSeparator: localSeparator, decimalChar: '.', toDecimal: localDecimal, lenientComma: false); + } + + /// + /// Converts a formula typed in localized form back to the canonical invariant form. Lenient: + /// "," is also accepted as an argument separator where it cannot be a decimal (=SUM(A1,A2) works in de-DE). + /// + internal static string ToInvariant(string formula, CultureInfo culture) + { + if (!TryGetLocalSyntax(formula, culture, out var localDecimal, out var localSeparator)) + { + return formula; + } + + return Transform(formula, separator: localSeparator, toSeparator: ',', decimalChar: localDecimal, toDecimal: '.', lenientComma: true); + } + + private static bool TryGetLocalSyntax(string formula, CultureInfo culture, out char localDecimal, out char localSeparator) + { + localDecimal = '.'; + localSeparator = ','; + + var dec = culture.NumberFormat.NumberDecimalSeparator; + + if (string.IsNullOrEmpty(formula) || dec.Length != 1 || dec[0] == '.') + { + return false; + } + + localDecimal = dec[0]; + + if (localDecimal == ',') + { + localSeparator = ';'; + } + else + { + var list = culture.TextInfo.ListSeparator; + localSeparator = list.Length == 1 && list[0] != localDecimal && list[0] != '.' ? list[0] : ';'; + } + + return true; + } + + private static string Transform(string formula, char separator, char toSeparator, char decimalChar, char toDecimal, bool lenientComma) + { + // Allocated on the first converted character; an unchanged formula returns itself. + StringBuilder? result = null; + + // The digits of A1 or my.name2 must not form a decimal context, so track how a run started. + var inRun = false; + var runStartsWithIdentifier = false; + + for (var i = 0; i < formula.Length; i++) + { + var c = formula[i]; + + // Array constants are not supported by the engine. + if (c is '{' or '}') + { + return formula; + } + + if (c is '"' or '\'') + { + var end = SkipQuoted(formula, i, c); + result?.Append(formula, i, end - i); + i = end - 1; + inRun = false; + continue; + } + + if (c == '[') + { + var end = SkipBrackets(formula, i); + result?.Append(formula, i, end - i); + i = end - 1; + inRun = false; + continue; + } + + char? converted = null; + + if (c == decimalChar && IsDecimalContext(formula, i, inRun, runStartsWithIdentifier)) + { + converted = toDecimal; + } + else if (c == separator || (lenientComma && c == ',')) + { + converted = toSeparator; + } + + if (converted is char replacement && replacement != c) + { + result ??= new StringBuilder(formula.Length).Append(formula, 0, i); + result.Append(replacement); + inRun = false; + continue; + } + + if (char.IsAsciiLetter(c) || c is '_' or '$') + { + if (!inRun) + { + inRun = true; + runStartsWithIdentifier = true; + } + } + else if (char.IsAsciiDigit(c)) + { + if (!inRun) + { + inRun = true; + runStartsWithIdentifier = false; + } + } + else + { + inRun = false; + } + + result?.Append(c); + } + + return result?.ToString() ?? formula; + } + + // Honors doubled-quote escapes ("" and ''). + private static int SkipQuoted(string formula, int start, char quote) + { + for (var i = start + 1; i < formula.Length; i++) + { + if (formula[i] == quote) + { + if (i + 1 < formula.Length && formula[i + 1] == quote) + { + i++; + continue; + } + + return i + 1; + } + } + + return formula.Length; + } + + // Depth-counted: Table[[Col1],[Col2]] must skip to the outer close bracket. + private static int SkipBrackets(string formula, int start) + { + var depth = 0; + + for (var i = start; i < formula.Length; i++) + { + if (formula[i] == '[') + { + depth++; + } + else if (formula[i] == ']' && --depth == 0) + { + return i + 1; + } + } + + return formula.Length; + } + + // Decimal context: digit on the right, and a pure numeric run or formula boundary on the left. + private static bool IsDecimalContext(string formula, int index, bool inRun, bool runStartsWithIdentifier) + { + if (index + 1 >= formula.Length || !char.IsAsciiDigit(formula[index + 1])) + { + return false; + } + + if (index == 0) + { + return true; + } + + var prev = formula[index - 1]; + + if (char.IsAsciiDigit(prev)) + { + return inRun && !runStartsWithIdentifier; + } + + return prev is '=' or '(' or ',' or ';' or '+' or '-' or '*' or '/' or '^' or '<' or '>' or '&' or '%' or ':'; + } +} diff --git a/Radzen.Blazor/Documents/Spreadsheet/FormulaParser.cs b/Radzen.Blazor/Documents/Spreadsheet/FormulaParser.cs index 90c8fc8b6..42bf5c8b4 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/FormulaParser.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/FormulaParser.cs @@ -320,8 +320,17 @@ internal class FormulaParser public static FormulaSyntaxTree Parse(string expression) { - var parser = new FormulaParser(expression); - return parser.Parse(); + try + { + var parser = new FormulaParser(expression); + return parser.Parse(); + } + catch (InvalidOperationException ex) + { + // The lexer throws on malformed numbers; surface it like parser-collected errors. + var token = new FormulaToken(FormulaTokenType.ErrorLiteral, CellError.Name.ToString()); + return new FormulaSyntaxTree(new ErrorLiteralSyntaxNode(token), [ex.Message]); + } } private FormulaSyntaxTree Parse() diff --git a/Radzen.Blazor/Documents/Spreadsheet/NumberFormat.cs b/Radzen.Blazor/Documents/Spreadsheet/NumberFormat.cs index 25e070082..2ebf1682b 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/NumberFormat.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/NumberFormat.cs @@ -18,14 +18,31 @@ public static class NumberFormat /// Returns null when the format is General/null (caller should fall back to default rendering). /// public static string? Apply(string? formatCode, object? value, CellDataType type) - => ApplyWithColor(formatCode, value, type).Text; + => ApplyWithColor(formatCode, value, type, CultureInfo.InvariantCulture).Text; + + /// + /// Applies a format code to a value and returns the formatted string, rendering separators, + /// month/day names and AM/PM designators with the specified culture. Format codes themselves + /// are canonical invariant tokens. + /// + public static string? Apply(string? formatCode, object? value, CellDataType type, CultureInfo culture) + => ApplyWithColor(formatCode, value, type, culture).Text; /// /// Applies a format code to a value and returns the formatted string and optional color. /// The color is determined by color codes in the format string (e.g., [Red], [Green]). /// public static (string? Text, string? Color) ApplyWithColor(string? formatCode, object? value, CellDataType type) + => ApplyWithColor(formatCode, value, type, CultureInfo.InvariantCulture); + + /// + /// Applies a format code to a value and returns the formatted string and optional color, + /// rendering separators, month/day names and AM/PM designators with the specified culture. + /// + public static (string? Text, string? Color) ApplyWithColor(string? formatCode, object? value, CellDataType type, CultureInfo culture) { + ArgumentNullException.ThrowIfNull(culture); + if (value is null || string.IsNullOrEmpty(formatCode) || string.Equals(formatCode, "General", StringComparison.OrdinalIgnoreCase)) { @@ -54,11 +71,11 @@ public static class NumberFormat if (section.IsDate) { - text = FormatDate(section, value, type, number); + text = FormatDate(section, value, type, number, culture); } else { - text = FormatNumber(section, number); + text = FormatNumber(section, number, culture); } return (text, section.Color); @@ -249,7 +266,7 @@ public static class NumberFormat return parsed.Sections[0]; } - private static string FormatDate(FormatSection section, object? value, CellDataType type, double number) + private static string FormatDate(FormatSection section, object? value, CellDataType type, double number, CultureInfo culture) { DateTime dt; if (value is DateTime dateTime) @@ -290,9 +307,9 @@ public static class NumberFormat { 1 => dt.Month.ToString(CultureInfo.InvariantCulture), 2 => dt.ToString("MM", CultureInfo.InvariantCulture), - 3 => dt.ToString("MMM", CultureInfo.InvariantCulture), - 4 => dt.ToString("MMMM", CultureInfo.InvariantCulture), - _ => dt.ToString("MMMM", CultureInfo.InvariantCulture) + 3 => dt.ToString("MMM", culture), + 4 => dt.ToString("MMMM", culture), + _ => dt.ToString("MMMM", culture) }); break; case TokenType.Day: @@ -300,8 +317,8 @@ public static class NumberFormat { 1 => dt.Day.ToString(CultureInfo.InvariantCulture), 2 => dt.ToString("dd", CultureInfo.InvariantCulture), - 3 => dt.ToString("ddd", CultureInfo.InvariantCulture), - _ => dt.ToString("dddd", CultureInfo.InvariantCulture) + 3 => dt.ToString("ddd", culture), + _ => dt.ToString("dddd", culture) }); break; case TokenType.Hour: @@ -332,7 +349,12 @@ public static class NumberFormat : dt.Second.ToString(CultureInfo.InvariantCulture)); break; case TokenType.AmPm: - sb.Append(dt.Hour >= 12 ? "PM" : "AM"); + var designator = dt.Hour >= 12 ? culture.DateTimeFormat.PMDesignator : culture.DateTimeFormat.AMDesignator; + if (string.IsNullOrEmpty(designator)) + { + designator = dt.Hour >= 12 ? "PM" : "AM"; + } + sb.Append(designator); break; } } @@ -340,7 +362,7 @@ public static class NumberFormat return StringBuilderCache.GetStringAndRelease(sb); } - private static string FormatNumber(FormatSection section, double value) + private static string FormatNumber(FormatSection section, double value, CultureInfo culture) { // Section with no digit placeholders (e.g. literal "-") returns just prefix+suffix if (!section.HasDigitPlaceholders) @@ -350,7 +372,7 @@ public static class NumberFormat if (section.IsScientific) { - return FormatScientific(section, value); + return FormatScientific(section, value, culture); } var absValue = Math.Abs(value); @@ -400,15 +422,18 @@ public static class NumberFormat intStr = intStr.PadLeft(intZeros, '0'); } + var groupSeparator = culture.NumberFormat.NumberGroupSeparator; + var decimalSeparator = culture.NumberFormat.NumberDecimalSeparator; + if (hasThousands && intStr.Length > 0) { - var formatted = new StringBuilder(intStr.Length + (intStr.Length - 1) / 3); + var formatted = new StringBuilder(intStr.Length + (intStr.Length - 1) / 3 * groupSeparator.Length); for (var i = 0; i < intStr.Length; i++) { var remaining = intStr.Length - i; if (i > 0 && remaining % 3 == 0) { - formatted.Append(','); + formatted.Append(groupSeparator); } formatted.Append(intStr[i]); } @@ -419,7 +444,7 @@ public static class NumberFormat if (decimalPlaces > 0) { - sb.Append('.'); + sb.Append(decimalSeparator); var fracStr = Math.Round(fracPart, decimalPlaces, MidpointRounding.AwayFromZero) .ToString("F" + decimalPlaces, CultureInfo.InvariantCulture); // Remove "0." prefix @@ -465,10 +490,10 @@ public static class NumberFormat fracStr = new string(chars); } - // If all decimal digits stripped, remove the dot too + // If all decimal digits stripped, remove the decimal separator too if (fracStr.Length == 0) { - sb.Length--; // remove the '.' + sb.Length -= decimalSeparator.Length; } else { @@ -481,7 +506,7 @@ public static class NumberFormat return StringBuilderCache.GetStringAndRelease(sb); } - private static string FormatScientific(FormatSection section, double value) + private static string FormatScientific(FormatSection section, double value, CultureInfo culture) { var sb = StringBuilderCache.Acquire(); sb.Append(section.Prefix); @@ -528,7 +553,7 @@ public static class NumberFormat if (section.DecimalPlaces > 0) { - sb.Append(mantissa.ToString("F" + section.DecimalPlaces, CultureInfo.InvariantCulture)); + sb.Append(mantissa.ToString("F" + section.DecimalPlaces, culture)); } else { diff --git a/Radzen.Blazor/Documents/Spreadsheet/NumberFormatParser.cs b/Radzen.Blazor/Documents/Spreadsheet/NumberFormatParser.cs index 53f0d4d84..a9917c4cc 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/NumberFormatParser.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/NumberFormatParser.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Text; +using System.Threading; namespace Radzen.Documents.Spreadsheet; @@ -8,7 +10,32 @@ namespace Radzen.Documents.Spreadsheet; static class NumberFormatParser { + // ParsedFormat is immutable and culture-independent (culture applies at render time). Hard-bounded + // because format codes can be generated dynamically (AdjustDecimals) - a full cache stops accepting. + private const int CacheCapacity = 512; + private static readonly ConcurrentDictionary cache = new(); + private static int cacheCount; + + internal static int CacheCount => Volatile.Read(ref cacheCount); + public static ParsedFormat Parse(string formatCode) + { + if (cache.TryGetValue(formatCode, out var cached)) + { + return cached; + } + + var parsed = ParseCore(formatCode); + + if (Volatile.Read(ref cacheCount) < CacheCapacity && cache.TryAdd(formatCode, parsed)) + { + Interlocked.Increment(ref cacheCount); + } + + return parsed; + } + + private static ParsedFormat ParseCore(string formatCode) { var parts = SplitSections(formatCode); var sections = new List(); diff --git a/Radzen.Blazor/Documents/Spreadsheet/Table.cs b/Radzen.Blazor/Documents/Spreadsheet/Table.cs index 52764d3a2..9d9fa9bed 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Table.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Table.cs @@ -181,7 +181,7 @@ public class Table for (var i = 0; i < columns.Count; i++) { var sheetCol = range.Start.Column + i; - Worksheet.Cells[headerRow, sheetCol].Value = columns[i].Name; + Worksheet.Cells[headerRow, sheetCol].SetValueInvariant(columns[i].Name); } showFilterButton = true; diff --git a/Radzen.Blazor/Documents/Spreadsheet/Workbook.cs b/Radzen.Blazor/Documents/Spreadsheet/Workbook.cs index f8fb72d44..99b400a45 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Workbook.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Workbook.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; namespace Radzen.Documents.Spreadsheet; @@ -17,6 +18,19 @@ public class Workbook /// public IReadOnlyList Sheets => sheets; + private CultureInfo? culture; + + /// + /// Gets or sets the culture used to parse typed cell input and to render values and number formats. + /// If not set, falls back to . + /// File storage (XLSX, CSV) and formula storage always use the invariant culture regardless of this setting. + /// + public CultureInfo Culture + { + get => culture ?? CultureInfo.CurrentCulture; + set => culture = value; + } + /// /// Gets or sets the workbook protection settings. /// diff --git a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs index 56bb4db02..5c7712737 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; @@ -234,6 +235,8 @@ public partial class Worksheet internal set => workbook = value; } + internal CultureInfo Culture => Workbook.Culture; + /// /// Returns true if the cell at the given address is editable (either protection is off or the cell is unlocked). /// diff --git a/Radzen.Blazor/Documents/Spreadsheet/XlsxReader.cs b/Radzen.Blazor/Documents/Spreadsheet/XlsxReader.cs index 9dd548222..7da0629d3 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/XlsxReader.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/XlsxReader.cs @@ -536,7 +536,7 @@ static class XlsxReader _ => valueElem!.Value }; - sheet.Cells[address.Row, address.Column].Value = value; + sheet.Cells[address.Row, address.Column].SetValueInvariant(value); } ApplyCellStyle(cellElem, sheet, address, styleInfo); diff --git a/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs b/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs index 4899d5362..36bd2615f 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs @@ -1131,7 +1131,7 @@ class XlsxWriter(Workbook sourceWorkbook) private static XElement CreateDimension(Worksheet sheet) { var populated = sheet.Cells.GetPopulatedCells() - .Where(c => c.GetValue() is not null) + .Where(c => c.Value is not null || c.Formula is not null) .ToList(); string refStr; @@ -1216,7 +1216,8 @@ class XlsxWriter(Workbook sourceWorkbook) try { - text = cell.GetDisplayText(); + // Autofit widths must be culture-independent so saved XML is deterministic. + text = cell.FormatDisplayText(CultureInfo.InvariantCulture); format = cell.GetEffectiveFormat(); } catch (ArgumentOutOfRangeException) @@ -1255,7 +1256,7 @@ class XlsxWriter(Workbook sourceWorkbook) : (rowMap[row] = new SortedDictionary()); // 1. Real data cells. - foreach (var cell in sheet.Cells.GetPopulatedCells().Where(c => c.GetValue() is not null)) + foreach (var cell in sheet.Cells.GetPopulatedCells().Where(c => c.Value is not null || c.Formula is not null)) { var rowDict = EnsureRow(cell.Address.Row); rowDict[cell.Address.Column] = CreateCellElement(sheet, cell.Address.Row, cell.Address.Column, cell, styleTracker, sharedStrings, sharedStringsDoc); diff --git a/Radzen.Blazor/RadzenSpreadsheet.razor.cs b/Radzen.Blazor/RadzenSpreadsheet.razor.cs index 62c8d2632..5e96f608e 100644 --- a/Radzen.Blazor/RadzenSpreadsheet.razor.cs +++ b/Radzen.Blazor/RadzenSpreadsheet.razor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; @@ -52,9 +53,9 @@ public interface ISpreadsheet void Redo(); /// - /// Gets the UI culture used for localized strings. Defaults to . + /// Gets the UI culture used for localized strings. Defaults to . /// - System.Globalization.CultureInfo UICulture => System.Globalization.CultureInfo.CurrentUICulture; + CultureInfo UICulture => CultureInfo.CurrentUICulture; /// /// Gets whether there is a command to undo. @@ -352,6 +353,30 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr { SetActiveSheet(SelectedSheetIndex); } + else if (stampedCulture is not null && !Culture.Equals(stampedCulture)) + { + // Comparing against the last stamped culture keeps a hand-set Workbook.Culture intact. + if (Editor is { Mode: not EditMode.None } editor) + { + // In-flight text was typed under the old culture and would misparse. + editor.Cancel(); + } + + StampCulture(workbook); + StateHasChanged(); + } + } + + private CultureInfo? stampedCulture; + + private void StampCulture(Workbook? target) + { + if (target is not null) + { + target.Culture = Culture; + } + + stampedCulture = Culture; } private int sheetIndex; @@ -452,6 +477,7 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr { workbook = value; workbookView = null; + StampCulture(value); SetActiveSheet(index); } @@ -692,7 +718,7 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr if (Worksheet != null && validationListRow >= 0 && validationListColumn >= 0 && Worksheet.Cells.TryGet(validationListRow, validationListColumn, out var cell)) { - return cell.Value?.ToString(); + return cell.GetValueAsString(); } return null; @@ -926,7 +952,8 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr { { nameof(FormatCellsDialog.CurrentFormat), cell.Format.NumberFormat }, { nameof(FormatCellsDialog.SampleValue), cell.Value ?? 1234.5 }, - { nameof(FormatCellsDialog.ValueType), cell.ValueType } + { nameof(FormatCellsDialog.ValueType), cell.ValueType }, + { nameof(FormatCellsDialog.Culture), Worksheet!.Culture } }; var result = await OpenDialogAsync( @@ -1524,6 +1551,7 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr // aria-describedby reference derive from it and must be unique per spreadsheet on the page. base.OnInitialized(); workbook = Workbook; + StampCulture(workbook); Bind("Enter", _ => CycleSelectionAsync(1, 0)); Bind("Escape", _ => CancelEditAsync()); Bind("Tab", _ => CycleSelectionAsync(0, 1)); diff --git a/Radzen.Blazor/Spreadsheet/AutofillCommand.cs b/Radzen.Blazor/Spreadsheet/AutofillCommand.cs index ce9d5b24e..73d7a4608 100644 --- a/Radzen.Blazor/Spreadsheet/AutofillCommand.cs +++ b/Radzen.Blazor/Spreadsheet/AutofillCommand.cs @@ -185,7 +185,7 @@ class AutofillCommand : RangeSnapshotCommandBase for (var column = source.Start.Column; column <= source.End.Column; column++) { - var sourceValues = new List(); + var sourceData = new List(); var sourceFormulas = new List(); var sourceFormats = new List(); @@ -193,19 +193,19 @@ class AutofillCommand : RangeSnapshotCommandBase { if (sheet.Cells.TryGet(sr, column, out var srcCell)) { - sourceValues.Add(srcCell.Value); + sourceData.Add(srcCell.Data); sourceFormulas.Add(srcCell.Formula); sourceFormats.Add(srcCell.FormatOrNull?.Clone()); } else { - sourceValues.Add(null); + sourceData.Add(null); sourceFormulas.Add(null); sourceFormats.Add(null); } } - var series = DetectSeries(sourceValues); + var series = DetectSeries(sourceData); var fillIndex = 0; @@ -231,7 +231,7 @@ class AutofillCommand : RangeSnapshotCommandBase } else { - dstCell.Value = sourceValues[sourceIndex]; + CopyData(dstCell, sourceData[sourceIndex]); } fillIndex++; @@ -239,6 +239,20 @@ class AutofillCommand : RangeSnapshotCommandBase } } + // The culture-aware Value setter would re-infer a string's type (text "31.12.2024" -> Date in de-DE). + private static void CopyData(Cell dstCell, CellData? data) + { + if (data is not null) + { + dstCell.Data = data; + dstCell.QuotePrefix = false; + } + else + { + dstCell.Value = null; + } + } + private void FillHorizontal() { var sourceCols = source.End.Column - source.Start.Column + 1; @@ -260,7 +274,7 @@ class AutofillCommand : RangeSnapshotCommandBase for (var row = source.Start.Row; row <= source.End.Row; row++) { - var sourceValues = new List(); + var sourceData = new List(); var sourceFormulas = new List(); var sourceFormats = new List(); @@ -268,19 +282,19 @@ class AutofillCommand : RangeSnapshotCommandBase { if (sheet.Cells.TryGet(row, sc, out var srcCell)) { - sourceValues.Add(srcCell.Value); + sourceData.Add(srcCell.Data); sourceFormulas.Add(srcCell.Formula); sourceFormats.Add(srcCell.FormatOrNull?.Clone()); } else { - sourceValues.Add(null); + sourceData.Add(null); sourceFormulas.Add(null); sourceFormats.Add(null); } } - var series = DetectSeries(sourceValues); + var series = DetectSeries(sourceData); var fillIndex = 0; @@ -306,7 +320,7 @@ class AutofillCommand : RangeSnapshotCommandBase } else { - dstCell.Value = sourceValues[sourceIndex]; + CopyData(dstCell, sourceData[sourceIndex]); } fillIndex++; @@ -320,18 +334,18 @@ class AutofillCommand : RangeSnapshotCommandBase // Tolerance for date-step equality, expressed in days (~86 ms); tight enough to distinguish "off by a second" series, loose enough for round-trips through ToOADate/double. private const double DateStepEpsilonInDays = 1e-6; - private static SeriesInfo? DetectSeries(List sourceValues) + private static SeriesInfo? DetectSeries(List sourceData) { - if (sourceValues.Count < 2) + if (sourceData.Count < 2) { return null; } var numbers = new List(); - foreach (var v in sourceValues) + foreach (var data in sourceData) { - if (v is double d) + if (data?.Value is double d) { numbers.Add(d); } @@ -364,9 +378,9 @@ class AutofillCommand : RangeSnapshotCommandBase var dates = new List(); - foreach (var v in sourceValues) + foreach (var data in sourceData) { - if (v is DateTime dt) + if (data?.Value is DateTime dt) { dates.Add(dt); } diff --git a/Radzen.Blazor/Spreadsheet/CellEditor.razor.cs b/Radzen.Blazor/Spreadsheet/CellEditor.razor.cs index f9d356f90..a5f2732dd 100644 --- a/Radzen.Blazor/Spreadsheet/CellEditor.razor.cs +++ b/Radzen.Blazor/Spreadsheet/CellEditor.razor.cs @@ -173,7 +173,7 @@ public partial class CellEditor : ComponentBase, IDisposable { var address = Worksheet.Selection.Cell; var cell = address != CellRef.Invalid ? Worksheet.Cells[address] : null; - var formattedValue = cell?.Value?.ToString(); + var formattedValue = cell?.GetValueAsString(); var context = new SpreadsheetCellEditContext( formattedValue, cell!, Worksheet, Editor, Spreadsheet diff --git a/Radzen.Blazor/Spreadsheet/CellView.razor.cs b/Radzen.Blazor/Spreadsheet/CellView.razor.cs index 3e1ed2957..6dea881a0 100644 --- a/Radzen.Blazor/Spreadsheet/CellView.razor.cs +++ b/Radzen.Blazor/Spreadsheet/CellView.razor.cs @@ -123,9 +123,9 @@ public partial class CellView : CellBase, IDisposable return null; } - var (formatted, color) = NumberFormat.ApplyWithColor(cell.FormatOrNull?.NumberFormat, cell.Value, cell.ValueType); + var (formatted, color) = NumberFormat.ApplyWithColor(cell.FormatOrNull?.NumberFormat, cell.Value, cell.ValueType, Worksheet.Culture); numberFormatColor = color; - return formatted ?? cell.Value?.ToString(); + return formatted ?? cell.GetValueAsString(); } private Type? ResolveRendererType() diff --git a/Radzen.Blazor/Spreadsheet/FilterDialog.razor.cs b/Radzen.Blazor/Spreadsheet/FilterDialog.razor.cs index 154b686ec..6378aa52c 100644 --- a/Radzen.Blazor/Spreadsheet/FilterDialog.razor.cs +++ b/Radzen.Blazor/Spreadsheet/FilterDialog.razor.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Components; using System.Collections.Generic; +using System.Globalization; using Radzen.Blazor; using Radzen.Documents.Spreadsheet; @@ -79,6 +80,7 @@ public partial class FilterDialog : SpreadsheetDialogBase /// protected override void OnInitialized() { + Culture = Worksheet.Culture; InitializeFieldName(); InitializeAvailableOperators(); InitializeLogicalOperators(); @@ -199,8 +201,22 @@ public partial class FilterDialog : SpreadsheetDialogBase return new SheetFilter(criterion, rangeToUse); } - private static FilterCriterion CreateCriterion(int column, FilterOperator operatorType, string value) + // Only ordered comparison values convert between the workbook culture and the invariant criterion + // form; text and Equals/NotEquals stay verbatim so a literal Contains "1,5" is never rewritten. + private static bool IsOrderedOperator(FilterOperator operatorType) => + operatorType is FilterOperator.GreaterThan or FilterOperator.GreaterThanOrEqual + or FilterOperator.LessThan or FilterOperator.LessThanOrEqual; + + private string ToInvariantFilterValue(FilterOperator operatorType, string value) => + IsOrderedOperator(operatorType) && !string.IsNullOrEmpty(value) ? ToInvariantNumber(value) : value; + + private string ToLocalFilterValue(FilterOperator operatorType, string value) => + IsOrderedOperator(operatorType) && !string.IsNullOrEmpty(value) ? ToLocalNumber(value) : value; + + private FilterCriterion CreateCriterion(int column, FilterOperator operatorType, string value) { + value = ToInvariantFilterValue(operatorType, value); + return operatorType switch { FilterOperator.Equals => new EqualToCriterion { Column = column, Value = value }, @@ -248,13 +264,13 @@ public partial class FilterDialog : SpreadsheetDialogBase { var firstCriterion = visitor.Criteria[0]; selectedOperator = firstCriterion.Operator; - filterValue = firstCriterion.Value ?? ""; + filterValue = ToLocalFilterValue(firstCriterion.Operator, firstCriterion.Value ?? ""); if (visitor.Criteria.Count > 1) { var secondCriterion = visitor.Criteria[1]; secondOperator = secondCriterion.Operator; - secondFilterValue = secondCriterion.Value ?? ""; + secondFilterValue = ToLocalFilterValue(secondCriterion.Operator, secondCriterion.Value ?? ""); logicalOperator = visitor.LogicalOperator; } } diff --git a/Radzen.Blazor/Spreadsheet/FormatCellsDialog.razor.cs b/Radzen.Blazor/Spreadsheet/FormatCellsDialog.razor.cs index 6af429f61..0abb91813 100644 --- a/Radzen.Blazor/Spreadsheet/FormatCellsDialog.razor.cs +++ b/Radzen.Blazor/Spreadsheet/FormatCellsDialog.razor.cs @@ -134,7 +134,7 @@ public partial class FormatCellsDialog : SpreadsheetDialogBase return; } - var result = NumberFormat.Apply(customFormatCode, SampleValue, ValueType); + var result = NumberFormat.Apply(customFormatCode, SampleValue, ValueType, Culture); preview = result ?? SampleValue.ToString() ?? ""; } diff --git a/Radzen.Blazor/Spreadsheet/HyperlinkCommand.cs b/Radzen.Blazor/Spreadsheet/HyperlinkCommand.cs index 2f7cc1746..797fffe79 100644 --- a/Radzen.Blazor/Spreadsheet/HyperlinkCommand.cs +++ b/Radzen.Blazor/Spreadsheet/HyperlinkCommand.cs @@ -17,16 +17,14 @@ public class HyperlinkCommand(Worksheet sheet, CellRef address, Hyperlink? hyper private readonly Worksheet sheet = sheet; private readonly CellRef address = address; private readonly Hyperlink? hyperlink = hyperlink; - private Hyperlink? previousHyperlink; - private object? previousValue; + private Cell? previousCell; /// public bool Execute() { var cell = sheet.Cells[address.Row, address.Column]; - previousHyperlink = cell.Hyperlink?.Clone(); - previousValue = cell.Value; + previousCell = cell.Clone(); cell.Hyperlink = hyperlink?.Clone(); @@ -45,13 +43,9 @@ public class HyperlinkCommand(Worksheet sheet, CellRef address, Hyperlink? hyper /// public void Unexecute() { - if (sheet.Cells.TryGet(address.Row, address.Column, out var cell)) + if (previousCell is not null && sheet.Cells.TryGet(address.Row, address.Column, out var cell)) { - cell.Hyperlink = previousHyperlink?.Clone(); - if (previousValue is not null) - { - cell.Value = previousValue; - } + cell.CopyFrom(previousCell.Clone()); } } } diff --git a/Radzen.Blazor/Spreadsheet/SpreadsheetCellRenderContext.cs b/Radzen.Blazor/Spreadsheet/SpreadsheetCellRenderContext.cs index 19db5d2fe..32a9d6e05 100644 --- a/Radzen.Blazor/Spreadsheet/SpreadsheetCellRenderContext.cs +++ b/Radzen.Blazor/Spreadsheet/SpreadsheetCellRenderContext.cs @@ -50,11 +50,14 @@ public class SpreadsheetCellEditContext : SpreadsheetCellRenderContext } /// - /// Commits the edited value to the cell. The value is converted to a string and applied through the undo/redo system. + /// Commits the edited value to the cell. The value is converted to a string with the workbook + /// culture (the same culture the commit re-parses with) and applied through the undo/redo system. /// public Task CommitAsync(object? value) { - editor.Value = value?.ToString(); + editor.Value = value is IFormattable formattable + ? formattable.ToString(null, Worksheet.Culture) + : value?.ToString(); return spreadsheet.AcceptAsync(); } diff --git a/Radzen.Blazor/Spreadsheet/SpreadsheetDialogBase.cs b/Radzen.Blazor/Spreadsheet/SpreadsheetDialogBase.cs index 86e3bca2c..0bf5c540c 100644 --- a/Radzen.Blazor/Spreadsheet/SpreadsheetDialogBase.cs +++ b/Radzen.Blazor/Spreadsheet/SpreadsheetDialogBase.cs @@ -36,6 +36,31 @@ public abstract class SpreadsheetDialogBase : ComponentBase /// protected string L(string key) => Localizer.Get(key, DefaultUICulture ?? CultureInfo.CurrentUICulture); + /// + /// The culture used to parse and format values entered in the dialog. The spreadsheet passes + /// its workbook culture when opening the dialog. + /// + [Parameter] + public CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture; + + /// + /// Converts a numeric string entered in to the canonical invariant form + /// stored by the engine. Non-numeric text is returned verbatim. + /// + protected string ToInvariantNumber(string value) => + double.TryParse(value, NumberStyles.Any, Culture, out var number) + ? number.ToString(CultureInfo.InvariantCulture) + : value; + + /// + /// Converts a canonical invariant numeric string to for display. + /// Non-numeric text is returned verbatim. + /// + protected string ToLocalNumber(string value) => + double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var number) + ? number.ToString(Culture) + : value; + /// /// Closes the dialog without returning a result. /// diff --git a/Radzen.Blazor/Spreadsheet/Tools/ConditionalFormatDialog.razor b/Radzen.Blazor/Spreadsheet/Tools/ConditionalFormatDialog.razor index 2a2065fd9..31c07fa39 100644 --- a/Radzen.Blazor/Spreadsheet/Tools/ConditionalFormatDialog.razor +++ b/Radzen.Blazor/Spreadsheet/Tools/ConditionalFormatDialog.razor @@ -1,6 +1,8 @@ +@using System.Globalization @using Radzen.Blazor @using Radzen.Blazor.Spreadsheet @using Radzen.Documents.Spreadsheet +@inherits SpreadsheetDialogBase @@ -54,14 +56,6 @@ @code { #nullable enable - [Inject] - public DialogService DialogService { get; set; } = default!; - - [Inject] - Localizer Localizer { get; set; } = default!; - - string L(string key) => Localizer.Get(key, System.Globalization.CultureInfo.CurrentUICulture); - [Parameter] public ConditionalFormatRuleType RuleType { get; set; } = ConditionalFormatRuleType.GreaterThan; @@ -85,16 +79,12 @@ var format = new Format { BackgroundColor = BackgroundColor, Color = TextColor }; ConditionalFormatBase? rule = RuleType switch { - ConditionalFormatRuleType.GreaterThan when double.TryParse(Value1, System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var v) => + ConditionalFormatRuleType.GreaterThan when double.TryParse(Value1, NumberStyles.Any, Culture, out var v) => new GreaterThanRule { Value = v, Format = format }, - ConditionalFormatRuleType.LessThan when double.TryParse(Value1, System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var v) => + ConditionalFormatRuleType.LessThan when double.TryParse(Value1, NumberStyles.Any, Culture, out var v) => new LessThanRule { Value = v, Format = format }, - ConditionalFormatRuleType.Between when double.TryParse(Value1, System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var min) && - double.TryParse(Value2, System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var max) => + ConditionalFormatRuleType.Between when double.TryParse(Value1, NumberStyles.Any, Culture, out var min) && + double.TryParse(Value2, NumberStyles.Any, Culture, out var max) => new BetweenRule { Minimum = min, Maximum = max, Format = format }, ConditionalFormatRuleType.EqualTo => new EqualToRule { Value = Value1, Format = format }, ConditionalFormatRuleType.TextContains => new TextContainsRule { Text = Value1, Format = format }, @@ -107,10 +97,6 @@ } } - private void OnCancel() - { - DialogService.Close(null); - } private sealed class DropDownItem(string name, T value) { diff --git a/Radzen.Blazor/Spreadsheet/Tools/DataValidationDialog.razor b/Radzen.Blazor/Spreadsheet/Tools/DataValidationDialog.razor index 86197e63b..f4581ac47 100644 --- a/Radzen.Blazor/Spreadsheet/Tools/DataValidationDialog.razor +++ b/Radzen.Blazor/Spreadsheet/Tools/DataValidationDialog.razor @@ -1,6 +1,8 @@ +@using System.Globalization @using Radzen.Blazor @using Radzen.Blazor.Spreadsheet @using Radzen.Documents.Spreadsheet +@inherits SpreadsheetDialogBase @@ -138,14 +140,6 @@ @code { #nullable enable - [Inject] - public DialogService DialogService { get; set; } = default!; - - [Inject] - Localizer Localizer { get; set; } = default!; - - string L(string key) => Localizer.Get(key, System.Globalization.CultureInfo.CurrentUICulture); - private DataValidationType ValidationType { get; set; } = DataValidationType.WholeNumber; private DataValidationOperator Operator { get; set; } = DataValidationOperator.Between; private string Formula1 { get; set; } = ""; @@ -217,24 +211,34 @@ { if (Formula1Date.HasValue) { - formula1 = Formula1Date.Value.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + formula1 = Formula1Date.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); } if (Formula2Date.HasValue) { - formula2 = Formula2Date.Value.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + formula2 = Formula2Date.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); } } else if (ValidationType == DataValidationType.Time) { if (Formula1Date.HasValue) { - formula1 = (Formula1Date.Value.TimeOfDay.TotalDays).ToString(System.Globalization.CultureInfo.InvariantCulture); + formula1 = (Formula1Date.Value.TimeOfDay.TotalDays).ToString(CultureInfo.InvariantCulture); } if (Formula2Date.HasValue) { - formula2 = (Formula2Date.Value.TimeOfDay.TotalDays).ToString(System.Globalization.CultureInfo.InvariantCulture); + formula2 = (Formula2Date.Value.TimeOfDay.TotalDays).ToString(CultureInfo.InvariantCulture); } } + else if (ValidationType is DataValidationType.WholeNumber or DataValidationType.Decimal or DataValidationType.TextLength) + { + // Bounds are typed in the workbook culture but stored as canonical invariant fragments. + formula1 = NormalizeNumericBound(formula1); + formula2 = NormalizeNumericBound(formula2); + } + else if (ValidationType == DataValidationType.Custom && formula1.StartsWith('=')) + { + formula1 = FormulaLocalizer.ToInvariant(formula1, Culture); + } var rule = new DataValidationRule { @@ -255,9 +259,14 @@ DialogService.Close(rule); } - private void OnCancel() + private string NormalizeNumericBound(string value) { - DialogService.Close(null); + if (string.IsNullOrEmpty(value) || value.StartsWith('=')) + { + return value; + } + + return ToInvariantNumber(value); } private sealed class DropDownItem(string name, T value) diff --git a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetConditionalFormat.razor b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetConditionalFormat.razor index dfd7cb349..3288a1382 100644 --- a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetConditionalFormat.razor +++ b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetConditionalFormat.razor @@ -38,7 +38,8 @@ var result = await DialogService.OpenAsync(Spreadsheet?.Localize(nameof(RadzenStrings.Spreadsheet_ConditionalFormattingTitle)) ?? "Conditional Formatting", new Dictionary { - { "RuleType", ruleType } + { "RuleType", ruleType }, + { "Culture", Worksheet.Culture } }); if (result is ConditionalFormatBase rule && Spreadsheet is not null) diff --git a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetDataValidation.razor b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetDataValidation.razor index 041cd8f7f..a36a561bf 100644 --- a/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetDataValidation.razor +++ b/Radzen.Blazor/Spreadsheet/Tools/RadzenSpreadsheetDataValidation.razor @@ -32,7 +32,10 @@ } var result = await DialogService.OpenAsync(Spreadsheet.Localize(nameof(RadzenStrings.Spreadsheet_DataValidationTitle)), - null, new DialogOptions { Width = "520px" }); + new Dictionary + { + { "Culture", Worksheet.Culture } + }, new DialogOptions { Width = "520px" }); if (result is DataValidationRule rule) { diff --git a/RadzenBlazorDemos/Pages/DocumentProcessingLocalization.razor b/RadzenBlazorDemos/Pages/DocumentProcessingLocalization.razor new file mode 100644 index 000000000..84946f244 --- /dev/null +++ b/RadzenBlazorDemos/Pages/DocumentProcessingLocalization.razor @@ -0,0 +1,63 @@ +@using System.Globalization +@using Radzen.Documents.Spreadsheet + + + Each row below is a Workbook with a different Culture. The canonical + Cell.Formula stays identical in every culture - only user-facing entry and display change. + Workbook.Culture defaults to CultureInfo.CurrentCulture; set it explicitly in + server-side code so results do not depend on the host locale. + + + + + + + + + + + + +@code { + class CultureRow + { + public string Culture { get; set; } = string.Empty; + public string LocalFormula { get; set; } = string.Empty; + public string CanonicalFormula { get; set; } = string.Empty; + public string FormattedNumber { get; set; } = string.Empty; + public string ParsedInput { get; set; } = string.Empty; + } + + readonly List rows = []; + + protected override void OnInitialized() + { + foreach (var name in new[] { "en-US", "de-DE", "es-ES", "fr-FR", "it-IT", "ja-JP" }) + { + var workbook = new Workbook { Culture = CultureInfo.GetCultureInfo(name) }; + var sheet = workbook.AddSheet("Sheet1", 5, 5); + + sheet.Cells["A1"].Value = 1000; + sheet.Cells["A2"].Value = 234.5; + + var formulaCell = sheet.Cells["B1"]; + formulaCell.Formula = "=SUM(A1,A2)*1.5"; + + var formattedCell = sheet.Cells["C1"]; + formattedCell.Value = 1234.5; + formattedCell.Format.NumberFormat = "#,##0.00"; + + var inputCell = sheet.Cells["D1"]; + inputCell.SetValue("10,50"); + + rows.Add(new CultureRow + { + Culture = name, + LocalFormula = formulaCell.GetValue(), + CanonicalFormula = formulaCell.Formula, + FormattedNumber = formattedCell.GetDisplayText(), + ParsedInput = $"{Convert.ToString(inputCell.Value, CultureInfo.InvariantCulture)} ({inputCell.ValueType})" + }); + } + } +} diff --git a/RadzenBlazorDemos/Pages/DocumentProcessingLocalizationPage.razor b/RadzenBlazorDemos/Pages/DocumentProcessingLocalizationPage.razor new file mode 100644 index 000000000..44b4b2240 --- /dev/null +++ b/RadzenBlazorDemos/Pages/DocumentProcessingLocalizationPage.razor @@ -0,0 +1,15 @@ +@page "/document-processing-localization" + + + Document Processing Localization + + + Compare how the same workbook parses input, displays values, and localizes formulas in different + cultures. Formulas and values are always stored in canonical invariant form - XLSX and CSV files + read and write identically on every host - while SetValue, GetValue, and + GetDisplayText follow Workbook.Culture. + + + + + diff --git a/RadzenBlazorDemos/Pages/SpreadsheetLocalization.razor b/RadzenBlazorDemos/Pages/SpreadsheetLocalization.razor new file mode 100644 index 000000000..5471e6f11 --- /dev/null +++ b/RadzenBlazorDemos/Pages/SpreadsheetLocalization.razor @@ -0,0 +1,84 @@ +@using System.Globalization +@using Radzen.Blazor.Spreadsheet +@using Radzen.Documents.Spreadsheet + + + Language + + + + + +@code { + class Language + { + public string Name { get; set; } = string.Empty; + public CultureInfo Culture { get; set; } = CultureInfo.InvariantCulture; + } + + // Cultures with RadzenStrings resx translations, so the toolbar UI localizes together with the values. + readonly Language[] cultures = + [ + new() { Name = "English", Culture = new CultureInfo("en-US") }, + new() { Name = "Deutsch", Culture = new CultureInfo("de-DE") }, + new() { Name = "Español", Culture = new CultureInfo("es-ES") }, + new() { Name = "Français", Culture = new CultureInfo("fr-FR") }, + new() { Name = "Italiano", Culture = new CultureInfo("it-IT") }, + new() { Name = "日本語", Culture = new CultureInfo("ja-JP") }, + ]; + + CultureInfo culture = new("en-US"); + + Workbook workbook = new Workbook(); + + protected override void OnInitialized() + { + var sheet = workbook.AddSheet("Localization", 30, 8); + + sheet.BeginUpdate(); + + sheet.Columns[0] = 340; + for (int i = 1; i < 8; i++) sheet.Columns[i] = 130; + + sheet.Cells["A1"].Value = "Number Formats"; + sheet.Cells["A1"].Format = new Format { Bold = true, FontSize = 12 }; + + sheet.Cells["A2"].Value = "Formatted number"; + sheet.Cells["B2"].Value = 1234.5; + sheet.Cells["B2"].Format.NumberFormat = "#,##0.00"; + + sheet.Cells["A3"].Value = "Percentage"; + sheet.Cells["B3"].Value = 0.755; + sheet.Cells["B3"].Format.NumberFormat = "0.00%"; + + sheet.Cells["A6"].Value = "Dates"; + sheet.Cells["A6"].Format = new Format { Bold = true, FontSize = 12 }; + + sheet.Cells["A7"].Value = "Long date"; + sheet.Cells["B7"].Value = new DateTime(2024, 3, 17); + sheet.Cells["B7"].Format.NumberFormat = "dd-mmmm-yyyy"; + + sheet.Cells["A8"].Value = "Short date"; + sheet.Cells["B8"].Value = new DateTime(2024, 3, 17); + sheet.Cells["B8"].Format.NumberFormat = "dd-mmm-yy"; + + sheet.Cells["A10"].Value = "Formulas"; + sheet.Cells["A10"].Format = new Format { Bold = true, FontSize = 12 }; + + sheet.Cells["A11"].Value = "Sum"; + sheet.Cells["B11"].Formula = "=SUM(B2,500.25)"; + sheet.Cells["B11"].Format.NumberFormat = "#,##0.00"; + + sheet.Cells["A12"].Value = "Scaled"; + sheet.Cells["B12"].Formula = "=B2*1.5"; + sheet.Cells["B12"].Format.NumberFormat = "#,##0.00"; + + sheet.Cells["A14"].Value = "Try it"; + sheet.Cells["A14"].Format = new Format { Bold = true, FontSize = 12 }; + + sheet.Cells["A15"].Value = "Switch to de-DE or es-ES, then type 10,50 in a cell,"; + sheet.Cells["A16"].Value = "or enter formulas such as =B2*1,5 and =SUM(B2;500,25)."; + + sheet.EndUpdate(); + } +} diff --git a/RadzenBlazorDemos/Pages/SpreadsheetLocalizationPage.razor b/RadzenBlazorDemos/Pages/SpreadsheetLocalizationPage.razor new file mode 100644 index 000000000..d1788cab0 --- /dev/null +++ b/RadzenBlazorDemos/Pages/SpreadsheetLocalizationPage.razor @@ -0,0 +1,17 @@ +@page "/spreadsheet-localization" + + + Spreadsheet Localization + + + Set the Culture property to control how RadzenSpreadsheet parses typed input, renders + number formats, and localizes formula entry. In comma-decimal cultures such as de-DE or es-ES users + type 10,50 for ten and a half and enter formulas as =SUM(A1;B1) or + =A1*1,5 - just like Excel. Set UICulture to localize the toolbar and menus. + Files (XLSX, CSV) and the programmatic Cell.Formula API always use canonical invariant + values regardless of the culture. + + + + + diff --git a/RadzenBlazorDemos/Services/ExampleService.cs b/RadzenBlazorDemos/Services/ExampleService.cs index 1c510e79b..c9576c70e 100644 --- a/RadzenBlazorDemos/Services/ExampleService.cs +++ b/RadzenBlazorDemos/Services/ExampleService.cs @@ -2936,6 +2936,15 @@ namespace RadzenBlazorDemos Related = new [] { "spreadsheet", "spreadsheet-conditional-formatting", "spreadsheet-merge-cells-borders" } }, new Example + { + Name = "Localization", + Path = "spreadsheet-localization", + Title = "Blazor Spreadsheet Localization | Free UI Components by Radzen", + Description = "Culture-aware editing, number formatting, and formula entry - type 10,50 and =SUM(A1;B1) in comma-decimal cultures.", + Tags = new [] { "spreadsheet", "localization", "culture", "globalization", "locale", "decimal", "separator", "international" }, + Related = new [] { "spreadsheet", "spreadsheet-cell-formatting", "spreadsheet-data-validation" } + }, + new Example { Name = "Filtering & Sorting", Path = "spreadsheet-filtering-sorting", @@ -3187,6 +3196,14 @@ namespace RadzenBlazorDemos Tags = new [] { "document", "processing", "import", "export", "xlsx", "csv", "excel", "upload", "download", "read", "write", "parse", "separator", "encoding", "quoting" } }, new Example + { + Name = "Localization", + Path = "document-processing-localization", + Title = "Culture-Aware Excel Processing in Blazor and C# | Radzen", + Description = "Parse, display, and localize spreadsheet values and formulas per culture in code while files stay canonical and host-independent.", + Tags = new [] { "document", "processing", "localization", "culture", "globalization", "locale", "decimal", "separator", "formula", "invariant" } + }, + new Example { Toc = [ new () { Text = "Formulas in code", Anchor = "#in-code" }, new () { Text = "Stateless evaluation", Anchor = "#stateless-engine" }, new () { Text = "Stateful evaluation", Anchor = "#stateful-engine" }, new () { Text = "Custom functions", Anchor = "#custom-functions" } ], Name = "Formulas",