diff --git a/Radzen.Blazor.Tests/Spreadsheet/AutoFitXlsxRoundTripTests.cs b/Radzen.Blazor.Tests/Spreadsheet/AutoFitXlsxRoundTripTests.cs index 57bdb7138..93b29386f 100644 --- a/Radzen.Blazor.Tests/Spreadsheet/AutoFitXlsxRoundTripTests.cs +++ b/Radzen.Blazor.Tests/Spreadsheet/AutoFitXlsxRoundTripTests.cs @@ -116,6 +116,60 @@ public class AutoFitXlsxRoundTripTests Assert.False(loaded.Columns.IsAutoFit(3)); } + [Fact] + public void Write_AutoFitColumn_WidensToFitContentWithExcelMetrics() + { + var wb = Build(); + var sheet = wb.Sheets[0]; + sheet.Cells[0, 0].Value = 157400d; + sheet.Cells[0, 0].Format.NumberFormat = "$#,##0.00"; + sheet.Cells[0, 0].Format.Bold = true; + sheet.Columns[0] = 78; // snug browser-font fit, too narrow for Excel's font + sheet.Columns.SetAutoFit(0); + + using var ms = Save(wb); + + var col = ReadSheetXml(ms).Descendants(Ns + "col").Single(); + var width = double.Parse(col.Attribute("width")!.Value, System.Globalization.CultureInfo.InvariantCulture); + // "$157,400.00" needs 76.7px + 5px padding in Aptos Narrow 11 + Assert.True(width * 7.5 > 81, $"exported width {width * 7.5}px"); + + // the model width is not changed by the export + Assert.Equal(78, sheet.Columns[0]); + } + + [Fact] + public void Write_AutoFitColumn_KeepsWiderModelWidth() + { + var wb = Build(); + var sheet = wb.Sheets[0]; + sheet.Cells[0, 0].Value = "abc"; + sheet.Columns[0] = 200; + sheet.Columns.SetAutoFit(0); + + using var ms = Save(wb); + + var col = ReadSheetXml(ms).Descendants(Ns + "col").Single(); + var width = double.Parse(col.Attribute("width")!.Value, System.Globalization.CultureInfo.InvariantCulture); + Assert.Equal(200 / 7.5, width, 3); + } + + [Fact] + public void Write_NonAutoFitColumn_KeepsModelWidth() + { + var wb = Build(); + var sheet = wb.Sheets[0]; + sheet.Cells[0, 0].Value = 157400d; + sheet.Cells[0, 0].Format.NumberFormat = "$#,##0.00"; + sheet.Columns[0] = 78; + + using var ms = Save(wb); + + var col = ReadSheetXml(ms).Descendants(Ns + "col").Single(); + var width = double.Parse(col.Attribute("width")!.Value, System.Globalization.CultureInfo.InvariantCulture); + Assert.Equal(78 / 7.5, width, 3); + } + [Fact] public void Read_BestFitRangeBeyondSheet_IsCapped() { diff --git a/Radzen.Blazor/Documents/Spreadsheet/ExcelTextMetrics.cs b/Radzen.Blazor/Documents/Spreadsheet/ExcelTextMetrics.cs new file mode 100644 index 000000000..e9aea70d7 --- /dev/null +++ b/Radzen.Blazor/Documents/Spreadsheet/ExcelTextMetrics.cs @@ -0,0 +1,75 @@ +namespace Radzen.Documents.Spreadsheet; + +#nullable enable + +// Per-character advance widths of Aptos Narrow 11 (the Normal font XlsxWriter emits) at 96dpi +// for ASCII 32..126, measured against Excel. Used to size auto fitted columns on export so +// their content renders without clipping in Excel regardless of the browser font metrics. +internal static class ExcelTextMetrics +{ + private static readonly double[] Regular = + [ + 2.54, 4.57, 5.08, 7.11, 7.62, 11.18, 8.63, 3.04, 4.57, 4.57, 7.11, 7.62, + 4.06, 4.57, 4.06, 4.57, 7.62, 7.62, 7.62, 7.62, 7.62, 7.62, 7.62, 7.62, + 7.62, 7.62, 4.06, 4.06, 7.62, 7.62, 7.62, 6.60, 11.69, 7.62, 8.12, 9.14, + 9.14, 7.62, 7.11, 9.65, 9.65, 3.55, 4.57, 7.62, 6.60, 10.67, 9.65, 9.65, + 7.62, 9.65, 8.12, 7.62, 6.60, 9.14, 7.62, 12.20, 7.62, 7.11, 7.11, 4.06, + 4.57, 4.06, 7.62, 6.09, 7.11, 7.11, 7.62, 7.11, 7.62, 7.11, 4.06, 6.60, + 7.62, 3.55, 3.55, 6.60, 3.55, 11.69, 7.62, 7.62, 7.62, 7.62, 4.57, 6.60, + 4.57, 7.62, 6.09, 9.65, 6.09, 6.09, 6.09, 4.06, 6.60, 4.06, 7.62 + ]; + + private static readonly double[] Bold = + [ + 2.54, 4.57, 5.58, 7.11, 7.62, 11.18, 9.14, 3.04, 4.57, 4.57, 7.11, 7.62, + 4.06, 4.57, 4.06, 5.08, 7.62, 7.62, 7.62, 7.62, 7.62, 7.62, 7.62, 7.62, + 7.62, 7.62, 4.06, 4.06, 7.62, 7.62, 7.62, 6.60, 11.18, 8.12, 8.12, 9.14, + 9.14, 7.62, 7.11, 9.65, 9.65, 4.06, 5.08, 8.12, 6.60, 10.67, 9.65, 9.65, + 8.12, 9.65, 8.63, 8.12, 6.60, 9.14, 8.12, 12.20, 8.12, 7.62, 7.11, 4.57, + 5.08, 4.57, 7.62, 6.09, 7.11, 7.11, 7.62, 7.11, 7.62, 7.62, 4.57, 6.60, + 7.62, 3.55, 3.55, 7.11, 4.06, 11.69, 7.62, 7.62, 7.62, 7.62, 5.08, 6.60, + 4.57, 7.62, 6.60, 10.16, 6.60, 6.60, 6.09, 4.57, 7.11, 4.57, 7.62 + ]; + + public static double EstimateWidth(string text, bool bold, double? fontSize) + { + var widths = bold ? Bold : Regular; + var total = 0d; + + foreach (var ch in text) + { + total += ch is >= ' ' and <= '~' ? widths[ch - ' '] : widths['0' - ' ']; + } + + return total * (fontSize ?? 11) / 11; + } + + // The single line that determines a cell's width: wrapped cells render hard line breaks so + // the longest line governs; non-wrapped cells render with white-space: nowrap which collapses them. + public static string DisplayLine(string text, bool wrapText) + { + if (!text.Contains('\n', System.StringComparison.Ordinal) && !text.Contains('\r', System.StringComparison.Ordinal)) + { + return text; + } + + text = text.Replace("\r", "", System.StringComparison.Ordinal); + + if (!wrapText) + { + return text.Replace('\n', ' '); + } + + var longest = ""; + + foreach (var line in text.Split('\n')) + { + if (line.Length > longest.Length) + { + longest = line; + } + } + + return longest; + } +} diff --git a/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs b/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs index e01d05e26..4899d5362 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/XlsxWriter.cs @@ -1175,12 +1175,14 @@ class XlsxWriter(Workbook sourceWorkbook) private static XElement? CreateColumns(Worksheet sheet) { + var autoFitWidths = ComputeAutoFitWidths(sheet); + var colElements = Enumerable.Range(0, sheet.ColumnCount) .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", ColumnWidthConversion.PixelsToChars(sheet.Columns[col])), + new XAttribute("width", ColumnWidthConversion.PixelsToChars(autoFitWidths.TryGetValue(col, out var autoFitWidth) ? autoFitWidth : sheet.Columns[col])), sheet.Columns.IsAutoFit(col) ? new XAttribute("bestFit", "1") : null, new XAttribute("customWidth", "1"))) .ToList(); @@ -1193,6 +1195,54 @@ class XlsxWriter(Workbook sourceWorkbook) return new XElement(XName.Get("cols", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"), colElements); } + // Auto fitted columns were sized against the browser font; Excel renders the Normal font with + // different metrics, so recompute their width from the content using Excel's own character + // widths and keep whichever is larger. + private static Dictionary ComputeAutoFitWidths(Worksheet sheet) + { + var widths = new Dictionary(); + + foreach (var cell in sheet.Cells.GetPopulatedCells().ToList()) + { + var col = cell.Address.Column; + + if (!sheet.Columns.IsAutoFit(col) || sheet.MergedCells.Contains(cell.Address)) + { + continue; + } + + string? text; + Format? format; + + try + { + text = cell.GetDisplayText(); + format = cell.GetEffectiveFormat(); + } + catch (ArgumentOutOfRangeException) + { + continue; + } + + if (string.IsNullOrEmpty(text)) + { + continue; + } + + text = ExcelTextMetrics.DisplayLine(text, format?.WrapText == true); + + var required = ExcelTextMetrics.EstimateWidth(text, format?.Bold == true, format?.FontSize) + 5; + var current = widths.TryGetValue(col, out var value) ? value : sheet.Columns[col]; + + if (required > current) + { + widths[col] = Math.Min(required, ColumnWidthConversion.MaxWidthInPixels); + } + } + + return widths; + } + private void ProcessSheetData(Worksheet sheet, XElement sheetData, StyleTracker styleTracker, Dictionary sharedStrings, XDocument sharedStringsDoc) { // row -> (col -> element). SortedDictionary keeps cells in column diff --git a/Radzen.Blazor/RadzenSpreadsheet.razor.cs b/Radzen.Blazor/RadzenSpreadsheet.razor.cs index df2f34ef3..2679178d2 100644 --- a/Radzen.Blazor/RadzenSpreadsheet.razor.cs +++ b/Radzen.Blazor/RadzenSpreadsheet.razor.cs @@ -3258,12 +3258,7 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr 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', ' '); - } + text = ExcelTextMetrics.DisplayLine(text, format?.WrapText == true); if (text.Length > AutoFitMaxTextLength) { @@ -3335,21 +3330,6 @@ public partial class RadzenSpreadsheet : RadzenComponent, IAsyncDisposable, ISpr 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;