Size auto fitted columns for Excel's font metrics on export

A width that snugly fits content rendered with the browser theme font
can clip in Excel, which renders the Normal style font with different
character widths - most visibly currency values whose digits are wider
in Aptos Narrow than in typical UI fonts.

On export, auto fitted columns recompute their width from the column
content using per-character advance widths of Aptos Narrow 11 measured
against Excel (regular and bold), keeping the larger of the model width
and the required width. The in-component width is unchanged.
This commit is contained in:
Atanas Korchev
2026-07-05 17:07:52 +03:00
committed by Atanas Korchev
parent b5960d4dd6
commit 54a1c905f4
4 changed files with 181 additions and 22 deletions

View File

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

View File

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

View File

@@ -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<int, double> ComputeAutoFitWidths(Worksheet sheet)
{
var widths = new Dictionary<int, double>();
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<string, int> sharedStrings, XDocument sharedStringsDoc)
{
// row -> (col -> <c> element). SortedDictionary keeps cells in column

View File

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