From dcf37789562b66cb6ba02cbb1c2598e6c4ecde09 Mon Sep 17 00:00:00 2001 From: Lieven De Foor <8639525+lievendf@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:34:27 +0200 Subject: [PATCH] Fix AutoFit column width calculation with AutoFilter and WrapText --- .../Drawing/Text/ITextMeasurer.cs | 49 ++++++++++- .../Drawing/Text/SystemDrawingTextMeasurer.cs | 4 +- src/EPPlus/Core/AutofitHelper.cs | 85 ++++++++++++++----- .../GenericFontMetricsTextMeasurer.cs | 36 +++++--- .../GenericFontMetricsTextMeasurerBase.cs | 68 ++++++++++++--- src/EPPlus/ExcelTextSettings.cs | 8 +- src/EPPlusTest/Issues/WorksheetIssues.cs | 83 +++++++++++++++++- 7 files changed, 283 insertions(+), 50 deletions(-) diff --git a/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs b/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs index f148a3062b..92ae6afab7 100644 --- a/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs +++ b/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs @@ -32,8 +32,55 @@ public interface ITextMeasurer TextMeasurement MeasureText(string text, MeasurementFont font); /// /// If the text measurer should measure wrap text cells. - /// Only CR, LF or CRLF should be considered. + /// Line breaks are considered on explicit newlines (CR, LF, CRLF) as well as soft wrap boundaries (spaces, tabs, and hyphens). /// bool MeasureWrappedTextCells { get; set; } } + + /// + /// Extension interface for advanced text measurement including cell-specific wrapping and last line padding. + /// + public interface IWrapTextMeasurer : ITextMeasurer + { + /// + /// Measures the supplied text with cell-specific wrapping and last-line padding + /// + /// The text to measure + /// Font of the text to measure + /// Whether word wrap is enabled for this cell + /// Padding in pixels to add only to the last wrapped line (e.g. for autofilter arrows) + /// A + TextMeasurement MeasureText(string text, MeasurementFont font, bool wrapText, float lastLinePadding); + } + + /// + /// High-compatibility extension methods for + /// + public static class TextMeasurerExtensions + { + /// + /// Measures the supplied text with cell-specific wrapping and last-line padding, using the advanced interface if supported, or falling back to interface-level properties. + /// + public static TextMeasurement MeasureText(this ITextMeasurer measurer, string text, MeasurementFont font, bool wrapText, float lastLinePadding) + { + var wrapMeasurer = measurer as IWrapTextMeasurer; + if (wrapMeasurer != null) + { + return wrapMeasurer.MeasureText(text, font, wrapText, lastLinePadding); + } + + var prev = measurer.MeasureWrappedTextCells; + try + { + measurer.MeasureWrappedTextCells = wrapText; + var measurement = measurer.MeasureText(text, font); + measurement.Width += lastLinePadding; + return measurement; + } + finally + { + measurer.MeasureWrappedTextCells = prev; + } + } + } } diff --git a/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs b/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs index 303887c0a3..15b958196b 100644 --- a/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs +++ b/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs @@ -9,8 +9,8 @@ namespace OfficeOpenXml.SystemDrawing.Text public class SystemDrawingTextMeasurer : ITextMeasurer, IDisposable { /// - /// If the text measurer should measure wrap text cells. - /// Only CR, LF or CRLF should be considered. + /// If the text measurer should measure wrap text cells during AutoFit calculations. + /// Line breaks are considered on explicit newlines (CR, LF, CRLF) as well as soft wrap boundaries (spaces, tabs, and hyphens). /// public bool MeasureWrappedTextCells { diff --git a/src/EPPlus/Core/AutofitHelper.cs b/src/EPPlus/Core/AutofitHelper.cs index 968f1abae7..361ac12eae 100644 --- a/src/EPPlus/Core/AutofitHelper.cs +++ b/src/EPPlus/Core/AutofitHelper.cs @@ -1,7 +1,7 @@ /************************************************************************************************* - Required Notice: Copyright (C) EPPlus Software AB. - This software is licensed under PolyForm Noncommercial License 1.0.0 - and may only be used for noncommercial purposes + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes https://polyformproject.org/licenses/noncommercial/1.0.0/ A commercial license to use this software can be purchased at https://epplussoftware.com @@ -24,7 +24,8 @@ namespace OfficeOpenXml.Core internal class AutofitHelper { // Approximate width in pixels (at 96 DPI) of the autofilter dropdown arrow rendered by Excel. - private const double AutoFilterArrowWidthPixels = 15d; + // Set to 22d (17 pixels physical dropdown button width + 5 pixels extra padding to prevent text from sticking). + private const double AutoFilterArrowWidthPixels = 22d; private ExcelRangeBase _range; ITextMeasurer _genericMeasurer = new GenericFontMetricsTextMeasurer(); MeasurementFont _nonExistingFont = new MeasurementFont() { FontFamily = FontSize.NonExistingFont }; @@ -111,7 +112,7 @@ internal void AutofitColumn(double MinimumWidth, double MaximumWidth) afAddr[afAddr.Count - 1]._ws = _range.WorkSheetName; } } - + for (int col = fromCol; col <= toCol; col++) { if (worksheet.Column(col).Hidden) //Issue 15338 @@ -130,13 +131,16 @@ internal void AutofitColumn(double MinimumWidth, double MaximumWidth) { var cell = worksheet.Cells[af._fromRow, col]; var cellStyleId = styles.CellXfs[cell.StyleID]; - currentMaxWidth = GetTextLength(cell, textLengthCache, styles, cellStyleId, normalSize, MaximumWidth, currentMaxWidth); + if (cellStyleId.WrapText && _textSettings.MeasureWrappedTextCells == false) continue; + // Reserve room for the autofilter dropdown arrow. The arrow is a fixed-size UI - // element (~15px at 96 DPI), so it is added as a constant converted to column - // width units (normalSize = width of the normal font's reference char in pixels). - // It is intentionally not affected by AutofitScaleFactor, since the arrow's - // pixel size does not shrink when the user requests tighter text margins. - currentMaxWidth += AutoFilterArrowWidthPixels / normalSize; + // element (17px at 96 DPI + 5px padding). Excel renders this dropdown arrow *only* on the last + // line of a header cell. Preceding lines in a wrapped cell can overlap into the + // dropdown area. To avoid inflating the column width unnecessarily, we pass this + // 22px padding as `lastLinePadding` so that the text measurer only adds it to + // the last wrapped line of the cell. + float lastLinePadding = (float)AutoFilterArrowWidthPixels; + currentMaxWidth = GetTextLength(cell, textLengthCache, styles, cellStyleId, normalSize, MaximumWidth, currentMaxWidth, lastLinePadding); if (currentMaxWidth >= MaximumWidth) { currentMaxWidth = MaximumWidth; @@ -164,7 +168,19 @@ internal void AutofitColumn(double MinimumWidth, double MaximumWidth) worksheet._package.DoAdjustDrawings = doAdjust; } - private double GetTextLength(ExcelRangeBase cell, Dictionary textLengthCache, ExcelStyles styles, Style.XmlAccess.ExcelXfs cellStyleId, float normalSize, double MaximumWidth, double currentMaxWidth) + /// + /// Calculates the text width of a cell and updates the current maximum column width + /// + /// The cell to measure + /// Cache for measured font lengths + /// The Excel styles collection + /// The XML style definition of the cell + /// The width of the normal font's reference char in pixels + /// The maximum allowed column width + /// The currently tracked maximum width of the column + /// Optional padding in pixels to apply only to the last wrapped line of text (e.g. for AutoFilter dropdown arrow). Defaults to 0f because most cells do not have an adjacent dropdown. + /// The updated maximum column width + private double GetTextLength(ExcelRangeBase cell, Dictionary textLengthCache, ExcelStyles styles, Style.XmlAccess.ExcelXfs cellStyleId, float normalSize, double MaximumWidth, double currentMaxWidth, float lastLinePadding = 0f) { var fontID = cellStyleId.FontId; MeasurementFont measurementFont; @@ -193,7 +209,7 @@ private double GetTextLength(ExcelRangeBase cell, Dictionary 0 && !string.IsNullOrEmpty(textForWidth) ? new string('_', indent) : ""); if (text.Length > 32000) { text = text.Substring(0, 32000); } //Issue - + if(cell.Style.WrapText==false) { text = text.Replace("\r","").Replace("\n",""); @@ -203,7 +219,7 @@ private double GetTextLength(ExcelRangeBase cell, Dictionary measureCache) + /// + /// Measures a text string using the specified font, with caching support + /// + /// The text to measure + /// ID of the font to use + /// Whether word wrapping is enabled on the cell style + /// Padding in pixels to apply only to the last line's width + /// High-performance lookup cache for measured strings + /// A representing the width and height + private TextMeasurement MeasureString(string text, int fontID, bool wrapText, float lastLinePadding, Dictionary measureCache) { - ulong key = ((ulong)((uint)text.GetHashCode()) << 32) | (uint)fontID; + // Create a high-performance 64-bit cache key based on the original key calculation. + // Original baseline: + // ulong key = ((ulong)((uint)text.GetHashCode()) << 32) | (uint)fontID; + // + // We extend this by packing wrapText and lastLinePadding flags into the high bits of the lower 32-bit word. + + // 1. Shift the 32-bit string hash code into the upper 32 bits (bits 32-63) + ulong hashPart = (ulong)(uint)text.GetHashCode() << 32; + + // 2. Set boolean flags in the upper two bits of the lower 32-bit word (bits 30 and 31) + ulong paddingFlag = lastLinePadding > 0f ? (1UL << 31) : 0UL; + ulong wrapFlag = wrapText ? (1UL << 30) : 0UL; + + // 3. Mask the font ID to the remaining 30 bits (bits 0-29). + // This supports up to 1,073,741,823 unique fonts while preventing any bit-flag overflow. + ulong fontPart = (ulong)fontID & 0x3FFFFFFF; + + // 4. Combine all parts into a single 64-bit key + ulong key = hashPart | paddingFlag | wrapFlag | fontPart; + if (!measureCache.TryGetValue(key, out var measurement)) { - var measurer = _textSettings.PrimaryTextMeasurer; var font = _fontCache[fontID]; - measurement = measurer.MeasureText(text, font); + + measurement = _textSettings.PrimaryTextMeasurer.MeasureText(text, font, wrapText, lastLinePadding); + if (measurement.IsEmpty && _textSettings.FallbackTextMeasurer != null && _textSettings.FallbackTextMeasurer != _textSettings.PrimaryTextMeasurer) { - measurer = _textSettings.FallbackTextMeasurer; - measurement = measurer.MeasureText(text, font); + measurement = _textSettings.FallbackTextMeasurer.MeasureText(text, font, wrapText, lastLinePadding); } if (measurement.IsEmpty && _fontWidthDefault != null) { measurement = MeasureGeneric(text, _textSettings, font); + measurement.Width += lastLinePadding; } if (!measurement.IsEmpty && _textSettings.AutofitScaleFactor != 1f) { diff --git a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs index 990dc86d1f..4079861e60 100644 --- a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs +++ b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs @@ -1,7 +1,7 @@ /************************************************************************************************* - Required Notice: Copyright (C) EPPlus Software AB. - This software is licensed under PolyForm Noncommercial License 1.0.0 - and may only be used for noncommercial purposes + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes https://polyformproject.org/licenses/noncommercial/1.0.0/ A commercial license to use this software can be purchased at https://epplussoftware.com @@ -17,17 +17,18 @@ Date Author Change namespace OfficeOpenXml.Core.Worksheet.Core.Worksheet.Fonts.GenericMeasurements { - internal class GenericFontMetricsTextMeasurer : GenericFontMetricsTextMeasurerBase, ITextMeasurer + internal class GenericFontMetricsTextMeasurer : GenericFontMetricsTextMeasurerBase, IWrapTextMeasurer { /// - /// If the text measurer should measure wrap text cells. - /// Only CR, LF or CRLF should be considered. + /// If the text measurer should measure wrap text cells during AutoFit calculations. + /// Line breaks are considered on explicit newlines (CR, LF, CRLF) as well as soft wrap boundaries (spaces, tabs, and hyphens). /// - public bool MeasureWrappedTextCells -{ - get; - set; + public bool MeasureWrappedTextCells + { + get; + set; } + /// /// Measures the supplied text /// @@ -35,10 +36,23 @@ public bool MeasureWrappedTextCells /// Font of the text to measure /// A public TextMeasurement MeasureText(string text, MeasurementFont font) + { + return MeasureText(text, font, MeasureWrappedTextCells); + } + + /// + /// Measures the supplied text with cell-specific wrapping and last-line padding + /// + /// The text to measure + /// Font of the text to measure + /// Whether word wrap is enabled for this cell + /// Padding in pixels to add only to the last wrapped line (e.g. for autofilter arrows) + /// A + public TextMeasurement MeasureText(string text, MeasurementFont font, bool wrapText, float lastLinePadding = 0f) { var fontKey = GetKey(font.FontFamily, font.Style); if (!IsValidFont(fontKey)) return TextMeasurement.Empty; - return MeasureTextInternal(text, fontKey, font.Style, font.Size, MeasureWrappedTextCells); + return MeasureTextInternal(text, fontKey, font.Style, font.Size, wrapText, lastLinePadding); } public bool ValidForEnvironment() diff --git a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs index 5ceab9145e..d0a5cbb458 100644 --- a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs +++ b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs @@ -47,26 +47,50 @@ internal protected bool IsValidFont(uint fontKey) return _fonts.ContainsKey(fontKey); } - internal protected TextMeasurement MeasureTextInternal(string text, uint fontKey, MeasurementFontStyles style, float size, bool wrapText = false) + internal protected TextMeasurement MeasureTextInternal(string text, uint fontKey, MeasurementFontStyles style, float size, bool wrapText = false, float lastLinePadding = 0f) { var sFont = _fonts[fontKey]; var width = 0f; var maxWidth = 0f; var widthEA = 0f; + var maxWidthEA = 0f; for (var x = 0; x < text.Length; x++) { var fnt = sFont; var c = text[x]; - if(wrapText && (c=='\n' || c=='\r')) + + // When word-wrap is enabled, split lines on explicit newlines (\n, \r) + // as well as soft wrap boundaries (spaces, tabs, and hyphens) as Excel does. + if(wrapText && (c=='\n' || c=='\r' || c==' ' || c=='-' || c=='\t')) { if(x>0 && c=='\r' && text[x-1]=='\n') { continue; //CRLF should be handled as one new line. } - if(width>maxWidth) + + // Hyphens remain visible at the end of the wrapped line. + // Therefore, we measure and add the hyphen width to the current line width before wrapping. + if (c == '-') + { + if (sFont.CharMetrics.ContainsKey(c)) + { + width += fnt.ClassWidths[sFont.CharMetrics[c]]; + } + } + if ((width + widthEA) > (maxWidth + maxWidthEA)) { maxWidth = width; - width = 0; + maxWidthEA = widthEA; + } + + // Reset current line width and East Asian width for the next line + width = 0; + widthEA = 0; + + // Since hyphen is already counted, continue directly to avoid processing it again + if (c == '-') + { + continue; } } @@ -89,15 +113,37 @@ internal protected TextMeasurement MeasureTextInternal(string text, uint fontKey } } } - if(maxWidth > width) + + // Only perform separate line measurements if we have active AutoFilter padding AND the text has actually wrapped into multiple lines. + if (lastLinePadding > 0f && (maxWidth > 0f || maxWidthEA > 0f)) { - width = maxWidth; + // Measure the preceding lines and the last line separately. + // Apply the padding ONLY to the last line. + float maxPreceding = maxWidth * size; + maxPreceding *= _fontScaleFactors.GetScaleFactor(fontKey, maxPreceding); + maxPreceding += maxWidthEA * size; + + float lastLine = width * size; + lastLine *= _fontScaleFactors.GetScaleFactor(fontKey, lastLine); + lastLine += widthEA * size; + + width = Math.Max(maxPreceding, lastLine + lastLinePadding); } - width *= size; - widthEA *= size; - var sf = _fontScaleFactors.GetScaleFactor(fontKey, width); - width *= sf; - width += widthEA; + else + { + if ((maxWidth + maxWidthEA) > (width + widthEA)) + { + width = maxWidth; + widthEA = maxWidthEA; + } + width *= size; + widthEA *= size; + var sf = _fontScaleFactors.GetScaleFactor(fontKey, width); + width *= sf; + width += widthEA; + width += lastLinePadding; + } + var height = sFont.LineHeight1em * size; return new TextMeasurement(width, height); } diff --git a/src/EPPlus/ExcelTextSettings.cs b/src/EPPlus/ExcelTextSettings.cs index 52fa9a222d..ed35fc271e 100644 --- a/src/EPPlus/ExcelTextSettings.cs +++ b/src/EPPlus/ExcelTextSettings.cs @@ -87,12 +87,12 @@ public ITextMeasurer GenericTextMeasurer /// Measures a text with default settings when there is no other option left... /// internal DefaultTextMeasurer DefaultTextMeasurer { get; set; } + bool _measureWrappedTextCells=false; /// - /// Should return true if the text measurer should measure wrap text cells. Only CR, LF or CRLF should be considered + /// True if the text measurer should measure wrapped text cells during AutoFit calculations. + /// Line breaks are considered on explicit newlines (CR, LF, CRLF) as well as soft wrap boundaries (spaces, tabs, and hyphens). /// - /// True if the measurer can be . - bool _measureWrappedTextCells=false; - internal bool MeasureWrappedTextCells + public bool MeasureWrappedTextCells { get { diff --git a/src/EPPlusTest/Issues/WorksheetIssues.cs b/src/EPPlusTest/Issues/WorksheetIssues.cs index c767615ccf..0fe34dca27 100644 --- a/src/EPPlusTest/Issues/WorksheetIssues.cs +++ b/src/EPPlusTest/Issues/WorksheetIssues.cs @@ -881,7 +881,7 @@ private static void AddMeasureSheet(ExcelPackage p, ExcelWorksheet ws) ws.Cells["B2"].Value = multiLineText; p.Settings.TextSettings.MeasureWrappedTextCells = true; - // AutoFitColumns - calculates width as if there were no line breaks. + // AutoFitColumns - calculates width taking line breaks and wrapping into account. ws.Cells["A1:B2"].AutoFitColumns(); p.Settings.TextSettings.MeasureWrappedTextCells = false; @@ -1193,5 +1193,86 @@ public void Issue2325() Assert.IsTrue(ex.Message.Contains("Strict Open XML")); } + [TestMethod] + public void MeasureWrappedText_SoftWrapBoundariesAndPaddingTest() + { + using (var package = new ExcelPackage()) + { + var ws = package.Workbook.Worksheets.Add("AutofitTest"); + + // Set the package text settings to enable soft wrap measurements + package.Settings.TextSettings.MeasureWrappedTextCells = true; + + // Set up a cell with standard text containing spaces, tabs, and hyphens (soft wrap boundaries) + var cell1 = ws.Cells["A1"]; + cell1.Value = "Generic wrapped text string - Test"; + cell1.Style.WrapText = true; + + // Auto-fit columns + ws.Cells["A1"].AutoFitColumns(); + + // Let's verify that the width is calculated and is reasonable (greater than 0) + var width1 = ws.Column(1).Width; + Assert.IsTrue(width1 > 0d, "Column width should be greater than 0"); + + // Test a cell with AutoFilter padding. Excel's AutoFilter header will apply a 15px padding. + // We want to verify that when MeasureWrappedTextCells is true, the padding is only added once + // to the last line, and does not multiply or break key lookups. + ws.AutoFilter.Address = ws.Cells["A1:B1"]; + + // Reset column width to a small value + ws.Column(1).Width = 5d; + + // Run autofit again on the autofiltered cell + ws.Cells["A1"].AutoFitColumns(); + + // Run autofit again on the autofiltered cell + var widthWithPadding = ws.Column(1).Width; + Assert.IsTrue(widthWithPadding > 0d); + + // Verify that the cache key lookup works properly without collisions. + // We do this by triggering multiple autofits on the same string with and without padding/wrap setting. + var cell2 = ws.Cells["A2"]; + cell2.Value = "Generic wrapped text string - Test"; + cell2.Style.WrapText = false; // no wrapping for row 2 + + ws.Cells["A1:A2"].AutoFitColumns(); + + var widthCombined = ws.Column(1).Width; + Assert.IsTrue(widthCombined > 0d); + } + } + + [TestMethod] + public void GenericFontMetricsTextMeasurer_OverloadDelegationTest() + { + var measurer = new OfficeOpenXml.Core.Worksheet.Core.Worksheet.Fonts.GenericMeasurements.GenericFontMetricsTextMeasurer(); + var font = new OfficeOpenXml.Interfaces.Drawing.Text.MeasurementFont { FontFamily = "Calibri", Size = 11, Style = OfficeOpenXml.Interfaces.Drawing.Text.MeasurementFontStyles.Regular }; + var text = "EPPlus AutoFit Word Wrap and Padding Test String"; + + // 1. By default, MeasureWrappedTextCells is false. Verify overloads match. + measurer.MeasureWrappedTextCells = false; + var measureDefault2Param = measurer.MeasureText(text, font); + var measureDefault4Param = measurer.MeasureText(text, font, wrapText: false, lastLinePadding: 0f); + + Assert.AreEqual(measureDefault4Param.Width, measureDefault2Param.Width, 0.0001f); + Assert.AreEqual(measureDefault4Param.Height, measureDefault2Param.Height, 0.0001f); + + // 2. Set MeasureWrappedTextCells to true. Verify overloads match. + measurer.MeasureWrappedTextCells = true; + var measureWrapped2Param = measurer.MeasureText(text, font); + var measureWrapped4Param = measurer.MeasureText(text, font, wrapText: true, lastLinePadding: 0f); + + Assert.AreEqual(measureWrapped4Param.Width, measureWrapped2Param.Width, 0.0001f); + Assert.AreEqual(measureWrapped4Param.Height, measureWrapped2Param.Height, 0.0001f); + + // 3. Verify that wrapping actually wraps the text and results in a narrower but taller box, or at least a different size. + Assert.AreNotEqual(measureDefault2Param.Width, measureWrapped2Param.Width); + + // 4. Test overload with custom padding to ensure the padding is correctly factored in. + var measureWithPadding = measurer.MeasureText(text, font, wrapText: true, lastLinePadding: 15f); + Assert.IsTrue(measureWithPadding.Width >= measureWrapped2Param.Width); + } + } }