From da70790c3e8e8f80342fe99afc6e38ecb6d2c876 Mon Sep 17 00:00:00 2001 From: Atanas Korchev Date: Sun, 5 Jul 2026 15:40:36 +0300 Subject: [PATCH] Measure auto fit column width against the rendered text Replaces the character-count width estimate with Excel-faithful auto fit: the displayed text (number format applied to the evaluated value) of the column's populated cells is measured with canvas.measureText using the active theme's cell font, so formulas, number formats, bold and conditional formatting fit correctly in any theme. Empty columns keep their width, merged cells are ignored, hidden rows are included and the width is clamped to Excel's 255 character limit. The behavior was verified against Excel via COM automation. Auto fitted columns are tracked per column and persisted as bestFit in XLSX; manual resize and undo clear the flag. --- .../Spreadsheet/AutoFitXlsxRoundTripTests.cs | 128 ++++++ Radzen.Blazor.Tests/SpreadsheetTests.cs | 405 ++++++++++++++++-- Radzen.Blazor/Documents/Spreadsheet/Axis.cs | 23 + Radzen.Blazor/Documents/Spreadsheet/Cell.cs | 10 + .../Spreadsheet/ColumnWidthConversion.cs | 16 + .../Documents/Spreadsheet/XlsxReader.cs | 26 +- .../Documents/Spreadsheet/XlsxWriter.cs | 5 +- Radzen.Blazor/RadzenSpreadsheet.razor.cs | 214 +++++++-- Radzen.Blazor/Spreadsheet/CellView.razor.cs | 2 +- .../Spreadsheet/ResizeAxisCommands.cs | 25 +- .../SpreadsheetAccessibility.razor.cs | 4 +- Radzen.Blazor/Spreadsheet/TextMeasureItem.cs | 12 + Radzen.Blazor/wwwroot/Radzen.Blazor.js | 63 ++- 13 files changed, 838 insertions(+), 95 deletions(-) create mode 100644 Radzen.Blazor.Tests/Spreadsheet/AutoFitXlsxRoundTripTests.cs create mode 100644 Radzen.Blazor/Documents/Spreadsheet/ColumnWidthConversion.cs create mode 100644 Radzen.Blazor/Spreadsheet/TextMeasureItem.cs 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) => {