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) => {