From bd3daaed253995104c292b20b2b33b94c9f1e4e2 Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:57:54 +0200 Subject: [PATCH 1/2] #2427 - Add WrappedTextAutofitMode enum for configurable WrapText measurement in AutoFitColumns; fix per-line East Asian width reset --- .../Drawing/Text/ITextMeasurer.cs | 12 +- .../Drawing/Text/eWrappedTextAutofitMode.cs | 47 +++ .../Drawing/Text/SystemDrawingTextMeasurer.cs | 8 + src/EPPlus/Core/AutofitHelper.cs | 2 +- .../GenericFontMetricsTextMeasurer.cs | 17 +- .../GenericFontMetricsTextMeasurerBase.cs | 82 ++++- src/EPPlus/ExcelTextSettings.cs | 28 +- src/EPPlusTest/AutofitTests.cs | 295 ++++++++++++++++++ .../RefAndLookup/ImageFunctionTests.cs | 14 + src/EPPlusTest/Issues/DefinedNameIssues.cs | 1 + src/EPPlusTest/Issues/WorksheetIssues.cs | 9 +- src/EPPlusTest/WorkSheetTests.cs | 100 +----- 12 files changed, 486 insertions(+), 129 deletions(-) create mode 100644 src/EPPlus.Interfaces/Drawing/Text/eWrappedTextAutofitMode.cs create mode 100644 src/EPPlusTest/AutofitTests.cs diff --git a/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs b/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs index f148a3062b..7b17e4adbb 100644 --- a/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs +++ b/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs @@ -11,6 +11,8 @@ Date Author Change 1/4/2021 EPPlus Software AB EPPlus Interfaces 1.0 *************************************************************************************************/ +using System; + namespace OfficeOpenXml.Interfaces.Drawing.Text { /// @@ -31,9 +33,15 @@ 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. + /// If the text measurer should measure wrapped text cells. /// + [Obsolete("Use WrappedTextAutofitMode instead. This property will be removed in a future major version.")] bool MeasureWrappedTextCells { get; set; } + + /// + /// Determines how cells with WrapText enabled are measured when calculating + /// column width in AutoFitColumns. Default is . + /// + eWrappedTextAutofitMode WrappedTextAutofitMode { get; set; } } } diff --git a/src/EPPlus.Interfaces/Drawing/Text/eWrappedTextAutofitMode.cs b/src/EPPlus.Interfaces/Drawing/Text/eWrappedTextAutofitMode.cs new file mode 100644 index 0000000000..812d4b2541 --- /dev/null +++ b/src/EPPlus.Interfaces/Drawing/Text/eWrappedTextAutofitMode.cs @@ -0,0 +1,47 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 1/4/2021 EPPlus Software AB Added wrapped text autofit mode + *************************************************************************************************/ +namespace OfficeOpenXml.Interfaces.Drawing.Text +{ + /// + /// Determines how cells with WrapText enabled are measured when calculating + /// column width in AutoFitColumns. + /// + public enum eWrappedTextAutofitMode + { + /// + /// Cells with WrapText enabled are ignored when calculating column width. + /// This is the default and matches the behaviour of earlier versions. + /// + Skip = 0, + /// + /// The entire cell text is measured as a single line, ignoring wrapping. + /// + FullText = 1, + /// + /// The text is split on explicit line breaks (CR, LF, CRLF) and the width + /// of the widest resulting line determines the cell's contribution to the + /// column width. + /// + SplitNewLine = 2, + /// + /// The text is split on whitespace (space, tab, and line breaks) and on + /// hyphen characters (U+002D hyphen-minus and U+2010 hyphen). The width of + /// the widest resulting segment determines the cell's contribution to the + /// column width. Hyphens are visible and their width is included in the + /// segment they terminate; whitespace is not. Note: this yields the minimum + /// column width at which no single word overflows, and is not a simulation + /// of Excel's line wrapping. + /// + SplitWord = 3 + } +} diff --git a/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs b/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs index 303887c0a3..9e2031ace9 100644 --- a/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs +++ b/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs @@ -12,11 +12,19 @@ public class SystemDrawingTextMeasurer : ITextMeasurer, IDisposable /// If the text measurer should measure wrap text cells. /// Only CR, LF or CRLF should be considered. /// +#pragma warning disable 618 public bool MeasureWrappedTextCells { get; set; } +#pragma warning restore 618 + public eWrappedTextAutofitMode WrappedTextAutofitMode + { + get; + set; + } + public SystemDrawingTextMeasurer() { if (Environment.OSVersion.Platform == PlatformID.Win32NT && diff --git a/src/EPPlus/Core/AutofitHelper.cs b/src/EPPlus/Core/AutofitHelper.cs index 968f1abae7..9c97dae441 100644 --- a/src/EPPlus/Core/AutofitHelper.cs +++ b/src/EPPlus/Core/AutofitHelper.cs @@ -147,7 +147,7 @@ internal void AutofitColumn(double MinimumWidth, double MaximumWidth) { var cellStyleId = styles.CellXfs[cell.StyleID]; if (cell.Merge == true) continue; - if (cellStyleId.WrapText && _textSettings.MeasureWrappedTextCells == false) continue; + if (cellStyleId.WrapText && _textSettings.WrappedTextAutofitMode == eWrappedTextAutofitMode.Skip) continue; currentMaxWidth = GetTextLength(cell, textLengthCache, styles, cellStyleId, normalSize, MaximumWidth, currentMaxWidth); if (currentMaxWidth >= MaximumWidth) { diff --git a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs index 990dc86d1f..f72b29d464 100644 --- a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs +++ b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs @@ -23,11 +23,24 @@ internal class GenericFontMetricsTextMeasurer : GenericFontMetricsTextMeasurerBa /// If the text measurer should measure wrap text cells. /// Only CR, LF or CRLF should be considered. /// +#pragma warning disable 618 public bool MeasureWrappedTextCells -{ + { get; set; } +#pragma warning restore 618 + /// + /// + /// + /// + public eWrappedTextAutofitMode WrappedTextAutofitMode + { + get; + set; + } + + /// /// Measures the supplied text /// @@ -38,7 +51,7 @@ public TextMeasurement MeasureText(string text, MeasurementFont font) { 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, WrappedTextAutofitMode); } 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..ca0c34a0cb 100644 --- a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs +++ b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs @@ -47,27 +47,53 @@ 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, eWrappedTextAutofitMode mode = eWrappedTextAutofitMode.Skip) { var sFont = _fonts[fontKey]; + + // Width of the current segment (a "segment" is a line in SplitNewLine mode, + // or a word in SplitWord mode). In FullText/Skip the whole text is one segment. var width = 0f; - var maxWidth = 0f; var widthEA = 0f; + + // Width of the widest segment seen so far. + var maxWidth = 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')) + + if (IsSegmentBoundary(c, mode)) { - if(x>0 && c=='\r' && text[x-1]=='\n') + // A CRLF pair is a single line break, not two. + if (x > 0 && c == '\r' && text[x - 1] == '\n') { - continue; //CRLF should be handled as one new line. + continue; } - if(width>maxWidth) + + // A visible boundary character (hyphen) remains at the end of the + // segment it terminates, so its own width is added before the break. + if (IsVisibleBoundary(c) && sFont.CharMetrics.ContainsKey(c)) + { + width += fnt.ClassWidths[sFont.CharMetrics[c]]; + } + + // Close the current segment: keep it if it is the widest so far. + if ((width + widthEA) > (maxWidth + maxWidthEA)) { maxWidth = width; - width = 0; + maxWidthEA = widthEA; } + + // Start a new, empty segment. + width = 0f; + widthEA = 0f; + + // The boundary character itself is not part of the next segment. + // (Visible boundaries were already counted into the closed segment above.) + continue; } //If east Asian char use default regardless of actual font. @@ -83,16 +109,23 @@ internal protected TextMeasurement MeasureTextInternal(string text, uint fontKey if (Char.IsDigit(c)) fw *= FontScaleFactors.DigitsScalingFactor; width += fw; } - else if (char.IsControl(c)==false) + else if (char.IsControl(c) == false) { width += sFont.ClassWidths[fnt.DefaultWidthClass]; } } } - if(maxWidth > width) + + // Close the final segment. + if ((width + widthEA) > (maxWidth + maxWidthEA)) { - width = maxWidth; + maxWidth = width; + maxWidthEA = widthEA; } + + width = maxWidth; + widthEA = maxWidthEA; + width *= size; widthEA *= size; var sf = _fontScaleFactors.GetScaleFactor(fontKey, width); @@ -102,6 +135,35 @@ internal protected TextMeasurement MeasureTextInternal(string text, uint fontKey return new TextMeasurement(width, height); } + /// + /// Returns true if the character ends the current measurement segment for the given mode. + /// + private static bool IsSegmentBoundary(char c, eWrappedTextAutofitMode mode) + { + switch (mode) + { + case eWrappedTextAutofitMode.SplitNewLine: + return c == '\n' || c == '\r'; + case eWrappedTextAutofitMode.SplitWord: + return c == '\n' || c == '\r' || c == ' ' || c == '\t' + || c == '\u002D' // hyphen-minus + || c == '\u2010'; // hyphen + default: + // FullText and Skip: the whole string is a single segment. + return false; + } + } + + /// + /// Returns true if the boundary character is visible and therefore contributes + /// its own width to the segment it terminates (hyphens). Whitespace and line + /// breaks are invisible and contribute no width. + /// + private static bool IsVisibleBoundary(char c) + { + return c == '\u002D' || c == '\u2010'; + } + static Dictionary AlphabetChars = new Dictionary { {'a', 0x06 }, diff --git a/src/EPPlus/ExcelTextSettings.cs b/src/EPPlus/ExcelTextSettings.cs index 52fa9a222d..afcb8a516b 100644 --- a/src/EPPlus/ExcelTextSettings.cs +++ b/src/EPPlus/ExcelTextSettings.cs @@ -41,7 +41,7 @@ public ITextMeasurer PrimaryTextMeasurer set { _primaryTextMeasurer = value; - _primaryTextMeasurer.MeasureWrappedTextCells = _measureWrappedTextCells; + _primaryTextMeasurer.WrappedTextAutofitMode = WrappedTextAutofitMode; } } /// @@ -56,7 +56,10 @@ public ITextMeasurer FallbackTextMeasurer set { _fallbackTextMeasurer = value; - _fallbackTextMeasurer.MeasureWrappedTextCells = _measureWrappedTextCells; + if(value != null) + { + _fallbackTextMeasurer.WrappedTextAutofitMode = WrappedTextAutofitMode; + } } } /// @@ -87,22 +90,25 @@ public ITextMeasurer GenericTextMeasurer /// Measures a text with default settings when there is no other option left... /// internal DefaultTextMeasurer DefaultTextMeasurer { get; set; } + + private eWrappedTextAutofitMode _wrappedTextAutofitMode = eWrappedTextAutofitMode.Skip; + /// - /// Should return true if the text measurer should measure wrap text cells. Only CR, LF or CRLF should be considered + /// Determines how cells with enabled are measured + /// when calculating column width in AutoFitColumns. The default is , + /// which ignores wrapped cells during autofit. /// - /// True if the measurer can be . - bool _measureWrappedTextCells=false; - internal bool MeasureWrappedTextCells - { + public eWrappedTextAutofitMode WrappedTextAutofitMode + { get { - return _measureWrappedTextCells; + return _wrappedTextAutofitMode; } set { - _measureWrappedTextCells = value; - PrimaryTextMeasurer.MeasureWrappedTextCells = value; - if (FallbackTextMeasurer != null) FallbackTextMeasurer.MeasureWrappedTextCells = value; + _wrappedTextAutofitMode = value; + PrimaryTextMeasurer.WrappedTextAutofitMode = value; + if(FallbackTextMeasurer != null) FallbackTextMeasurer.WrappedTextAutofitMode = value; } } } diff --git a/src/EPPlusTest/AutofitTests.cs b/src/EPPlusTest/AutofitTests.cs new file mode 100644 index 0000000000..6b86b5ca30 --- /dev/null +++ b/src/EPPlusTest/AutofitTests.cs @@ -0,0 +1,295 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using OfficeOpenXml.Interfaces.Drawing.Text; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlusTest +{ + [TestClass] + public class AutofitTests : TestBase + { + static ExcelPackage _pck; + [ClassInitialize] + public static void Init(TestContext context) + { + InitBase(); + _pck = OpenPackage("Worksheet.xlsx", true); + } + [ClassCleanup] + public static void Cleanup() + { + var dirName = _pck.File.DirectoryName; + var fileName = _pck.File.FullName; + + SaveAndCleanup(_pck); + if (File.Exists(fileName)) + { + File.Copy(fileName, dirName + "\\WorksheetRead.xlsx", true); + } + } + + [TestMethod] + public void AutoFitColumns() + { + var ws = _pck.Workbook.Worksheets.Add("Autofit"); + ws.Cells["A1:H1"].Value = "Auto fit column that is veeery long..."; + ws.Cells["A1:H1"].Style.Font.Name = "Arial"; + ws.Cells["B1"].Style.TextRotation = 30; + ws.Cells["C1"].Style.TextRotation = 45; + ws.Cells["D1"].Style.TextRotation = 75; + ws.Cells["E1"].Style.TextRotation = 90; + ws.Cells["F1"].Style.TextRotation = 120; + ws.Cells["G1"].Style.TextRotation = 135; + ws.Cells["H1"].Style.TextRotation = 180; + ws.Cells["A1:H1"].AutoFitColumns(0); + + ws.Column(40).AutoFit(); + } + [TestMethod] + public void AutoFitColumn() + { + var ws = _pck.Workbook.Worksheets.Add("Autofit2"); + ws.Cells["A1:A10"].Value = "Auto fit column that is veeery long..."; + ws.Cells["A1:A10"].Style.Font.Name = "Arial"; + ws.Columns[1].AutoFit(); + } + + [TestMethod] + public void AutoFitColumnTest() + { + var p = OpenTemplatePackage("AutoFitWorkbook.xlsx"); + var ws = p.Workbook.Worksheets[0]; + var start = DateTime.Now; + ws.Columns[1].AutoFit(); + var end = DateTime.Now; + TimeSpan span = end - start; + 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 Autofit_Skip() + { + // Skip: a WrapText cell must not contribute to the column width at all. + // Column A holds a long wrapped cell; column B holds nothing. Under Skip the + // wrapped cell is ignored, so both columns end up at the same (default) width. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.Skip; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Line 1\nLine 2 is a bit longer\nLine 3"; + ws.Cells["A1"].Style.WrapText = true; + // Act + ws.Cells["A1:B1"].AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Skip should ignore the wrapped cell, so column A matches the empty column B"); + } + + [TestMethod] + public void Autofit_FullText() + { + // FullText: the entire cell text is measured as a single line. + // The reference cell B holds the same text; because a WrapText cell keeps its + // newlines (measured as zero width), B must use identical newline placement so + // both cells measure the exact same visible characters. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.FullText; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Line 1\nLine 2 is a bit longer\nLine 3"; + ws.Cells["B1"].Value = "Line 1\nLine 2 is a bit longer\nLine 3"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "FullText should measure the entire string, matching the identical reference cell"); + } + + [TestMethod] + public void Autofit_SplitNewLine() + { + // SplitNewLine: the widest newline-separated line drives the width. + // Reference cell B holds that line ("Line 2 is a bit longer") on its own. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Line 1\nLine 2 is a bit longer\nLine 3"; + ws.Cells["B1"].Value = "Line 2 is a bit longer"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Column A (widest line) should match column B (that line in full)"); + } + + [TestMethod] + public void Autofit_SplitWord() + { + // SplitWord: the widest whitespace/hyphen-separated segment drives the width. + // Reference cell B holds that word ("aVeryLongWord") on its own. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitWord; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "short longer aVeryLongWord medium"; + ws.Cells["B1"].Value = "aVeryLongWord"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Column A (widest word) should match column B (that word in full)"); + } + + [TestMethod] + public void Autofit_SplitWord_HyphenIsVisibleAndBreaksTheWord() + { + // A hyphen is a visible break boundary: it terminates the preceding segment + // AND its own width is counted into that segment. So the widest segment of + // "aVeryLongWord-x" is "aVeryLongWord-" (word + trailing hyphen), not the bare word. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitWord; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "aVeryLongWord-x"; + ws.Cells["B1"].Value = "aVeryLongWord-"; // includes the trailing hyphen + ws.Cells["C1"].Value = "aVeryLongWord"; // bare word, no hyphen + ws.Cells["A1:C1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Segment should include the trailing hyphen (visible boundary)"); + Assert.IsTrue(ws.Columns[1].Width > ws.Columns[3].Width, + "Segment with hyphen should be wider than the bare word without it"); + } + + [TestMethod] + public void Autofit_SplitNewLine_CrlfCountsAsSingleBreak() + { + // A CRLF pair must be treated as one line break, not two. If it were counted + // as two breaks it would create an empty phantom segment between the lines, + // but that has zero width and would not change the result. What this guards + // is that the CR is not measured as a separate one-character segment and that + // the widest line is identified correctly across a CRLF boundary. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Short\r\nLine 2 is a bit longer\r\nShort"; + ws.Cells["B1"].Value = "Line 2 is a bit longer"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "CRLF should be treated as a single line break; widest line should match column B"); + } + + [TestMethod] + public void Autofit_SplitNewLine_WidestSegmentFirstIsStillChosen() + { + // The widest segment appears FIRST here. This guards against a reset bug where + // the running width of an earlier (wider) segment fails to carry over into the + // max comparison once a later, narrower segment is measured. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Line 1 is clearly the longest\nShort\nAlso short"; + ws.Cells["B1"].Value = "Line 1 is clearly the longest"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "The widest line is the first one and must still drive the column width"); + } + + [TestMethod] + public void Autofit_SplitNewLine_EastAsianWidthResetsPerLine() + { + // Regression test for the East Asian width bug: previously the EA width (widthEA) + // accumulated across ALL lines and was never reset at a line break, so a multi-line + // CJK cell was measured as the SUM of every line's EA width instead of the widest + // single line. Here the three lines are 2, 5 and 3 hiragana characters; the correct + // result is the width of the 5-character line. With the bug it would be roughly the + // width of all 10 characters combined. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "\u3042\u3042\n\u3042\u3042\u3042\u3042\u3042\n\u3042\u3042\u3042"; // , , + ws.Cells["B1"].Value = "\u3042\u3042\u3042\u3042\u3042"; // (widest line) + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Multi-line CJK column should match its widest line, not the sum of all lines"); + } + } +} diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunctionTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunctionTests.cs index eff68ac678..a4d6608a54 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunctionTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunctionTests.cs @@ -134,5 +134,19 @@ public void ImageTest_ShouldCacheUrls1() Assert.AreEqual(1, httpsService.NumberOfCalls); } + + [TestMethod] + public void ImageTest_ShouldReturnNameErrorWhenServiceIsNull() + { + using var package = new ExcelPackage(); + var sheet = package.Workbook.Worksheets.Add("Sheet1"); + sheet.Cells["A1"].Formula = "IMAGE(\"https://epplussoftware.com/img/EPPlus-logo-full.png\", \"Alt text\", 1)"; + + package.Settings.ImageFunctionService = null; + + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Name), sheet.Cells["A1"].Value); + } } } diff --git a/src/EPPlusTest/Issues/DefinedNameIssues.cs b/src/EPPlusTest/Issues/DefinedNameIssues.cs index 2ac08c50fd..15f7b8e221 100644 --- a/src/EPPlusTest/Issues/DefinedNameIssues.cs +++ b/src/EPPlusTest/Issues/DefinedNameIssues.cs @@ -253,3 +253,4 @@ static void RunTest(string name, Func<(ExcelPackage pkg, ExcelWorksheet ws1, Exc } } } + diff --git a/src/EPPlusTest/Issues/WorksheetIssues.cs b/src/EPPlusTest/Issues/WorksheetIssues.cs index c767615ccf..c777640838 100644 --- a/src/EPPlusTest/Issues/WorksheetIssues.cs +++ b/src/EPPlusTest/Issues/WorksheetIssues.cs @@ -7,6 +7,7 @@ using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; using OfficeOpenXml.FormulaParsing.Excel.Functions.Logical; using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; +using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.RichData; using OfficeOpenXml.SystemDrawing.Image; using OfficeOpenXml.SystemDrawing.Text; @@ -880,16 +881,16 @@ 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. + p.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + // AutoFitColumns - measures the widest newline-separated line. ws.Cells["A1:B2"].AutoFitColumns(); - p.Settings.TextSettings.MeasureWrappedTextCells = false; + p.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.Skip; ws.Cells["C1"].Value = multiLineText; ws.Cells["C1"].Style.WrapText = true; ws.Cells["D2"].Value = multiLineText; - // AutoFitColumns - calculates width as if there were no line breaks. + // AutoFitColumns - wrapped cells are ignored. ws.Cells["C1:D2"].AutoFitColumns(); } diff --git a/src/EPPlusTest/WorkSheetTests.cs b/src/EPPlusTest/WorkSheetTests.cs index f9fe27debb..c091bbc84d 100644 --- a/src/EPPlusTest/WorkSheetTests.cs +++ b/src/EPPlusTest/WorkSheetTests.cs @@ -2143,105 +2143,7 @@ public void BuildInStyles() ws.Cells["a1:c3"].StyleName = "Normal"; // n.CustomBuildin = true; } - [TestMethod] - public void AutoFitColumns() - { - var ws = _pck.Workbook.Worksheets.Add("Autofit"); - ws.Cells["A1:H1"].Value = "Auto fit column that is veeery long..."; - ws.Cells["A1:H1"].Style.Font.Name = "Arial"; - ws.Cells["B1"].Style.TextRotation = 30; - ws.Cells["C1"].Style.TextRotation = 45; - ws.Cells["D1"].Style.TextRotation = 75; - ws.Cells["E1"].Style.TextRotation = 90; - ws.Cells["F1"].Style.TextRotation = 120; - ws.Cells["G1"].Style.TextRotation = 135; - ws.Cells["H1"].Style.TextRotation = 180; - ws.Cells["A1:H1"].AutoFitColumns(0); - - ws.Column(40).AutoFit(); - } - [TestMethod] - public void AutoFitColumn() - { - var ws = _pck.Workbook.Worksheets.Add("Autofit2"); - ws.Cells["A1:A10"].Value = "Auto fit column that is veeery long..."; - ws.Cells["A1:A10"].Style.Font.Name = "Arial"; - ws.Columns[1].AutoFit(); - } - [TestMethod] - public void AutoFitColumnTest() - { - var p = OpenTemplatePackage("AutoFitWorkbook.xlsx"); - var ws = p.Workbook.Worksheets[0]; - var start = DateTime.Now; - ws.Columns[1].AutoFit(); - var end = DateTime.Now; - TimeSpan span = end - start; - 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() { From d5eb9158899fb33e0f21b74facc559a4493bb2c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 9 Jul 2026 16:48:13 +0200 Subject: [PATCH 2/2] Added tests. Might be correct despite failing test --- src/EPPlusTest/AutofitTests.cs | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/EPPlusTest/AutofitTests.cs b/src/EPPlusTest/AutofitTests.cs index 6b86b5ca30..dca8b2654f 100644 --- a/src/EPPlusTest/AutofitTests.cs +++ b/src/EPPlusTest/AutofitTests.cs @@ -291,5 +291,78 @@ public void Autofit_SplitNewLine_EastAsianWidthResetsPerLine() Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, "Multi-line CJK column should match its widest line, not the sum of all lines"); } + + [TestMethod] + public void AutoFitTestWithDifferenLengths() + { + using (var p = OpenPackage("SimpleAutofitTests.xlsx", true)) + { + //p.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitWord; + var ws = p.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Little"; + ws.Cells["A2"].Value = "MEDIUM"; + ws.Cells["A3"].Value = "Largeeeeeesssst"; + ws.Cells["A4"].Value = "Larg-ish"; + + ws.Cells["B1"].Value = "I should not be autofit"; + + var bWidth = ws.Columns[2].Width; + + ws.Cells["A1:A4"].AutoFitColumns(); + + var bWidthAfter = ws.Columns[2].Width; + + //Untouched cols should remain untouched + Assert.AreEqual(bWidth, bWidthAfter); + + ws.Cells["A5"].Value = "Very large but outside the range of what should be fitted"; + + var widthBeforeA = ws.Columns[1].Width; + + ws.Cells["A1:A4"].AutoFitColumns(); + + var widthAfterA = ws.Columns[1].Width; + + + //Untouched cells within same column Probaly should not change the column + //technically different from excel but also different syntax + Assert.AreEqual(widthBeforeA, widthAfterA); + + + ws.Cells.AutoFitColumns(); + + //Doing all cells should however + Assert.AreNotEqual(widthAfterA, ws.Columns[1].Width); + Assert.IsTrue(ws.Columns[1].Width > widthAfterA); + + SaveAndCleanup(p); + } + } + + [TestMethod] + public void AutofitOneCellCompoundingConfigs() + { + using (var p = OpenPackage("AutofitCompoundOneCell.xlsx", true)) + { + //p.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitWord; + var ws = p.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "aaaaaaaaaaaaaaaaa"; + + ws.Cells["A1"].AutoFitColumns(); + + ws.Cells["A1"].Value = "aaaaaaaaaaaaaaaaaaaaaaaa"; + ws.Cells["A1"].Style.WrapText = true; + + ws.Cells["A1"].AutoFitColumns(); + + var colWidth = ws.Column(1).Width;; + + SaveAndCleanup(p); + + //Does not appear to match output file + //Might still be correct bc OS margins etc. + Assert.AreEqual(8.43d, ws.Column(1).Width); + } + } } }