Add culture-aware editing, display, and formula entry to RadzenSpreadsheet

The spreadsheet engine parsed typed input with the invariant culture while
displaying values with the current culture, breaking the edit round-trip on
non-en-US hosts and making comma-decimal entry (10,50) impossible.

Workbook gains a Culture property (defaults to CurrentCulture) which the
component stamps from its inherited Culture parameter. It drives:

- Cell input parsing (CellData type inference), including day-month date
  handling for cultures where the group and date separators collide (de-DE)
- Edit text and display rendering (GetValue, GetValueAsString, GetDisplayText)
- Number format rendering - format codes stay canonical invariant tokens while
  separators, month names, and AM/PM designators follow the culture
- Formula entry and display via the new FormulaLocalizer (Excel FormulaLocal
  semantics: ';' argument separators and ',' decimals in comma-decimal
  cultures, lenient comma acceptance where unambiguous, canonical invariant
  storage)
- Dialog input (data validation, conditional format, filter) via shared
  conversion helpers on SpreadsheetDialogBase

XLSX and CSV files read and write canonical invariant values regardless of
the workbook culture, including autofit column widths. Malformed formulas now
surface as error trees instead of an unhandled lexer exception. Number format
parsing is cached (hard-bounded) since CellView reparsed per render.

Includes localization demos for the Spreadsheet and Document Processing
sections and culture test suites.

Note: headless code on non-en-US hosts now parses string values with the
host culture; set Workbook.Culture explicitly (or to InvariantCulture) for
host-independent processing.
This commit is contained in:
Atanas Korchev
2026-07-08 14:54:40 +03:00
parent 58da371a55
commit 609622692e
35 changed files with 1326 additions and 119 deletions

View File

@@ -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<FormatCellsDialog>(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]

View File

@@ -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);
}
}

View File

@@ -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<DateTime>(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);
}
}

View File

@@ -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<RadzenSpreadsheet>(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<RadzenSpreadsheet>(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<bool> Run(IRenderedComponent<RadzenSpreadsheet> 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<RadzenSpreadsheet>(p => p.Add(x => x.Workbook, wb));
var c = ctx.RenderComponent<RadzenSpreadsheet>(p => p
.Add(x => x.Workbook, wb)
.Add(x => x.Culture, CultureInfo.InvariantCulture));
await AutoFit(c, 0);

View File

@@ -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
/// <summary>
/// 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.
/// </summary>
public string? GetDisplayText()
public string? GetDisplayText() => FormatDisplayText(Culture);
/// <summary>
/// Formats the display text with the specified culture. The XLSX writer measures autofit widths
/// with the invariant culture so saved files are host-independent.
/// </summary>
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;
/// <summary>
/// Sets the cell value with invariant type inference - file content is canonical and must parse
/// the same on every host.
/// </summary>
internal void SetValueInvariant(string? value)
{
Data = new CellData(value);
QuotePrefix = false;
Worksheet.OnCellValueChanged(this);
}
/// <summary>
/// Gets the value of the cell as a string, or the formula if it exists.
/// </summary>
@@ -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
}
/// <summary>
/// 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 <see cref="SetValue"/> under the same culture.
/// </summary>
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
{

View File

@@ -100,9 +100,14 @@ public class CellData : IComparable, IComparable<CellData>
public CellDataType Type { get; }
/// <summary>
/// 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.
/// </summary>
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<CellData>
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<CellData>
}
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<CellData>
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<CellData>
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;

View File

@@ -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
{

View File

@@ -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));
}

View File

@@ -0,0 +1,215 @@
using System;
using System.Globalization;
using System.Text;
namespace Radzen.Documents.Spreadsheet;
#nullable enable
/// <summary>
/// Converts formulas between the canonical invariant form (stored in <see cref="Cell.Formula"/> 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.
/// </summary>
internal static class FormulaLocalizer
{
/// <summary>
/// Converts a canonical invariant formula to the localized form for the specified culture.
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
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 ':';
}
}

View File

@@ -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()

View File

@@ -18,14 +18,31 @@ public static class NumberFormat
/// Returns null when the format is General/null (caller should fall back to default rendering).
/// </summary>
public static string? Apply(string? formatCode, object? value, CellDataType type)
=> ApplyWithColor(formatCode, value, type).Text;
=> ApplyWithColor(formatCode, value, type, CultureInfo.InvariantCulture).Text;
/// <summary>
/// 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.
/// </summary>
public static string? Apply(string? formatCode, object? value, CellDataType type, CultureInfo culture)
=> ApplyWithColor(formatCode, value, type, culture).Text;
/// <summary>
/// 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]).
/// </summary>
public static (string? Text, string? Color) ApplyWithColor(string? formatCode, object? value, CellDataType type)
=> ApplyWithColor(formatCode, value, type, CultureInfo.InvariantCulture);
/// <summary>
/// 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.
/// </summary>
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
{

View File

@@ -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<string, ParsedFormat> 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<FormatSection>();

View File

@@ -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;

View File

@@ -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
/// </summary>
public IReadOnlyList<Worksheet> Sheets => sheets;
private CultureInfo? culture;
/// <summary>
/// Gets or sets the culture used to parse typed cell input and to render values and number formats.
/// If not set, falls back to <see cref="CultureInfo.CurrentCulture"/>.
/// File storage (XLSX, CSV) and formula storage always use the invariant culture regardless of this setting.
/// </summary>
public CultureInfo Culture
{
get => culture ?? CultureInfo.CurrentCulture;
set => culture = value;
}
/// <summary>
/// Gets or sets the workbook protection settings.
/// </summary>

View File

@@ -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;
/// <summary>
/// Returns true if the cell at the given address is editable (either protection is off or the cell is unlocked).
/// </summary>

View File

@@ -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);

View File

@@ -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 <col> 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<int, XElement>());
// 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);

View File

@@ -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();
/// <summary>
/// Gets the UI culture used for localized strings. Defaults to <see cref="System.Globalization.CultureInfo.CurrentUICulture"/>.
/// Gets the UI culture used for localized strings. Defaults to <see cref="CultureInfo.CurrentUICulture"/>.
/// </summary>
System.Globalization.CultureInfo UICulture => System.Globalization.CultureInfo.CurrentUICulture;
CultureInfo UICulture => CultureInfo.CurrentUICulture;
/// <summary>
/// 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<FormatCellsDialog>(
@@ -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));

View File

@@ -185,7 +185,7 @@ class AutofillCommand : RangeSnapshotCommandBase
for (var column = source.Start.Column; column <= source.End.Column; column++)
{
var sourceValues = new List<object?>();
var sourceData = new List<CellData?>();
var sourceFormulas = new List<string?>();
var sourceFormats = new List<Format?>();
@@ -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<object?>();
var sourceData = new List<CellData?>();
var sourceFormulas = new List<string?>();
var sourceFormats = new List<Format?>();
@@ -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<object?> sourceValues)
private static SeriesInfo? DetectSeries(List<CellData?> sourceData)
{
if (sourceValues.Count < 2)
if (sourceData.Count < 2)
{
return null;
}
var numbers = new List<double>();
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<DateTime>();
foreach (var v in sourceValues)
foreach (var data in sourceData)
{
if (v is DateTime dt)
if (data?.Value is DateTime dt)
{
dates.Add(dt);
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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
/// <inheritdoc />
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;
}
}

View File

@@ -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() ?? "";
}

View File

@@ -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;
/// <inheritdoc/>
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
/// <inheritdoc/>
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());
}
}
}

View File

@@ -50,11 +50,14 @@ public class SpreadsheetCellEditContext : SpreadsheetCellRenderContext
}
/// <summary>
/// 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.
/// </summary>
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();
}

View File

@@ -36,6 +36,31 @@ public abstract class SpreadsheetDialogBase : ComponentBase
/// </summary>
protected string L(string key) => Localizer.Get(key, DefaultUICulture ?? CultureInfo.CurrentUICulture);
/// <summary>
/// The culture used to parse and format values entered in the dialog. The spreadsheet passes
/// its workbook culture when opening the dialog.
/// </summary>
[Parameter]
public CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture;
/// <summary>
/// Converts a numeric string entered in <see cref="Culture"/> to the canonical invariant form
/// stored by the engine. Non-numeric text is returned verbatim.
/// </summary>
protected string ToInvariantNumber(string value) =>
double.TryParse(value, NumberStyles.Any, Culture, out var number)
? number.ToString(CultureInfo.InvariantCulture)
: value;
/// <summary>
/// Converts a canonical invariant numeric string to <see cref="Culture"/> for display.
/// Non-numeric text is returned verbatim.
/// </summary>
protected string ToLocalNumber(string value) =>
double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var number)
? number.ToString(Culture)
: value;
/// <summary>
/// Closes the dialog without returning a result.
/// </summary>

View File

@@ -1,6 +1,8 @@
@using System.Globalization
@using Radzen.Blazor
@using Radzen.Blazor.Spreadsheet
@using Radzen.Documents.Spreadsheet
@inherits SpreadsheetDialogBase
<RadzenStack Gap="1rem" Style="min-width: 350px">
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem" AlignItems="AlignItems.Center">
@@ -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<T>(string name, T value)
{

View File

@@ -1,6 +1,8 @@
@using System.Globalization
@using Radzen.Blazor
@using Radzen.Blazor.Spreadsheet
@using Radzen.Documents.Spreadsheet
@inherits SpreadsheetDialogBase
<RadzenStack Gap="0.5rem" Style="min-width: 400px">
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem" AlignItems="AlignItems.Center">
@@ -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<T>(string name, T value)

View File

@@ -38,7 +38,8 @@
var result = await DialogService.OpenAsync<ConditionalFormatDialog>(Spreadsheet?.Localize(nameof(RadzenStrings.Spreadsheet_ConditionalFormattingTitle)) ?? "Conditional Formatting",
new Dictionary<string, object?>
{
{ "RuleType", ruleType }
{ "RuleType", ruleType },
{ "Culture", Worksheet.Culture }
});
if (result is ConditionalFormatBase rule && Spreadsheet is not null)

View File

@@ -32,7 +32,10 @@
}
var result = await DialogService.OpenAsync<DataValidationDialog>(Spreadsheet.Localize(nameof(RadzenStrings.Spreadsheet_DataValidationTitle)),
null, new DialogOptions { Width = "520px" });
new Dictionary<string, object?>
{
{ "Culture", Worksheet.Culture }
}, new DialogOptions { Width = "520px" });
if (result is DataValidationRule rule)
{

View File

@@ -0,0 +1,63 @@
@using System.Globalization
@using Radzen.Documents.Spreadsheet
<RadzenText TextStyle="TextStyle.Body1" TagName="TagName.P" class="rz-pb-2">
Each row below is a <code>Workbook</code> with a different <code>Culture</code>. The canonical
<code>Cell.Formula</code> stays identical in every culture - only user-facing entry and display change.
<code>Workbook.Culture</code> defaults to <code>CultureInfo.CurrentCulture</code>; set it explicitly in
server-side code so results do not depend on the host locale.
</RadzenText>
<RadzenDataGrid Data="@rows" TItem="CultureRow" AllowPaging="false" class="rz-pb-4">
<Columns>
<RadzenDataGridColumn Property="@nameof(CultureRow.Culture)" Title="Culture" Width="90px" />
<RadzenDataGridColumn Property="@nameof(CultureRow.LocalFormula)" Title="Formula as the user types it" />
<RadzenDataGridColumn Property="@nameof(CultureRow.CanonicalFormula)" Title="Canonical Cell.Formula" />
<RadzenDataGridColumn Property="@nameof(CultureRow.FormattedNumber)" Title="1234.5 as #,##0.00" />
<RadzenDataGridColumn Property="@nameof(CultureRow.ParsedInput)" Title="Typing &quot;10,50&quot; produces" />
</Columns>
</RadzenDataGrid>
@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<CultureRow> 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})"
});
}
}
}

View File

@@ -0,0 +1,15 @@
@page "/document-processing-localization"
<RadzenText TextStyle="TextStyle.H2" TagName="TagName.H1" class="rz-pt-8">
Document Processing <strong>Localization</strong>
</RadzenText>
<RadzenText TextStyle="TextStyle.Subtitle1" TagName="TagName.P" class="rz-pb-4">
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 <code>SetValue</code>, <code>GetValue</code>, and
<code>GetDisplayText</code> follow <code>Workbook.Culture</code>.
</RadzenText>
<RadzenExample ComponentName="Spreadsheet" Example="DocumentProcessingLocalization">
<DocumentProcessingLocalization />
</RadzenExample>

View File

@@ -0,0 +1,84 @@
@using System.Globalization
@using Radzen.Blazor.Spreadsheet
@using Radzen.Documents.Spreadsheet
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem" Wrap="FlexWrap.Wrap" class="rz-pb-4">
<RadzenText TextStyle="TextStyle.Subtitle2" TagName="TagName.P" class="rz-mb-0">Language</RadzenText>
<RadzenSelectBar @bind-Value="@culture" Data="@cultures" TextProperty="Name" ValueProperty="Culture" Size="ButtonSize.Small" />
</RadzenStack>
<RadzenSpreadsheet Workbook=@workbook Culture=@culture UICulture=@culture style="height: 600px" />
@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();
}
}

View File

@@ -0,0 +1,17 @@
@page "/spreadsheet-localization"
<RadzenText TextStyle="TextStyle.H2" TagName="TagName.H1" class="rz-pt-8">
Spreadsheet <strong>Localization</strong>
</RadzenText>
<RadzenText TextStyle="TextStyle.Subtitle1" TagName="TagName.P" class="rz-pb-4">
Set the <code>Culture</code> 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 <code>10,50</code> for ten and a half and enter formulas as <code>=SUM(A1;B1)</code> or
<code>=A1*1,5</code> - just like Excel. Set <code>UICulture</code> to localize the toolbar and menus.
Files (XLSX, CSV) and the programmatic <code>Cell.Formula</code> API always use canonical invariant
values regardless of the culture.
</RadzenText>
<RadzenExample ComponentName="Spreadsheet" Example="SpreadsheetLocalization">
<SpreadsheetLocalization />
</RadzenExample>

View File

@@ -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",