diff --git a/Radzen.Blazor.Tests/Spreadsheet/AutoFitXlsxRoundTripTests.cs b/Radzen.Blazor.Tests/Spreadsheet/AutoFitXlsxRoundTripTests.cs
new file mode 100644
index 000000000..57bdb7138
--- /dev/null
+++ b/Radzen.Blazor.Tests/Spreadsheet/AutoFitXlsxRoundTripTests.cs
@@ -0,0 +1,128 @@
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Xml.Linq;
+using Radzen.Documents.Spreadsheet;
+using Xunit;
+
+namespace Radzen.Blazor.Spreadsheet.Tests;
+
+#nullable enable
+
+// Round-trip the bestFit (auto fit) column flag through XlsxWriter + XlsxReader.
+public class AutoFitXlsxRoundTripTests
+{
+ private static readonly XNamespace Ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
+
+ private static Workbook Build()
+ {
+ var wb = new Workbook();
+ wb.AddSheet("Sheet1", 10, 10);
+ return wb;
+ }
+
+ private static MemoryStream Save(Workbook wb)
+ {
+ var ms = new MemoryStream();
+ wb.SaveToStream(ms);
+ ms.Position = 0;
+ return ms;
+ }
+
+ private static XDocument ReadSheetXml(MemoryStream ms)
+ {
+ using var zip = new ZipArchive(ms, ZipArchiveMode.Read, leaveOpen: true);
+ using var stream = zip.GetEntry("xl/worksheets/sheet1.xml")!.Open();
+ var doc = XDocument.Load(stream);
+ ms.Position = 0;
+ return doc;
+ }
+
+ private static MemoryStream PatchSheetXml(MemoryStream ms, string colsXml)
+ {
+ using (var zip = new ZipArchive(ms, ZipArchiveMode.Update, leaveOpen: true))
+ {
+ var entry = zip.GetEntry("xl/worksheets/sheet1.xml")!;
+ XDocument doc;
+ using (var stream = entry.Open())
+ {
+ doc = XDocument.Load(stream);
+ }
+
+ doc.Descendants(Ns + "cols").Remove();
+ var cols = XElement.Parse(colsXml);
+ foreach (var element in cols.DescendantsAndSelf())
+ {
+ element.Name = Ns + element.Name.LocalName;
+ }
+ doc.Root!.Element(Ns + "sheetData")!.AddBeforeSelf(cols);
+
+ entry.Delete();
+ var newEntry = zip.CreateEntry("xl/worksheets/sheet1.xml");
+ using var output = newEntry.Open();
+ doc.Save(output);
+ }
+
+ ms.Position = 0;
+ return ms;
+ }
+
+ [Fact]
+ public void RoundTrip_PreservesWidthAndBestFit()
+ {
+ var wb = Build();
+ wb.Sheets[0].Columns[1] = 240;
+ wb.Sheets[0].Columns.SetAutoFit(1);
+
+ using var ms = Save(wb);
+
+ var col = ReadSheetXml(ms).Descendants(Ns + "col").Single();
+ Assert.Equal("2", col.Attribute("min")?.Value);
+ Assert.Equal("1", col.Attribute("bestFit")?.Value);
+ Assert.Equal("1", col.Attribute("customWidth")?.Value);
+ Assert.NotNull(col.Attribute("width"));
+
+ var loaded = Workbook.LoadFromStream(ms).Sheets[0];
+ Assert.True(System.Math.Abs(loaded.Columns[1] - 240) < 0.5);
+ Assert.True(loaded.Columns.IsAutoFit(1));
+ Assert.False(loaded.Columns.IsAutoFit(0));
+ }
+
+ [Fact]
+ public void Write_AutoFitAtDefaultWidth_StillEmitsColumn()
+ {
+ var wb = Build();
+ wb.Sheets[0].Columns.SetAutoFit(2);
+
+ using var ms = Save(wb);
+
+ var col = ReadSheetXml(ms).Descendants(Ns + "col").Single();
+ Assert.Equal("3", col.Attribute("min")?.Value);
+ Assert.Equal("1", col.Attribute("bestFit")?.Value);
+
+ var loaded = Workbook.LoadFromStream(ms).Sheets[0];
+ Assert.True(loaded.Columns.IsAutoFit(2));
+ }
+
+ [Fact]
+ public void Read_BestFitWithoutWidth_SetsFlagAndKeepsDefaultWidth()
+ {
+ using var ms = PatchSheetXml(Save(Build()), """""");
+
+ var loaded = Workbook.LoadFromStream(ms).Sheets[0];
+ Assert.Equal(loaded.Columns.Size, loaded.Columns[1]);
+ Assert.True(loaded.Columns.IsAutoFit(1));
+ Assert.True(loaded.Columns.IsAutoFit(2));
+ Assert.False(loaded.Columns.IsAutoFit(3));
+ }
+
+ [Fact]
+ public void Read_BestFitRangeBeyondSheet_IsCapped()
+ {
+ using var ms = PatchSheetXml(Save(Build()), """""");
+
+ var loaded = Workbook.LoadFromStream(ms).Sheets[0];
+ Assert.True(loaded.Columns.IsAutoFit(0));
+ Assert.True(loaded.Columns.IsAutoFit(loaded.Columns.Count - 1));
+ }
+}
diff --git a/Radzen.Blazor.Tests/SpreadsheetTests.cs b/Radzen.Blazor.Tests/SpreadsheetTests.cs
index 04ba21909..5015c4cd5 100644
--- a/Radzen.Blazor.Tests/SpreadsheetTests.cs
+++ b/Radzen.Blazor.Tests/SpreadsheetTests.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using Bunit;
using Microsoft.Extensions.DependencyInjection;
@@ -797,62 +798,414 @@ public class SpreadsheetTests
Assert.Equal(2, reported[^1]); // the new third sheet becomes active and is reported back
}
+ // ── Auto fit column width ───────────────────────────────────────────
+
+ // SetupModule intercepts the InvokeAsync that creates the Spreadsheet JS
+ // object, so measureTexts can return a canned result and its payload can be inspected.
+ private static BunitJSModuleInterop SetupSpreadsheetJs(TestContext ctx, params double[] widths)
+ {
+ var module = ctx.JSInterop.SetupModule("Radzen.createSpreadsheet", _ => true);
+ module.Mode = JSRuntimeMode.Loose;
+ if (widths.Length > 0)
+ {
+ module.Setup("measureTexts", _ => true).SetResult(widths);
+ }
+ return module;
+ }
+
+ private static Task AutoFit(IRenderedComponent c, int column)
+ => c.InvokeAsync(() => c.Instance.OnColumnResizeDoubleClickAsync(new CellEventArgs
+ {
+ Column = column,
+ Pointer = new Microsoft.AspNetCore.Components.Web.PointerEventArgs()
+ }));
+
+ private static List MeasuredItems(BunitJSModuleInterop module)
+ {
+ var invocation = Assert.Single(module.Invocations["measureTexts"]);
+ return Assert.IsType>(invocation.Arguments[0]);
+ }
+
[Fact]
- public async Task AutoFitColumn_ResizesToContent_WhenContentExists()
+ public async Task AutoFitColumn_ResizesToMeasuredWidth()
{
using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
var wb = NewWorkbook();
var sheet = wb.Sheets[0];
sheet.Cells[0, 0].Value = "Short";
sheet.Cells[1, 0].Value = "This is a much longer string";
-
+
var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
- await c.InvokeAsync(() => c.Instance.OnColumnResizeDoubleClickAsync(new CellEventArgs
- {
- Column = 0,
- Pointer = new Microsoft.AspNetCore.Components.Web.PointerEventArgs()
- }));
+ await AutoFit(c, 0);
- Assert.True(sheet.Columns[0] > 100);
+ Assert.Equal(300, sheet.Columns[0]);
+ Assert.True(sheet.Columns.IsAutoFit(0));
}
[Fact]
- public async Task AutoFitColumn_ResizesToDefault_WhenColumnIsEmpty()
+ public async Task AutoFitColumn_MeasuresDisplayedValue_NotFormulaText()
{
using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
var wb = NewWorkbook();
var sheet = wb.Sheets[0];
- sheet.Columns[1] = 50;
-
+ sheet.Cells[1, 0].Value = 1d;
+ sheet.Cells[2, 0].Value = 2d;
+ sheet.Cells[0, 0].Formula = "=A2+A3";
+
var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
- await c.InvokeAsync(() => c.Instance.OnColumnResizeDoubleClickAsync(new CellEventArgs
- {
- Column = 1,
- Pointer = new Microsoft.AspNetCore.Components.Web.PointerEventArgs()
- }));
+ await AutoFit(c, 0);
- Assert.Equal(100, sheet.Columns[1]);
+ var texts = MeasuredItems(module).Select(item => item.Text).ToList();
+ Assert.Contains("3", texts);
+ Assert.DoesNotContain("=A2+A3", texts);
}
[Fact]
- public async Task AutoFitColumn_ClampsTo800_WhenContentIsExtremelyLong()
+ public async Task AutoFitColumn_MeasuresNumberFormattedText()
{
using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
var wb = NewWorkbook();
var sheet = wb.Sheets[0];
- sheet.Cells[0, 0].Value = new string('A', 500);
-
+ 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));
- await c.InvokeAsync(() => c.Instance.OnColumnResizeDoubleClickAsync(new CellEventArgs
- {
- Column = 0,
- Pointer = new Microsoft.AspNetCore.Components.Web.PointerEventArgs()
- }));
+ await AutoFit(c, 0);
- Assert.Equal(800, sheet.Columns[0]);
+ var texts = MeasuredItems(module).Select(item => item.Text).ToList();
+ Assert.Contains("39448.00", texts);
+ Assert.DoesNotContain("39448", texts);
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_EmptyColumn_KeepsWidth()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Columns[1] = 50;
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 1);
+
+ Assert.Equal(50, sheet.Columns[1]);
+ Assert.False(sheet.Columns.IsAutoFit(1));
+ module.VerifyNotInvoke("measureTexts");
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_IgnoresMergedCells()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "extremely long merged content that spans two columns";
+ sheet.MergedCells.Add(new RangeRef(new CellRef(0, 0), new CellRef(0, 1)));
+ sheet.Cells[1, 0].Value = "tiny";
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+
+ var texts = MeasuredItems(module).Select(item => item.Text).ToList();
+ Assert.Equal(["tiny"], texts);
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_IncludesHiddenRows()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "short";
+ sheet.Cells[1, 0].Value = "very long content in a hidden row";
+ sheet.Rows.Hide(1);
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+
+ var texts = MeasuredItems(module).Select(item => item.Text).ToList();
+ Assert.Contains("very long content in a hidden row", texts);
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_ClampsToMaxAndMinWidth()
+ {
+ using var ctx = CreateContext();
+ SetupSpreadsheetJs(ctx, 50000);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "content";
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+ Assert.Equal(ColumnWidthConversion.MaxWidthInPixels, sheet.Columns[0]);
+
+ using var ctx2 = CreateContext();
+ SetupSpreadsheetJs(ctx2, 5);
+ var wb2 = NewWorkbook();
+ var sheet2 = wb2.Sheets[0];
+ sheet2.Cells[0, 0].Value = "content";
+
+ var c2 = ctx2.RenderComponent(p => p.Add(x => x.Workbook, wb2));
+
+ await AutoFit(c2, 0);
+ Assert.Equal(RadzenSpreadsheet.MinColumnWidth, sheet2.Columns[0]);
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_Undo_RestoresWidthAndFlag()
+ {
+ using var ctx = CreateContext();
+ SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "content";
+ sheet.Columns[0] = 120;
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+ Assert.Equal(300, sheet.Columns[0]);
+ Assert.True(sheet.Columns.IsAutoFit(0));
+
+ await c.InvokeAsync(c.Instance.Undo);
+
+ Assert.Equal(120, sheet.Columns[0]);
+ Assert.False(sheet.Columns.IsAutoFit(0));
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_ManualResize_ClearsFlag()
+ {
+ using var ctx = CreateContext();
+ SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "content";
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+ Assert.True(sheet.Columns.IsAutoFit(0));
+
+ await Run(c, new ResizeColumnCommand(sheet, 0, 300, 150));
+
+ Assert.Equal(150, sheet.Columns[0]);
+ Assert.False(sheet.Columns.IsAutoFit(0));
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_ResizingDisallowed_DoesNothing()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "content";
+
+ var c = ctx.RenderComponent(p =>
+ {
+ p.Add(x => x.Workbook, wb);
+ p.Add(x => x.AllowResizing, false);
+ });
+
+ await AutoFit(c, 0);
+
+ Assert.Equal(100, sheet.Columns[0]);
+ module.VerifyNotInvoke("measureTexts");
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_DeduplicatesIdenticalCells()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "Same";
+ sheet.Cells[1, 0].Value = "Same";
+ sheet.Cells[2, 0].Value = "Same";
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+
+ Assert.Single(MeasuredItems(module));
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_NullMeasurement_DoesNothing()
+ {
+ using var ctx = CreateContext();
+ SetupSpreadsheetJs(ctx);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "content";
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+
+ Assert.Equal(100, sheet.Columns[0]);
+ Assert.False(sheet.Columns.IsAutoFit(0));
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_OutOfRangeOrHiddenColumn_DoesNothing()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 2].Value = "content";
+ sheet.Columns.Hide(2);
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, -1);
+ await AutoFit(c, sheet.Columns.Count);
+ await AutoFit(c, 2);
+
+ Assert.Equal(100, sheet.Columns[2]);
+ Assert.False(sheet.Columns.IsAutoFit(2));
+ module.VerifyNotInvoke("measureTexts");
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_ConditionalFormat_AffectsFontAndSkipsDedupe()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = 5d;
+ sheet.Cells[5, 0].Value = 5d; // identical twin outside the rule range
+ sheet.ConditionalFormats.Add(new RangeRef(new CellRef(0, 0), new CellRef(1, 0)),
+ new GreaterThanRule { Value = 0, Format = new Format { Bold = true } });
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+
+ var items = MeasuredItems(module);
+ Assert.Contains(items, item => item.Text == "5" && item.Bold);
+ Assert.Contains(items, item => item.Text == "5" && !item.Bold);
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_WrapText_MeasuresLongestLine()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "short\nthe longest line here";
+ sheet.Cells[0, 0].Format.WrapText = true;
+ sheet.Cells[1, 0].Value = "a\nbb";
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+
+ var texts = MeasuredItems(module).Select(item => item.Text).ToList();
+ Assert.Contains("the longest line here", texts);
+ Assert.Contains("a bb", texts);
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_TruncatesAndCapsPayload()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = new Workbook();
+ var sheet = wb.AddSheet("Sheet1", 250, 5);
+ sheet.Cells[0, 0].Value = new string('A', 1500);
+ for (var row = 1; row < 220; row++)
+ {
+ sheet.Cells[row, 0].Value = $"item {row}";
+ sheet.Cells[row, 0].Format.FontSize = row + 1d; // distinct font group per cell
+ }
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+
+ var items = MeasuredItems(module);
+ Assert.Equal(1000, items.Max(item => item.Text.Length));
+ Assert.True(items.Count <= 200);
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_PoisonCell_IsSkipped()
+ {
+ using var ctx = CreateContext();
+ var module = SetupSpreadsheetJs(ctx, 300);
+ var wb = new Workbook();
+ var sheet = wb.AddSheet("Sheet1", 200, 5);
+ sheet.Cells[0, 0].Value = "good";
+ // Out-of-range date serial in a row outside the rendered viewport
+ sheet.Cells[150, 0].Value = 1e10;
+ sheet.Cells[150, 0].Format.NumberFormat = "dddd, mmmm dd, yyyy";
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+
+ var texts = MeasuredItems(module).Select(item => item.Text).ToList();
+ Assert.Contains("good", texts);
+ Assert.Equal(300, sheet.Columns[0]);
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_AlreadyAtFitWidth_StillSetsFlag()
+ {
+ using var ctx = CreateContext();
+ SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = "content";
+ sheet.Columns[0] = 300;
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+ Assert.Equal(300, sheet.Columns[0]);
+ Assert.True(sheet.Columns.IsAutoFit(0));
+
+ await c.InvokeAsync(c.Instance.Undo);
+ Assert.False(sheet.Columns.IsAutoFit(0));
+ }
+
+ [Fact]
+ public async Task AutoFitColumn_Top10RuleOverSparseColumn_DoesNotThrow()
+ {
+ using var ctx = CreateContext();
+ SetupSpreadsheetJs(ctx, 300);
+ var wb = NewWorkbook();
+ var sheet = wb.Sheets[0];
+ sheet.Cells[0, 0].Value = 1d;
+ sheet.Cells[1, 0].Value = 2d;
+ sheet.ConditionalFormats.Add(new RangeRef(new CellRef(0, 0), new CellRef(9, 0)),
+ new Top10Rule { Count = 1, Format = new Format { Bold = true } });
+
+ var c = ctx.RenderComponent(p => p.Add(x => x.Workbook, wb));
+
+ await AutoFit(c, 0);
+
+ Assert.Equal(300, sheet.Columns[0]);
}
private static class EventCallbackFactory
diff --git a/Radzen.Blazor/Documents/Spreadsheet/Axis.cs b/Radzen.Blazor/Documents/Spreadsheet/Axis.cs
index 2b60e833f..0227150d8 100644
--- a/Radzen.Blazor/Documents/Spreadsheet/Axis.cs
+++ b/Radzen.Blazor/Documents/Spreadsheet/Axis.cs
@@ -46,6 +46,8 @@ public class Axis(double size, int count)
private readonly HashSet hidden = [];
+ private readonly HashSet autoFit = [];
+
private bool isUpdating;
///
@@ -120,6 +122,25 @@ public class Axis(double size, int count)
}
}
+ ///
+ /// Checks if the size at the specified index was set by auto fit (persisted as bestFit in XLSX).
+ ///
+ public bool IsAutoFit(int index)
+ {
+ return autoFit.Contains(index);
+ }
+
+ // No change event: the flag has no visual effect, it only feeds the XLSX writer.
+ internal void SetAutoFit(int index)
+ {
+ autoFit.Add(index);
+ }
+
+ internal void ClearAutoFit(int index)
+ {
+ autoFit.Remove(index);
+ }
+
///
/// Gets or sets the size of the axis at the specified index.
///
@@ -167,6 +188,7 @@ public class Axis(double size, int count)
DictionaryShift.Remap(data, Remap);
DictionaryShift.Remap(hidden, Remap);
+ DictionaryShift.Remap(autoFit, Remap);
}
internal void ShiftDown(int fromIndex, int count)
@@ -175,6 +197,7 @@ public class Axis(double size, int count)
DictionaryShift.Remap(data, Remap);
DictionaryShift.Remap(hidden, Remap);
+ DictionaryShift.Remap(autoFit, Remap);
}
internal IEnumerable GetCustomSizedIndices() => data.Keys;
diff --git a/Radzen.Blazor/Documents/Spreadsheet/Cell.cs b/Radzen.Blazor/Documents/Spreadsheet/Cell.cs
index 0d68f31ea..c5306b335 100644
--- a/Radzen.Blazor/Documents/Spreadsheet/Cell.cs
+++ b/Radzen.Blazor/Documents/Spreadsheet/Cell.cs
@@ -94,6 +94,16 @@ public class Cell
Changed?.Invoke(this);
}
+ internal Format? FormatOrNull => format;
+
+ ///
+ /// Gets the text displayed in the cell: the number format applied to the value, or the value as a string.
+ ///
+ public string? GetDisplayText()
+ {
+ return NumberFormat.Apply(format?.NumberFormat, Value, ValueType) ?? Value?.ToString();
+ }
+
internal Format? GetEffectiveFormat()
{
var effectiveFormat = format;
diff --git a/Radzen.Blazor/Documents/Spreadsheet/ColumnWidthConversion.cs b/Radzen.Blazor/Documents/Spreadsheet/ColumnWidthConversion.cs
new file mode 100644
index 000000000..8518bf919
--- /dev/null
+++ b/Radzen.Blazor/Documents/Spreadsheet/ColumnWidthConversion.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Radzen.Documents.Spreadsheet;
+
+#nullable enable
+
+// XLSX column widths are in characters of the Normal font's maximum digit width (7px per character).
+internal static class ColumnWidthConversion
+{
+ // Excel caps column width at 255 characters; 255 * 7 converts back to exactly 255.
+ public const double MaxWidthInPixels = 255 * 7;
+
+ public static double PixelsToChars(double pixels) => Math.Round(pixels / 7.0, 8);
+
+ public static double CharsToPixels(double chars) => (256 * chars + Math.Truncate(128 / 7.0)) / 256 * 7;
+}
diff --git a/Radzen.Blazor/Documents/Spreadsheet/XlsxReader.cs b/Radzen.Blazor/Documents/Spreadsheet/XlsxReader.cs
index ea121ef90..1e7c86f37 100644
--- a/Radzen.Blazor/Documents/Spreadsheet/XlsxReader.cs
+++ b/Radzen.Blazor/Documents/Spreadsheet/XlsxReader.cs
@@ -412,21 +412,29 @@ static class XlsxReader
{
var min = col.Attribute("min")?.Value;
var max = col.Attribute("max")?.Value;
- var width = col.Attribute("width")?.Value;
- if (min is not null && max is not null && width is not null &&
- int.TryParse(min, out var minCol) &&
- int.TryParse(max, out var maxCol) &&
- double.TryParse(width, NumberStyles.Float, CultureInfo.InvariantCulture, out var colWidth))
+ if (min is null || max is null ||
+ !int.TryParse(min, out var minCol) ||
+ !int.TryParse(max, out var maxCol))
{
- // Excel column width to pixels conversion:
- // pixels = Truncate(((256 * width + Truncate(128/7)) / 256) * 7)
- var pixelWidth = (256 * colWidth + Math.Truncate(128 / 7.0)) / 256 * 7;
+ continue;
+ }
- for (var i = minCol - 1; i <= maxCol - 1 && i < sheet.Columns.Count; i++)
+ var bestFit = col.Attribute("bestFit")?.Value is "1" or "true";
+ var hasWidth = double.TryParse(col.Attribute("width")?.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var colWidth);
+ var pixelWidth = ColumnWidthConversion.CharsToPixels(colWidth);
+
+ for (var i = minCol - 1; i <= maxCol - 1 && i < sheet.Columns.Count; i++)
+ {
+ if (hasWidth)
{
sheet.Columns[i] = pixelWidth;
}
+
+ if (bestFit)
+ {
+ sheet.Columns.SetAutoFit(i);
+ }
}
}
}
diff --git a/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs b/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs
index 3391051e1..0ceb9afa5 100644
--- a/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs
+++ b/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs
@@ -1175,11 +1175,12 @@ class XlsxWriter(Workbook sourceWorkbook)
private static XElement? CreateColumns(Worksheet sheet)
{
var colElements = Enumerable.Range(0, sheet.ColumnCount)
- .Where(col => Math.Abs(sheet.Columns[col] - sheet.Columns.Size) > 1e-6)
+ .Where(col => Math.Abs(sheet.Columns[col] - sheet.Columns.Size) > 1e-6 || sheet.Columns.IsAutoFit(col))
.Select(col => new XElement(XName.Get("col", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"),
new XAttribute("min", col + 1),
new XAttribute("max", col + 1),
- new XAttribute("width", Math.Round(sheet.Columns[col] / 7.0, 8)),
+ new XAttribute("width", ColumnWidthConversion.PixelsToChars(sheet.Columns[col])),
+ sheet.Columns.IsAutoFit(col) ? new XAttribute("bestFit", "1") : null,
new XAttribute("customWidth", "1")))
.ToList();
diff --git a/Radzen.Blazor/RadzenSpreadsheet.razor.cs b/Radzen.Blazor/RadzenSpreadsheet.razor.cs
index 7fdee07f9..df2f34ef3 100644
--- a/Radzen.Blazor/RadzenSpreadsheet.razor.cs
+++ b/Radzen.Blazor/RadzenSpreadsheet.razor.cs
@@ -2709,12 +2709,15 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr
return Task.CompletedTask;
}
+ internal const double MinColumnWidth = 24;
+ internal const double MinRowHeight = 16;
+
private Task OnColumnResizePointerMoveAsync(ColumnResizeCapture capture, PointerEventArgs pointer)
{
if (Worksheet != null && capture.Column >= 0 && capture.Column < Worksheet.Columns.Count)
{
var delta = pointer.ClientX - capture.StartX;
- var newWidth = Math.Max(24, capture.StartWidth + delta);
+ var newWidth = Math.Max(MinColumnWidth, capture.StartWidth + delta);
Worksheet.Columns[capture.Column] = newWidth;
}
@@ -2726,7 +2729,7 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr
if (Worksheet != null && capture.Row >= 0 && capture.Row < Worksheet.Rows.Count)
{
var delta = pointer.ClientY - capture.StartY;
- var newHeight = Math.Max(16, capture.StartHeight + delta);
+ var newHeight = Math.Max(MinRowHeight, capture.StartHeight + delta);
Worksheet.Rows[capture.Row] = newHeight;
}
@@ -3156,8 +3159,7 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr
activeCapture = null;
}
///
- /// Automatically adjusts the column width to fit the content of the longest cell,
- /// constrained by a maximum width of 800 pixels or defaults to 100 pixels if empty.
+ /// Invoked by JS interop when a column resize handle is double-clicked. Auto fits the column width to its displayed content.
///
/// The column event arguments containing the target column index.
[JSInvokable]
@@ -3165,47 +3167,189 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr
{
ArgumentNullException.ThrowIfNull(args);
- if (!IsFeatureAllowed(SpreadsheetFeature.Resizing) || Worksheet == null)
+ if (!IsFeatureAllowed(SpreadsheetFeature.Resizing) || Worksheet == null || jsRef == null ||
+ args.Column < 0 || args.Column >= Worksheet.Columns.Count || Worksheet.Columns.IsHidden(args.Column))
{
return;
}
- var result = await AcceptAsync();
-
- if (result)
+ if (!await AcceptAsync())
{
- int targetColumn = args.Column;
- double maxWidth = 0;
- bool hasContent = false;
+ return;
+ }
- for (int r = 0; r < Worksheet.RowCount; r++)
- {
- if (Worksheet.Cells.TryGet(r, targetColumn, out var cell))
- {
- var text = cell.GetValue()?.ToString();
-
- if (!string.IsNullOrEmpty(text))
- {
- hasContent = true;
- var estimatedWidth = (text.Length * 8) + 16;
-
- if (estimatedWidth > maxWidth)
- {
- maxWidth = estimatedWidth;
- }
- }
- }
- }
+ var items = GetAutoFitMeasureItems(Worksheet, args.Column);
- var startWidth = Worksheet.Columns[targetColumn];
- var finalWidth = hasContent ? Math.Min(Math.Max(maxWidth, 24), 800) : 100; // fall back to the default 100
+ if (items.Count == 0)
+ {
+ return;
+ }
- if (startWidth != finalWidth)
- {
- await ExecuteAsync(new ResizeColumnCommand(Worksheet, targetColumn, startWidth, finalWidth));
- }
+ double[]? widths;
+
+ try
+ {
+ widths = await jsRef.InvokeAsync("measureTexts", items);
+ }
+ catch (Exception ex) when (ex is JSException or JSDisconnectedException or ObjectDisposedException or TaskCanceledException)
+ {
+ return;
+ }
+
+ if (widths is not { Length: > 0 })
+ {
+ return;
+ }
+
+ var maxWidth = widths.Max();
+
+ if (maxWidth <= 0)
+ {
+ return;
+ }
+
+ var targetWidth = Math.Clamp(maxWidth, MinColumnWidth, ColumnWidthConversion.MaxWidthInPixels);
+ var startWidth = Worksheet.Columns[args.Column];
+
+ if (startWidth != targetWidth || !Worksheet.Columns.IsAutoFit(args.Column))
+ {
+ await ExecuteAsync(new ResizeColumnCommand(Worksheet, args.Column, startWidth, targetWidth, isAutoFit: true));
}
}
+
+ private const int AutoFitMaxTextLength = 1000;
+ private const int AutoFitCandidatesPerFontGroup = 20;
+ private const int AutoFitMaxMeasureItems = 200;
+
+ private static List GetAutoFitMeasureItems(Worksheet sheet, int column)
+ {
+ // Snapshot: computing effective formats can populate cells in the store.
+ var cells = sheet.Cells.GetPopulatedCells().Where(cell => cell.Address.Column == column).ToList();
+
+ var conditionalRanges = sheet.ConditionalFormats.Ranges
+ .Where(range => range.Start.Column <= column && column <= range.End.Column)
+ .ToList();
+
+ var seen = new HashSet<(string, bool, bool, double?, string?)>();
+ var candidates = new List<(Cell Cell, string Text, (bool Bold, bool Italic, double? FontSize, string? FontFamily) Font)>();
+
+ foreach (var cell in cells)
+ {
+ if (sheet.MergedCells.Contains(cell.Address))
+ {
+ continue;
+ }
+
+ string? text;
+
+ try
+ {
+ text = cell.GetDisplayText();
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ continue;
+ }
+
+ if (string.IsNullOrEmpty(text))
+ {
+ continue;
+ }
+
+ var format = cell.FormatOrNull;
+
+ if (text.Contains('\n', StringComparison.Ordinal) || text.Contains('\r', StringComparison.Ordinal))
+ {
+ // Wrap renders hard line breaks so the longest line governs; nowrap collapses them.
+ text = text.Replace("\r", "", StringComparison.Ordinal);
+ text = format?.WrapText == true ? LongestLine(text) : text.Replace('\n', ' ');
+ }
+
+ if (text.Length > AutoFitMaxTextLength)
+ {
+ text = text[..AutoFitMaxTextLength];
+ }
+
+ var font = (format?.Bold == true, format?.Italic == true, format?.FontSize, format?.FontFamily);
+
+ // Conditionally formatted cells are exempt from dedupe - an unstyled twin must not replace them.
+ var conditional = conditionalRanges.Any(range => range.Contains(cell.Address));
+
+ if (!conditional && !seen.Add((text, font.Item1, font.Item2, font.Item3, font.Item4)))
+ {
+ continue;
+ }
+
+ candidates.Add((cell, text, font));
+ }
+
+ var survivors = candidates
+ .GroupBy(candidate => candidate.Font)
+ .SelectMany(group => group.OrderByDescending(candidate => candidate.Text.Length).Take(AutoFitCandidatesPerFontGroup));
+
+ var items = new List();
+
+ foreach (var (cell, text, font) in survivors)
+ {
+ var (bold, italic, fontSize, fontFamily) = font;
+
+ if (conditionalRanges.Count > 0)
+ {
+ Format? effective;
+
+ try
+ {
+ effective = cell.GetEffectiveFormat();
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ continue;
+ }
+
+ bold = effective?.Bold == true;
+ italic = effective?.Italic == true;
+ fontSize = effective?.FontSize;
+ fontFamily = effective?.FontFamily;
+ }
+
+ if (fontSize is { } size && !double.IsFinite(size))
+ {
+ fontSize = null;
+ }
+
+ items.Add(new TextMeasureItem
+ {
+ Text = text,
+ Bold = bold,
+ Italic = italic,
+ FontSize = fontSize,
+ FontFamily = fontFamily
+ });
+ }
+
+ if (items.Count > AutoFitMaxMeasureItems)
+ {
+ items = items.OrderByDescending(item => item.Text.Length).Take(AutoFitMaxMeasureItems).ToList();
+ }
+
+ return items;
+ }
+
+ private static string LongestLine(string text)
+ {
+ var longest = "";
+
+ foreach (var line in text.Split('\n'))
+ {
+ if (line.Length > longest.Length)
+ {
+ longest = line;
+ }
+ }
+
+ return longest;
+ }
+
async ValueTask IAsyncDisposable.DisposeAsync()
{
activeCapture = null;
diff --git a/Radzen.Blazor/Spreadsheet/CellView.razor.cs b/Radzen.Blazor/Spreadsheet/CellView.razor.cs
index f81295ba3..3e1ed2957 100644
--- a/Radzen.Blazor/Spreadsheet/CellView.razor.cs
+++ b/Radzen.Blazor/Spreadsheet/CellView.razor.cs
@@ -123,7 +123,7 @@ public partial class CellView : CellBase, IDisposable
return null;
}
- var (formatted, color) = NumberFormat.ApplyWithColor(cell.Format?.NumberFormat, cell.Value, cell.ValueType);
+ var (formatted, color) = NumberFormat.ApplyWithColor(cell.FormatOrNull?.NumberFormat, cell.Value, cell.ValueType);
numberFormatColor = color;
return formatted ?? cell.Value?.ToString();
}
diff --git a/Radzen.Blazor/Spreadsheet/ResizeAxisCommands.cs b/Radzen.Blazor/Spreadsheet/ResizeAxisCommands.cs
index 666c87327..fefe24281 100644
--- a/Radzen.Blazor/Spreadsheet/ResizeAxisCommands.cs
+++ b/Radzen.Blazor/Spreadsheet/ResizeAxisCommands.cs
@@ -5,15 +5,29 @@ namespace Radzen.Blazor.Spreadsheet;
///
/// Undoable command that resizes a column to a new width.
///
-public sealed class ResizeColumnCommand(Worksheet sheet, int column, double oldWidth, double newWidth) : ICommand
+public sealed class ResizeColumnCommand(Worksheet sheet, int column, double oldWidth, double newWidth, bool isAutoFit = false) : ICommand
{
+ private bool wasAutoFit;
+
///
public SpreadsheetFeature? Feature => null;
///
public bool Execute()
{
+ wasAutoFit = sheet.Columns.IsAutoFit(column);
+
sheet.Columns[column] = newWidth;
+
+ if (isAutoFit)
+ {
+ sheet.Columns.SetAutoFit(column);
+ }
+ else
+ {
+ sheet.Columns.ClearAutoFit(column);
+ }
+
return true;
}
@@ -21,6 +35,15 @@ public sealed class ResizeColumnCommand(Worksheet sheet, int column, double oldW
public void Unexecute()
{
sheet.Columns[column] = oldWidth;
+
+ if (wasAutoFit)
+ {
+ sheet.Columns.SetAutoFit(column);
+ }
+ else
+ {
+ sheet.Columns.ClearAutoFit(column);
+ }
}
}
diff --git a/Radzen.Blazor/Spreadsheet/SpreadsheetAccessibility.razor.cs b/Radzen.Blazor/Spreadsheet/SpreadsheetAccessibility.razor.cs
index 0f9b6321a..49182fa48 100644
--- a/Radzen.Blazor/Spreadsheet/SpreadsheetAccessibility.razor.cs
+++ b/Radzen.Blazor/Spreadsheet/SpreadsheetAccessibility.razor.cs
@@ -183,9 +183,7 @@ public partial class SpreadsheetAccessibility : ComponentBase, IDisposable
return string.Empty;
}
- return NumberFormat.Apply(c.Format.NumberFormat, c.Value, c.ValueType)
- ?? c.Value?.ToString()
- ?? string.Empty;
+ return c.GetDisplayText() ?? string.Empty;
}
private static string GetHeader(Worksheet worksheet, CellRef cell, string content)
diff --git a/Radzen.Blazor/Spreadsheet/TextMeasureItem.cs b/Radzen.Blazor/Spreadsheet/TextMeasureItem.cs
new file mode 100644
index 000000000..42ca4988d
--- /dev/null
+++ b/Radzen.Blazor/Spreadsheet/TextMeasureItem.cs
@@ -0,0 +1,12 @@
+namespace Radzen.Blazor.Spreadsheet;
+
+#nullable enable
+
+internal sealed class TextMeasureItem
+{
+ public string Text { get; set; } = "";
+ public bool Bold { get; set; }
+ public bool Italic { get; set; }
+ public double? FontSize { get; set; }
+ public string? FontFamily { get; set; }
+}
diff --git a/Radzen.Blazor/wwwroot/Radzen.Blazor.js b/Radzen.Blazor/wwwroot/Radzen.Blazor.js
index 65aaf9acf..6542074e7 100644
--- a/Radzen.Blazor/wwwroot/Radzen.Blazor.js
+++ b/Radzen.Blazor/wwwroot/Radzen.Blazor.js
@@ -6210,25 +6210,49 @@ class Spreadsheet {
}
}
- onDoubleClick = (e) => {
- if (e.target.matches('.rz-spreadsheet-cell')) {
- const cell = e.target;
- const row = +cell.dataset.row;
- const column = +cell.dataset.column;
- this.dotNetRef.invokeMethodAsync('OnCellDoubleClickAsync', { row, column, pointer: this.toEventArgs(e) });
- } else if (e.target.matches('.rz-spreadsheet-column-resize-handle')) {
- e.preventDefault();
- e.stopPropagation();
-
- const column = +e.target.dataset.column;
-
- this.dotNetRef.invokeMethodAsync('OnColumnResizeDoubleClickAsync', {
- row: 0,
- column: column,
- pointer: this.toEventArgs(e)
- });
- }
+ onDoubleClick = (e) => {
+ if (e.target.matches('.rz-spreadsheet-cell')) {
+ const cell = e.target;
+ const row = +cell.dataset.row;
+ const column = +cell.dataset.column;
+ this.dotNetRef.invokeMethodAsync('OnCellDoubleClickAsync', { row, column, pointer: this.toEventArgs(e) });
+ } else if (e.target.matches('.rz-spreadsheet-column-resize-handle')) {
+ e.preventDefault();
+ e.stopPropagation();
+ const column = +e.target.dataset.column;
+ this.dotNetRef.invokeMethodAsync('OnColumnResizeDoubleClickAsync', { row: 0, column, pointer: this.toEventArgs(e) });
}
+ }
+
+ measureTexts = (items) => {
+ const probe = document.createElement('div');
+ probe.className = 'rz-spreadsheet-cell';
+ probe.style.visibility = 'hidden';
+ this.element.appendChild(probe);
+ const style = getComputedStyle(probe);
+ const baseFont = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
+ const padding = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0)
+ + (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.borderRightWidth) || 0);
+ const baseFontSize = style.fontSize;
+ const baseFontFamily = style.fontFamily;
+ this.element.removeChild(probe);
+
+ this.measureCanvas ??= document.createElement('canvas');
+ const context = this.measureCanvas.getContext('2d');
+ context.direction = this.rtl ? 'rtl' : 'ltr';
+
+ return items.map(item => {
+ // Invalid font shorthand assignments are silently ignored; reset so they fall back to the base font.
+ context.font = baseFont;
+ if (item.bold || item.italic || item.fontSize != null || item.fontFamily) {
+ const size = item.fontSize != null ? item.fontSize + 'pt' : baseFontSize;
+ const family = item.fontFamily ? `"${item.fontFamily.replaceAll('"', '\\"')}", ${baseFontFamily}` : baseFontFamily;
+ context.font = `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${size} ${family}`;
+ }
+ const width = context.measureText(item.text).width;
+ return Number.isFinite(width) ? Math.ceil(width + padding) : 0;
+ });
+ }
onColumnPointerUp = (e) => {
removeEventListener('pointermove', this.onColumnPointerMove);
@@ -6612,6 +6636,9 @@ class SheetEditor {
}
}
+
+
+
Radzen.createSheetEditor = (element, value, autoFocus, dotNetRef) => new SheetEditor(element, value, autoFocus, dotNetRef);
Radzen.createSpreadsheet = (element, dotNetRef, shortcuts) => new Spreadsheet(element, dotNetRef, shortcuts);
Radzen.createVirtualItemContainer = (scrollable, content, ref) => {