From 28e269d0901374a04e42663dbeaa82bef8ace5ec Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:14:25 +0200 Subject: [PATCH] Fix for autofit columns with autofilters --- src/EPPlus/Core/AutofitHelper.cs | 14 ++++- src/EPPlus/Drawing/ExcelDrawing.cs | 4 +- src/EPPlus/LoadFunctions/LoadFromTextBase.cs | 16 ++--- src/EPPlusTest/HyperlinkTest.cs | 46 ++++++++++++++ src/EPPlusTest/Issues/WorksheetIssues.cs | 52 ++++++++++++++++ .../LoadFunctions/LoadFromTextTests.cs | 15 +++++ src/EPPlusTest/WorkSheetTests.cs | 62 +++++++++++++++++++ 7 files changed, 198 insertions(+), 11 deletions(-) diff --git a/src/EPPlus/Core/AutofitHelper.cs b/src/EPPlus/Core/AutofitHelper.cs index a121b3480b..968f1abae7 100644 --- a/src/EPPlus/Core/AutofitHelper.cs +++ b/src/EPPlus/Core/AutofitHelper.cs @@ -23,6 +23,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; private ExcelRangeBase _range; ITextMeasurer _genericMeasurer = new GenericFontMetricsTextMeasurer(); MeasurementFont _nonExistingFont = new MeasurementFont() { FontFamily = FontSize.NonExistingFont }; @@ -126,9 +128,19 @@ internal void AutofitColumn(double MinimumWidth, double MaximumWidth) { if (af.Collide(fromRow, col, toRow, col) != eAddressCollition.No) { - var cell = worksheet.Cells[af.Address]; + var cell = worksheet.Cells[af._fromRow, col]; var cellStyleId = styles.CellXfs[cell.StyleID]; currentMaxWidth = GetTextLength(cell, textLengthCache, styles, cellStyleId, normalSize, MaximumWidth, currentMaxWidth); + // 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; + if (currentMaxWidth >= MaximumWidth) + { + currentMaxWidth = MaximumWidth; + } } } foreach (var cell in worksheet.Cells[fromRow, col, toRow, col]) diff --git a/src/EPPlus/Drawing/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index 226fa723db..08fe0167ff 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -544,7 +544,7 @@ public Uri Hyperlink DeleteNode(_hyperLinkPath); if (HypRel != null) { - _drawings._package.ZipPackage.DeletePart(UriHelper.ResolvePartUri(HypRel.SourceUri, HypRel.TargetUri)); + _drawings.Part.DeleteRelationship(HypRel.Id); } } @@ -559,7 +559,7 @@ public Uri Hyperlink HypRel = _drawings.Part.CreateRelationship(value, Packaging.TargetMode.External, ExcelPackage.schemaHyperlink); } SetXmlNodeString(_hyperLinkPath + "/@r:id", HypRel.Id); - if (Hyperlink is ExcelHyperLink excelLink) + if (value is ExcelHyperLink excelLink) { SetXmlNodeString(_hyperLinkPath + "/@tooltip", excelLink.ToolTip); } diff --git a/src/EPPlus/LoadFunctions/LoadFromTextBase.cs b/src/EPPlus/LoadFunctions/LoadFromTextBase.cs index a61614e06d..feb94d5574 100644 --- a/src/EPPlus/LoadFunctions/LoadFromTextBase.cs +++ b/src/EPPlus/LoadFunctions/LoadFromTextBase.cs @@ -62,16 +62,16 @@ protected bool IsEOL(string text, int ix, string eol) protected object ConvertData(T Format, eDataTypes[] dataType, string v, int col, bool isText) { - if (isText && (dataType == null || dataType.Length < col)) + bool isOutOfBounds = dataType == null || col >= dataType.Length; + if (isOutOfBounds) { - return string.IsNullOrEmpty(v) ? null : v; - } - else - { - if(dataType == null || dataType.Length < col) - return ConvertData(Format, eDataTypes.Unknown, v, col, isText); - return ConvertData(Format, dataType[col], v, col, isText); + if (isText) + { + return string.IsNullOrEmpty(v) ? null : v; + } + return ConvertData(Format, eDataTypes.Unknown, v, col, isText); } + return ConvertData(Format, dataType[col], v, col, isText); } protected object ConvertData(T Format, eDataTypes? dataType, string v, int col, bool isText) diff --git a/src/EPPlusTest/HyperlinkTest.cs b/src/EPPlusTest/HyperlinkTest.cs index 65d119e60c..6d1f40a2e4 100644 --- a/src/EPPlusTest/HyperlinkTest.cs +++ b/src/EPPlusTest/HyperlinkTest.cs @@ -102,5 +102,51 @@ public void ReadUriWithLocation() } } + + [TestMethod] + public void DrawingHyperlinkUpdate_ShouldNotThrowException() + { + using (var package = new ExcelPackage()) + { + var ws = package.Workbook.Worksheets.Add("Sheet1"); + + // Add a drawing (shape) + var shape = ws.Drawings.AddShape("MyShape", eShapeStyle.Rect); + + // 1. Assign initial external URL hyperlink + var initialUrl = new ExcelHyperLink("https://epplussoftware.com") { ToolTip = "Initial ToolTip" }; + shape.Hyperlink = initialUrl; + + Assert.IsNotNull(shape.Hyperlink); + + // 2. Re-assign to a new external URL hyperlink (this would crash without the fix) + var updatedUrl = new ExcelHyperLink("https://github.com/EPPlusSoftware/EPPlus") { ToolTip = "Updated ToolTip" }; + shape.Hyperlink = updatedUrl; + + Assert.AreEqual("https://github.com/EPPlusSoftware/EPPlus", shape.Hyperlink.OriginalString); + + // 3. Re-assign to an internal sheet reference (this would also crash without the fix) + var internalLink = new ExcelHyperLink("Sheet1!A10", "Go to A10") { ToolTip = "Internal ToolTip" }; + shape.Hyperlink = internalLink; + + Assert.AreEqual("Sheet1!A10", ((ExcelHyperLink)shape.Hyperlink).ReferenceAddress); + + // 4. Save and read back to verify XML and tooltip are correct + package.Save(); + + using (var readPackage = new ExcelPackage(package.Stream)) + { + var readWs = readPackage.Workbook.Worksheets["Sheet1"]; + var readShape = readWs.Drawings["MyShape"]; + + var readLink = (ExcelHyperLink)readShape.Hyperlink; + Assert.IsNotNull(readLink); + Assert.AreEqual("Sheet1!A10", readLink.ReferenceAddress); + Assert.AreEqual("Internal ToolTip", readLink.ToolTip); + } + } + } + + } } diff --git a/src/EPPlusTest/Issues/WorksheetIssues.cs b/src/EPPlusTest/Issues/WorksheetIssues.cs index c6258b6390..c767615ccf 100644 --- a/src/EPPlusTest/Issues/WorksheetIssues.cs +++ b/src/EPPlusTest/Issues/WorksheetIssues.cs @@ -160,6 +160,58 @@ public void i1314() package.Dispose(); } } + + [TestMethod] + public void AutofitAutofilterTest() + { + using var package = OpenPackage("AutofitAutofilterTest.xlsx", true); + + // Two sheets with identical data: one with an autofilter, one without. + // After autofit, the filtered columns should be wider than the unfiltered + // ones by the reserved width of the dropdown arrow. + var wsFilter = package.Workbook.Worksheets.Add("WithFilter"); + var wsNoFilter = package.Workbook.Worksheets.Add("NoFilter"); + + foreach (var ws in new[] { wsFilter, wsNoFilter }) + { + // Headers are the widest text in each column - the data below is deliberately + // shorter so the column width is driven by the header (+ the dropdown arrow + // on the filtered sheet). + ws.Cells["A1"].Value = "Department"; + ws.Cells["B1"].Value = "Annual Budget"; + ws.Cells["C1"].Value = "Region Name"; + + ws.Cells["A2"].Value = "Sales"; + ws.Cells["B2"].Value = 1200; + ws.Cells["C2"].Value = "North"; + + ws.Cells["A3"].Value = "IT"; + ws.Cells["B3"].Value = 980; + ws.Cells["C3"].Value = "West"; + + ws.Cells["A4"].Value = "HR"; + ws.Cells["B4"].Value = 540; + ws.Cells["C4"].Value = "East"; + } + + // Only one sheet gets the autofilter. + wsFilter.Cells["A1:C4"].AutoFilter = true; + + wsFilter.Cells["A1:C4"].AutoFitColumns(); + wsNoFilter.Cells["A1:C4"].AutoFitColumns(); + + for (int col = 1; col <= 3; col++) + { + var filterWidth = wsFilter.Column(col).Width; + var noFilterWidth = wsNoFilter.Column(col).Width; + System.Diagnostics.Debug.WriteLine($"Column {col}: filter={filterWidth}, noFilter={noFilterWidth}"); + Assert.IsTrue(filterWidth > noFilterWidth, + $"Column {col}: filtered width ({filterWidth}) should be greater than unfiltered width ({noFilterWidth})."); + } + + SaveAndCleanup(package); + } + [TestMethod] public void i1317() { diff --git a/src/EPPlusTest/LoadFunctions/LoadFromTextTests.cs b/src/EPPlusTest/LoadFunctions/LoadFromTextTests.cs index ce444fed02..72b7e9ab2a 100644 --- a/src/EPPlusTest/LoadFunctions/LoadFromTextTests.cs +++ b/src/EPPlusTest/LoadFunctions/LoadFromTextTests.cs @@ -467,5 +467,20 @@ public void ReadFixedTextFile3() } } + [TestMethod] + public void ShouldLoadCsvFormatWithTrailingColumns() + { + // This test verifies that importing a text/CSV file with more columns than specified in DataTypes does not crash. + // Previously, an off-by-one bounds check (dataType.Length < col) allowed the loop to attempt accessing dataType[col] + // when 'col' was equal to 'dataType.Length', resulting in a System.IndexOutOfRangeException. + // With the fix (col >= dataType.Length), trailing columns beyond the DataTypes array are gracefully imported as Unknown (General). + AddLine("a;2;extra"); + _format.Delimiter = ';'; + _format.DataTypes = new eDataTypes[] { eDataTypes.String, eDataTypes.Number }; + _worksheet.Cells["A1"].LoadFromText(_lines.ToString(), _format); + Assert.AreEqual("a", _worksheet.Cells["A1"].Value); + Assert.AreEqual(2d, _worksheet.Cells["B1"].Value); + Assert.AreEqual("extra", _worksheet.Cells["C1"].Value); + } } } diff --git a/src/EPPlusTest/WorkSheetTests.cs b/src/EPPlusTest/WorkSheetTests.cs index ed53a0be5f..f9fe27debb 100644 --- a/src/EPPlusTest/WorkSheetTests.cs +++ b/src/EPPlusTest/WorkSheetTests.cs @@ -2180,6 +2180,68 @@ public void AutoFitColumnTest() Assert.AreEqual(125d, ws.Columns[1].Width, 5d); SaveAndCleanup(p); } + + [TestMethod] + public void AutofitAutofilterTest() + { + using var package = OpenTemplatePackage("AutoFitAutofilter.xlsx"); + var ws = package.Workbook.Worksheets.Add("Sheet1"); + + // Headers are the widest text in each column - the data below is deliberately + // shorter so the column width is driven by the header + the autofilter dropdown arrow. + ws.Cells["A1"].Value = "Department"; + ws.Cells["B1"].Value = "Annual Budget"; + ws.Cells["C1"].Value = "Region Name"; + + // Data rows - all shorter than the headers above them. + ws.Cells["A2"].Value = "Sales"; + ws.Cells["B2"].Value = 1200; + ws.Cells["C2"].Value = "North"; + + ws.Cells["A3"].Value = "IT"; + ws.Cells["B3"].Value = 980; + ws.Cells["C3"].Value = "West"; + + ws.Cells["A4"].Value = "HR"; + ws.Cells["B4"].Value = 540; + ws.Cells["C4"].Value = "East"; + + // Apply autofilter across the header row + data. + ws.Cells["A1:C4"].AutoFilter = true; + + // Autofit the columns. + ws.Cells["A1:C4"].AutoFitColumns(); + + // Inspect what EPPlus actually produced for each column. + System.Diagnostics.Debug.WriteLine($"Column A (Department): {ws.Column(1).Width}"); + System.Diagnostics.Debug.WriteLine($"Column B (Annual Budget): {ws.Column(2).Width}"); + System.Diagnostics.Debug.WriteLine($"Column C (Region Name): {ws.Column(3).Width}"); + + // Save the workbook + SaveAndCleanup(package); + } + + [TestMethod] + public void AutoFitColumnsWithAutoFilter() + { + var ws = _pck.Workbook.Worksheets.Add("AutofitAutoFilter"); + ws.Cells["A1"].Value = "hour"; + ws.Cells["B1"].Value = "minute"; + ws.Cells["A2"].Value = 12; + ws.Cells["B2"].Value = 30; + + ws.Cells["A1:B2"].AutoFilter = true; + + ws.Cells["A1:B2"].AutoFitColumns(); + + // Without the fix, the AutoFilter header row range (A1:B1) is measured as a whole. + // Under the hood, worksheet.Cells["A1:B1"].TextForWidth evaluated to "System.Object[,]" (16 chars), + // which forced a minimum width of ~16.07 points. + // With the fix, the specific cell for each column in the AutoFilter is measured, + // resulting in a narrow width matching "hour" / "minute". + Assert.IsTrue(ws.Column(1).Width < 12d, $"Column 1 width should be small but was {ws.Column(1).Width}"); + Assert.IsTrue(ws.Column(2).Width < 12d, $"Column 2 width should be small but was {ws.Column(2).Width}"); + } [TestMethod] public void CopyOverwrite() {