From 936c92a595da144b37fa6ff44c95d5608b7e5dfc Mon Sep 17 00:00:00 2001 From: karlkallman <137038948+karlkallman@users.noreply.github.com> Date: Wed, 27 May 2026 15:56:34 +0200 Subject: [PATCH 001/118] Feature/pivotbyv2 (#2365) * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * Fixed failing test * New pivotby branch * Added some failing unit tests * WIP * WIP * WIP * WIP --------- Co-authored-by: swmal <897655+swmal@users.noreply.github.com> --- src/EPPlus/ExcelWorkbook.cs | 13 +- .../Excel/Functions/BuiltInFunctions.cs | 4 +- .../Excel/Functions/RefAndLookup/ColEntry.cs | 26 + .../Excel/Functions/RefAndLookup/Groupby.cs | 153 +- .../GroupingFunctions/GroupByFunctionBase.cs | 325 ++- .../GroupingFunctions/GroupLevel.cs | 2 + .../GroupingFunctions/LeafWithPath.cs | 29 + .../GroupingFunctions/PivotByArgs.cs | 25 + .../GroupingFunctions/RelativeTo.cs | 24 + .../RefAndLookup/PivotBy/DataBuilding.cs | 200 ++ .../Functions/RefAndLookup/PivotBy/PivotBy.cs | 124 + .../RefAndLookup/PivotBy/Rendering.cs | 971 ++++++++ .../Functions/RefAndLookup/PivotByTests.cs | 2078 +++++++++++++++++ 13 files changed, 3750 insertions(+), 224 deletions(-) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ColEntry.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/LeafWithPath.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/DataBuilding.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs diff --git a/src/EPPlus/ExcelWorkbook.cs b/src/EPPlus/ExcelWorkbook.cs index 02b0c79d79..282f47ff65 100644 --- a/src/EPPlus/ExcelWorkbook.cs +++ b/src/EPPlus/ExcelWorkbook.cs @@ -439,7 +439,7 @@ internal void ThrowIfCalculationCanceled() /// /// Returns true if a calculation was canceled, leaving the workbook in an inconsistent state. - /// A workbook in this state must be disposed — saving or recalculating is not permitted. + /// A workbook in this state must be disposed � saving or recalculating is not permitted. /// public bool IsCalculationInconsistent => IsCalculationCanceled; #endif @@ -1196,14 +1196,6 @@ private void CreateWorkbookXml(XmlNamespaceManager namespaceManager) XmlElement workbookView = _workbookXml.CreateElement("workbookView", ExcelPackage.schemaMain); bookViews.AppendChild(workbookView); - XmlElement calcPr = _workbookXml.CreateElement("calcPr", ExcelPackage.schemaMain); - calcPr.SetAttribute("calcId", "191029"); //Set the version of the calc engine to the latest known version. This will make sure that Excel does not downgrade the calculation engine and that new functions are supported. - wbElem.AppendChild(calcPr); - - XmlElement extLst = _workbookXml.CreateElement("extLst", ExcelPackage.schemaMain); - AddCalculationFeatures(extLst); - wbElem.AppendChild(extLst); - // save it to the package StreamWriter stream = new StreamWriter(partWorkbook.GetStream(FileMode.Create, FileAccess.Write)); _workbookXml.Save(stream); @@ -1399,7 +1391,8 @@ public ExcelCalcMode CalcMode SetXmlNodeString(CALC_MODE_PATH, "autoNoTable"); break; case ExcelCalcMode.Manual: - SetXmlNodeString(CALC_MODE_PATH, "manual"); + SetXmlNodeString(CALC_MODE_PATH, "manual"); + SetXmlNodeString("d:calcPr/@calcId", "191029"); break; default: SetXmlNodeString(CALC_MODE_PATH, "auto"); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index b73b4e4a04..b9e05fb849 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs @@ -17,6 +17,7 @@ Date Author Change using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; using OfficeOpenXml.FormulaParsing.Excel.Functions.Numeric; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.PivotBy; using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; using OfficeOpenXml.FormulaParsing.Excel.Functions.Finance; using OfficeOpenXml.FormulaParsing.Excel.Functions.Engineering; @@ -337,7 +338,8 @@ public BuiltInFunctions() // Reference and lookup Functions["address"] = new Address(); Functions["areas"] = new Areas(); - //Functions["groupby"] = new GroupBy(); //Will be released in next minor release. + Functions["groupby"] = new GroupBy(); + Functions["pivotby"] = new PivotBy(); Functions["hlookup"] = new HLookup(); Functions["vlookup"] = new VLookup(); Functions["xlookup"] = new Xlookup(); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ColEntry.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ColEntry.cs new file mode 100644 index 0000000000..2d582ae60a --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ColEntry.cs @@ -0,0 +1,26 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; +using System.Collections.Generic; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +{ + internal class ColEntry + { + public bool IsSubtotal { get; set; } + public string GroupKey { get; set; } + public List GroupLeaves { get; set; } + public LeafWithPath Leaf { get; set; } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index 8785bcd6ee..7a13f24319 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -34,7 +34,7 @@ internal class GroupBy : GroupByFunctionBase public override CompileResult Execute(IList arguments, ParsingContext context) { - if (!TryParseBaseArgs(arguments, out var args, out var error)) + if (!TryParseGroupByArgs(arguments, out var args, out var error)) return error; var groups = BuildGroups(args, context); groups = ApplySort(groups, args); @@ -43,112 +43,85 @@ public override CompileResult Execute(IList arguments, Parsing return CreateDynamicArrayResult(result, DataType.ExcelRange); } - // ------------------------------------------------------- - // Sorting - // ------------------------------------------------------- - private List ApplySort(List levels, GroupByBaseArgs args, int depth = 1) + private bool TryParseGroupByArgs(IList arguments, + out GroupByBaseArgs args, + out CompileResult error) { - if (args.SortOrders == null || args.SortOrders.All(s => s == 0)) return levels; + args = new GroupByBaseArgs(); + error = null; - if (args.FieldRelationship == FieldRelationship.Table) - { - var allRows = levels.SelectMany(l => CollectLeafRows(l)).ToList(); - allRows = SortRowsMulti(allRows, args); + if (!arguments[0].IsExcelRange) // TODO. Man kan skicka in enskilda celler i rowfields och values, sÃ¥ detta är fel. + return Fail(eErrorType.Value, out error); + args.RowFields = arguments[0].ValueAsRangeInfo; - var newLevelDict = new Dictionary(); - var newLevelOrder = new List(); - foreach (var row in allRows) - { - var topKey = (row.KeyParts[0]?.ToString() ?? string.Empty).ToLowerInvariant(); - if (!newLevelDict.TryGetValue(topKey, out var level)) - { - level = new GroupLevel { Key = row.KeyParts[0] }; - newLevelDict[topKey] = level; - newLevelOrder.Add(topKey); - } - level.Rows.Add(row); - } - return newLevelOrder.Select(k => newLevelDict[k]).ToList(); - } - else - { - var sortForThisLevel = args.SortOrders - .FirstOrDefault(s => Math.Abs(s) == depth); + if (!arguments[1].IsExcelRange) + return Fail(eErrorType.Value, out error); + args.Values = arguments[1].ValueAsRangeInfo; - bool hasSortForThisLevel = sortForThisLevel != 0; - bool desc = sortForThisLevel < 0; - bool sortOnAggregated = hasSortForThisLevel && Math.Abs(sortForThisLevel) > args.RowFields.Size.NumberOfCols; + if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) + return Fail(eErrorType.Value, out error); - if (hasSortForThisLevel) - { - levels = sortOnAggregated - ? (desc ? levels.OrderByDescending(l => l.SubtotalValue as IComparable, _comparer).ToList() - : levels.OrderBy(l => l.SubtotalValue as IComparable, _comparer).ToList()) - : (desc ? levels.OrderByDescending(l => l.Key as IComparable, _comparer).ToList() - : levels.OrderBy(l => l.Key as IComparable, _comparer).ToList()); - } + if (!TryParseFunctionArg(arguments[2], args.Functions, out LambdaCalculator function, out FunctionLayout layout)) + return Fail(eErrorType.Value, out error); - foreach (var level in levels) - { - if (!level.IsLeaf) - level.Children = ApplySort(level.Children, args, depth + 1); - else - level.Rows = SortRowsMulti(level.Rows, args); - } + args.Function = function; + args.FunctionLayout = layout; + + if (args.Functions.Count == 0) + return Fail(eErrorType.Value, out error); + + // field_headers (optional) + if (arguments.Count > 3 && arguments[3].Value != null) + { + var v = Convert.ToInt32(arguments[3].Value); + if (!Enum.IsDefined(typeof(FieldHeaders), v)) + return Fail(eErrorType.Value, out error); + args.Headers = (FieldHeaders)v; + } + else if (args.Functions.Count > 1) // In excel, if multiple functions are included, headers are by default displayed. + { + args.Headers = FieldHeaders.YesAndShow; + } - return levels; + // total_depth (optional) + if (arguments.Count > 4 && arguments[4].Value != null) + { + if (!TryParseTotalDepthArg(arguments[4], args.RowFields.Size.NumberOfCols, out int totalDepth)) + return Fail(eErrorType.Value, out error); + args.TotalDepth = totalDepth; } - } - private List SortRowsMulti(List rows, GroupByBaseArgs args) - { - if (rows == null || rows.Count == 0) return rows; + // sort_order (optional) + if (arguments.Count > 5 && arguments[5].Value != null) + { + args.SortOrders = ParseSortOrderArg(arguments[5]); + } - int nKeyCols = args.RowFields.Size.NumberOfCols; - IOrderedEnumerable ordered = null; + // filter_array (optional) + if (arguments.Count > 6 && arguments[6].IsExcelRange) + args.FilterArray = arguments[6].ValueAsRangeInfo; - foreach (var sortOrder in args.SortOrders) + // field_relationship (optional) + if (arguments.Count > 7 && arguments[7].Value != null) { - if (sortOrder == 0) continue; - bool desc = sortOrder < 0; - int col = Math.Abs(sortOrder); - bool sortOnAggregated = col > nKeyCols; - - // Capture loop variables - var capturedCol = col; - var capturedSortOnAggregated = sortOnAggregated; - - Func keySelector = capturedSortOnAggregated - ? (Func)(r => r.AggregatedValue) - : (r => r.KeyParts[Math.Min(capturedCol - 1, r.KeyParts.Length - 1)]); - - if (ordered == null) - ordered = desc - ? rows.OrderByDescending(keySelector, _comparer) - : rows.OrderBy(keySelector, _comparer); - else - ordered = desc - ? ordered.ThenByDescending(keySelector, _comparer) - : ordered.ThenBy(keySelector, _comparer); + var v = Convert.ToInt32(arguments[7].Value); + if (!Enum.IsDefined(typeof(FieldRelationship), v)) + return Fail(eErrorType.Value, out error); + if (v == (int)FieldRelationship.Table && Math.Abs(args.TotalDepth) > 1) + return Fail(eErrorType.Value, out error); + args.FieldRelationship = (FieldRelationship)v; } - return ordered?.ToList() ?? rows; - } - - private IEnumerable CollectLeafRows(GroupLevel level) - { - if (level.IsLeaf) - return level.Rows; - return level.Children.SelectMany(c => CollectLeafRows(c)); + return true; } - + // ------------------------------------------------------- // Build result // ------------------------------------------------------- private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, ParsingContext context) { - var resolvedHeaders = ResolveHeaders(args); + var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); bool showHeaders = resolvedHeaders == FieldHeaders.YesAndShow || resolvedHeaders == FieldHeaders.NoButGenerate; bool addFunctionHeaders = args.Functions.Count > 1; @@ -192,7 +165,7 @@ private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, if(addFunctionHeaders) { - var functionHeaders = ResolveFunctionHeaders(args); + var functionHeaders = ResolveFunctionHeaders(args.Functions); if(args.FunctionLayout == FunctionLayout.Horizontal) { for (int c = 0; c < nFunctions; c++) @@ -247,7 +220,7 @@ private int WriteRows( int depth) { var functionHeaders = args.FunctionLayout == FunctionLayout.Vertical - ? ResolveFunctionHeaders(args) + ? ResolveFunctionHeaders(args.Functions) : null; foreach (var level in levels) @@ -318,7 +291,7 @@ private int CountSubtotalRows(List levels, int subtotalDepth, int de private int WriteSubtotal(InMemoryRange result, int r, GroupLevel level, int nKeyCols, int nValCols, GroupByBaseArgs args) { - var functionHeaders = ResolveFunctionHeaders(args); + var functionHeaders = ResolveFunctionHeaders(args.Functions); if (args.FunctionLayout == FunctionLayout.Vertical) { for (int f = 0; f < args.Functions.Count; f++) @@ -347,7 +320,7 @@ private int WriteSubtotal(InMemoryRange result, int r, GroupLevel level, int nKe private int WriteGrandTotal(InMemoryRange result, int r, List levels, string label, int nKeyCols, int nValCols, GroupByBaseArgs args, ParsingContext context) { - var functionHeaders = ResolveFunctionHeaders(args); + var functionHeaders = ResolveFunctionHeaders(args.Functions); int nAllValCols = args.AllValuesInOrder.Count > 0 ? args.AllValuesInOrder[0].Length : 1; if (args.FunctionLayout == FunctionLayout.Vertical) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs index 770283a1bd..b86ec5475f 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs @@ -19,7 +19,6 @@ Date Author Change using System.Collections.Generic; using System.Linq; - namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions { internal abstract class GroupByFunctionBase : ExcelFunction @@ -29,9 +28,12 @@ internal abstract class GroupByFunctionBase : ExcelFunction protected const int TotalDepthNoTotals = 0; protected const int TotalDepthGrandOnly = 1; - protected List ResolveFunctionHeaders(GroupByBaseArgs args) + private InMemoryRange _allValuesRangeCache; + private List _allValuesCacheKey; + + protected List ResolveFunctionHeaders(List functions) { - var names = args.Functions + var names = functions .Select(f => f.EtaFunction != null ? f.EtaFunction.Name : "CUSTOM") .ToList(); @@ -51,38 +53,30 @@ protected List ResolveFunctionHeaders(GroupByBaseArgs args) // ------------------------------------------------------- // Argument parsing // ------------------------------------------------------- - protected bool TryParseBaseArgs( - IList arguments, - out GroupByBaseArgs args, - out CompileResult error) - { - args = new GroupByBaseArgs(); - error = null; - - if (!arguments[0].IsExcelRange) - return Fail(eErrorType.Value, out error); - args.RowFields = arguments[0].ValueAsRangeInfo; - if (!arguments[1].IsExcelRange) - return Fail(eErrorType.Value, out error); - args.Values = arguments[1].ValueAsRangeInfo; - - if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) - return Fail(eErrorType.Value, out error); + protected bool Fail(eErrorType err, out CompileResult error) + { + error = CompileResult.GetErrorResult(err); + return false; + } - if (arguments[2].DataType == DataType.LambdaCalculation) + protected bool TryParseFunctionArg(FunctionArgument funtionArgument, List functions, + out LambdaCalculator function, out FunctionLayout layout) + { + function = null; + layout = FunctionLayout.Single; + if (funtionArgument.DataType == DataType.LambdaCalculation) { // Single function - args.Function = arguments[2].Value as LambdaCalculator; - args.Functions.Add(args.Function); - args.FunctionLayout = FunctionLayout.Single; + function = funtionArgument.Value as LambdaCalculator; + functions.Add(function); } - else if (arguments[2].IsExcelRange) + else if (funtionArgument.IsExcelRange) { // Multiple functions via HSTACK or VSTACK - var range = arguments[2].ValueAsRangeInfo; + var range = funtionArgument.ValueAsRangeInfo; bool isHorizontal = range.Size.NumberOfRows == 1; - args.FunctionLayout = isHorizontal ? FunctionLayout.Horizontal : FunctionLayout.Vertical; + layout = isHorizontal ? FunctionLayout.Horizontal : FunctionLayout.Vertical; int count = isHorizontal ? range.Size.NumberOfCols : range.Size.NumberOfRows; for (int i = 0; i < count; i++) @@ -92,99 +86,61 @@ protected bool TryParseBaseArgs( : range.GetOffset(i, 0); if (cellVal is LambdaCalculator lc) - args.Functions.Add(lc); + functions.Add(lc); else - return Fail(eErrorType.Value, out error); + return false; } - args.Function = args.Functions[0]; + function = functions[0]; } else { - return Fail(eErrorType.Value, out error); - } - - if (args.Functions.Count == 0) - return Fail(eErrorType.Value, out error); - - // field_headers (optional) - if (arguments.Count > 3 && arguments[3].Value != null) - { - var v = Convert.ToInt32(arguments[3].Value); - if (!Enum.IsDefined(typeof(FieldHeaders), v)) - return Fail(eErrorType.Value, out error); - args.Headers = (FieldHeaders)v; - } - else if(args.Functions.Count > 1) // In excel, if multiple functions are included, headers are by default displayed. - { - args.Headers = FieldHeaders.YesAndShow; + return false; } + return true; + } - // total_depth (optional) - if (arguments.Count > 4 && arguments[4].Value != null) - { - var totalDepth = Convert.ToInt32(arguments[4].Value); - if (Math.Abs(totalDepth) > args.RowFields.Size.NumberOfCols) - return Fail(eErrorType.Value, out error); - args.TotalDepth = totalDepth; - } + protected bool TryParseTotalDepthArg(FunctionArgument arg, int numberOfCols, + out int totalDepth) + { + totalDepth = Convert.ToInt32(arg.Value); + if (Math.Abs(totalDepth) > numberOfCols) + return false; + return true; + } - // sort_order (optional) - if (arguments.Count > 5 && arguments[5].Value != null) + protected int[] ParseSortOrderArg(FunctionArgument arg) + { + if (arg.IsExcelRange) { - if (arguments[5].IsExcelRange) - { - var range = arguments[5].ValueAsRangeInfo; - bool isHorizontal = range.Size.NumberOfRows == 1; - int count = isHorizontal ? range.Size.NumberOfCols : range.Size.NumberOfRows; - args.SortOrders = new int[count]; - for (int i = 0; i < count; i++) - args.SortOrders[i] = Convert.ToInt32(isHorizontal - ? range.GetOffset(0, i) - : range.GetOffset(i, 0)); - } - else - { - args.SortOrders = new[] { Convert.ToInt32(arguments[5].Value) }; - } + var range = arg.ValueAsRangeInfo; + bool isHorizontal = range.Size.NumberOfRows == 1; + int count = isHorizontal ? range.Size.NumberOfCols : range.Size.NumberOfRows; + var result = new int[count]; + for (int i = 0; i < count; i++) + result[i] = Convert.ToInt32(isHorizontal + ? range.GetOffset(0, i) + : range.GetOffset(i, 0)); + return result; } - - // filter_array (optional) - if (arguments.Count > 6 && arguments[6].IsExcelRange) - args.FilterArray = arguments[6].ValueAsRangeInfo; - - // field_relationship (optional) - if (arguments.Count > 7 && arguments[7].Value != null) + else { - var v = Convert.ToInt32(arguments[7].Value); - if (!Enum.IsDefined(typeof(FieldRelationship), v)) - return Fail(eErrorType.Value, out error); - if (v == (int)FieldRelationship.Table && Math.Abs(args.TotalDepth) > 1) - return Fail(eErrorType.Value, out error); - args.FieldRelationship = (FieldRelationship)v; + return new[] { Convert.ToInt32(arg.Value) }; } - - return true; - } - - protected bool Fail(eErrorType err, out CompileResult error) - { - error = CompileResult.GetErrorResult(err); - return false; } // ------------------------------------------------------- // Header resolution // ------------------------------------------------------- - protected FieldHeaders ResolveHeaders(GroupByBaseArgs args) + protected FieldHeaders ResolveHeaders(FieldHeaders headers, IRangeInfo values) { - if (args.Headers != FieldHeaders.Missing) - return args.Headers; + if (headers != FieldHeaders.Missing) + return headers; - if (args.Values.Size.NumberOfRows < 2) + if (values.Size.NumberOfRows < 2) return FieldHeaders.No; - var first = args.Values.GetValue(0, 0); - var second = args.Values.GetValue(1, 0); + var first = values.GetOffset(0, 0); + var second = values.GetOffset(1, 0); bool firstIsText = first is string; bool secondIsNumber = second is double || second is int || second is long || second is float; @@ -199,7 +155,7 @@ protected FieldHeaders ResolveHeaders(GroupByBaseArgs args) // ------------------------------------------------------- protected List BuildGroups(GroupByBaseArgs args, ParsingContext context) { - var resolvedHeaders = ResolveHeaders(args); + var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow || resolvedHeaders == FieldHeaders.YesAndDontShow; bool multipleFunctions = args.Functions.Count > 1; @@ -272,6 +228,8 @@ protected List BuildGroups(GroupByBaseArgs args, ParsingContext cont return levels; } + + protected List BuildOrderedTree( Dictionary dict, List order) @@ -297,10 +255,7 @@ protected void AggregateTree(List levels, GroupByBaseArgs args, Pars var result = new object[nValCols]; for (int col = 0; col < nValCols; col++) { - var colValues = row.Values - .Select(v => new object[] { v[col] }) - .ToList(); - result[col] = Aggregate(f, colValues, context, + result[col] = Aggregate(f, row.Values, col, context, f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); } return result; @@ -315,10 +270,7 @@ protected void AggregateTree(List levels, GroupByBaseArgs args, Pars var result = new object[nValCols]; for (int col = 0; col < nValCols; col++) { - var colValues = allVals - .Select(v => new object[] { v[col] }) - .ToList(); - result[col] = Aggregate(f, colValues, context, + result[col] = Aggregate(f, allVals, col, context, f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); } return result; @@ -336,10 +288,7 @@ protected void AggregateTree(List levels, GroupByBaseArgs args, Pars var result = new object[nValCols]; for (int col = 0; col < nValCols; col++) { - var colValues = allVals - .Select(v => new object[] { v[col] }) - .ToList(); - result[col] = Aggregate(f, colValues, context, + result[col] = Aggregate(f, allVals, col, context, f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); } return result; @@ -356,30 +305,160 @@ protected List GetAllValues(GroupLevel level) return level.Children.SelectMany(c => GetAllValues(c)).ToList(); } - protected object Aggregate(LambdaCalculator calculator, List values, ParsingContext context, List allValues = null) + private static InMemoryRange BuildRangeFromList(List values) { int nRows = values.Count; int nCols = values.Count > 0 ? values[0].Length : 1; - var range = new InMemoryRange(nRows, (short)nCols); for (int row = 0; row < nRows; row++) for (int col = 0; col < nCols; col++) range.SetValue(row, col, values[row][col]); + return range; + } + + protected object Aggregate(LambdaCalculator calculator, List values, ParsingContext context, List allValues = null) + { + var range = BuildRangeFromList(values); calculator.BeginCalculation(); calculator.SetVariableValue(0, range, DataType.ExcelRange, context); - if(calculator.NumberOfVariables > 1 && allValues != null) + if (calculator.NumberOfVariables > 1 && allValues != null) { - int allRows = allValues.Count; - int allCols = allValues.Count > 0 ? allValues[0].Length : 1; - var allRange = new InMemoryRange(allRows, (short)allCols); - for (int row = 0; row < allRows; row++) - for (int col = 0; col < allCols; col++) - allRange.SetValue(row, col, allValues[row][col]); - calculator.SetVariableValue(1, allRange, DataType.ExcelRange, context); + // Cacha: om vi fÃ¥r samma allValues-referens som förra gÃ¥ngen, + // Ã¥teranvänd den InMemoryRange vi redan byggt. + if (!ReferenceEquals(_allValuesCacheKey, allValues)) + { + _allValuesRangeCache = BuildRangeFromList(allValues); + _allValuesCacheKey = allValues; + } + calculator.SetVariableValue(1, _allValuesRangeCache, DataType.ExcelRange, context); } return calculator.Execute(context).ResultValue; } + + protected object Aggregate(LambdaCalculator calculator, List values, int colIndex, ParsingContext context, List allValues = null) + { + var range = BuildRangeFromColumn(values, colIndex); + + calculator.BeginCalculation(); + calculator.SetVariableValue(0, range, DataType.ExcelRange, context); + + if (calculator.NumberOfVariables > 1 && allValues != null) + { + if (!ReferenceEquals(_allValuesCacheKey, allValues)) + { + _allValuesRangeCache = BuildRangeFromList(allValues); + _allValuesCacheKey = allValues; + } + calculator.SetVariableValue(1, _allValuesRangeCache, DataType.ExcelRange, context); + } + return calculator.Execute(context).ResultValue; + } + + protected List ApplySort(List levels, GroupByBaseArgs args, int depth = 1) + { + if (args.SortOrders == null || args.SortOrders.All(s => s == 0)) return levels; + + if (args.FieldRelationship == FieldRelationship.Table) + { + var allRows = levels.SelectMany(l => CollectLeafRows(l)).ToList(); + allRows = SortRowsMulti(allRows, args); + + var newLevelDict = new Dictionary(); + var newLevelOrder = new List(); + foreach (var row in allRows) + { + var topKey = (row.KeyParts[0]?.ToString() ?? string.Empty).ToLowerInvariant(); + if (!newLevelDict.TryGetValue(topKey, out var level)) + { + level = new GroupLevel { Key = row.KeyParts[0] }; + newLevelDict[topKey] = level; + newLevelOrder.Add(topKey); + } + level.Rows.Add(row); + } + return newLevelOrder.Select(k => newLevelDict[k]).ToList(); + } + else + { + var sortForThisLevel = args.SortOrders + .FirstOrDefault(s => Math.Abs(s) == depth); + + bool hasSortForThisLevel = sortForThisLevel != 0; + bool desc = sortForThisLevel < 0; + bool sortOnAggregated = hasSortForThisLevel && Math.Abs(sortForThisLevel) > args.RowFields.Size.NumberOfCols; + + if (hasSortForThisLevel) + { + levels = sortOnAggregated + ? (desc ? levels.OrderByDescending(l => l.SubtotalValue as IComparable, _comparer).ToList() + : levels.OrderBy(l => l.SubtotalValue as IComparable, _comparer).ToList()) + : (desc ? levels.OrderByDescending(l => l.Key as IComparable, _comparer).ToList() + : levels.OrderBy(l => l.Key as IComparable, _comparer).ToList()); + } + + foreach (var level in levels) + { + if (!level.IsLeaf) + level.Children = ApplySort(level.Children, args, depth + 1); + else + level.Rows = SortRowsMulti(level.Rows, args); + } + + return levels; + } + } + + private List SortRowsMulti(List rows, GroupByBaseArgs args) + { + if (rows == null || rows.Count == 0) return rows; + + int nKeyCols = args.RowFields.Size.NumberOfCols; + IOrderedEnumerable ordered = null; + + foreach (var sortOrder in args.SortOrders) + { + if (sortOrder == 0) continue; + bool desc = sortOrder < 0; + int col = Math.Abs(sortOrder); + bool sortOnAggregated = col > nKeyCols; + + // Capture loop variables + var capturedCol = col; + var capturedSortOnAggregated = sortOnAggregated; + + Func keySelector = capturedSortOnAggregated + ? (Func)(r => r.AggregatedValue) + : (r => r.KeyParts[Math.Min(capturedCol - 1, r.KeyParts.Length - 1)]); + + if (ordered == null) + ordered = desc + ? rows.OrderByDescending(keySelector, _comparer) + : rows.OrderBy(keySelector, _comparer); + else + ordered = desc + ? ordered.ThenByDescending(keySelector, _comparer) + : ordered.ThenBy(keySelector, _comparer); + } + + return ordered?.ToList() ?? rows; + } + + private static InMemoryRange BuildRangeFromColumn(List values, int colIndex) + { + int nRows = values.Count; + var range = new InMemoryRange(nRows, 1); + for (int row = 0; row < nRows; row++) + range.SetValue(row, 0, values[row][colIndex]); + return range; + } + + private IEnumerable CollectLeafRows(GroupLevel level) + { + if (level.IsLeaf) + return level.Rows; + return level.Children.SelectMany(c => CollectLeafRows(c)); + } } -} +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs index a9fc7b952f..9d0684bf31 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs @@ -10,6 +10,7 @@ Date Author Change ************************************************************************************************* 19/3/2026 EPPlus Software AB EPPlus v8.6 *************************************************************************************************/ +using System; using System.Collections.Generic; @@ -18,6 +19,7 @@ namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunc internal class GroupLevel { public object Key { get; set; } + public object[] KeyParts { get; set; } public List Children { get; set; } = new List(); public Dictionary ChildDict { get; set; } = null; public List ChildOrder { get; set; } = null; diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/LeafWithPath.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/LeafWithPath.cs new file mode 100644 index 0000000000..bf35231701 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/LeafWithPath.cs @@ -0,0 +1,29 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal class LeafWithPath + { + public GroupLevel Leaf { get; private set; } + public object[] Path { get; private set; } + public string PivotKey { get; private set; } + + public LeafWithPath(GroupLevel leaf, object[] path, string pivotKey) + { + Leaf = leaf; + Path = path; + PivotKey = pivotKey; + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs new file mode 100644 index 0000000000..b9c63d7ce1 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs @@ -0,0 +1,25 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal class PivotByArgs : GroupByBaseArgs + { + public IRangeInfo ColFields { get; set; } + public int RowTotalDepth { get; set; } = 0; + public int[] RowSortOrders { get; set; } = new[] { 1 }; + public int ColTotalDepth { get; set; } = 0; + public int[] ColSortOrders { get; set; } = new[] { 1 }; + public RelativeTo RelativeTo { get; set; } = RelativeTo.ColumnTotals; // Default + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs new file mode 100644 index 0000000000..6740eb5769 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs @@ -0,0 +1,24 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal enum RelativeTo + { + ColumnTotals = 0, + RowTotals = 1, + GrandTotals = 2, + ParentColTotal = 3, + ParentRowTotal = 4 + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/DataBuilding.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/DataBuilding.cs new file mode 100644 index 0000000000..7e4db6f5f1 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/DataBuilding.cs @@ -0,0 +1,200 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.PivotBy +{ + internal partial class PivotBy + { + private void BuildPivotData( + PivotByArgs args, + ParsingContext context, + out List rowLeaves, + out List colLeaves, + out Dictionary>> pivotMap) + { + var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); + bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow + || resolvedHeaders == FieldHeaders.YesAndDontShow; + int startRow = hasHeaders ? 1 : 0; + + int nRowKeyCols = args.RowFields.Size.NumberOfCols; + int nColKeyCols = args.ColFields.Size.NumberOfCols; + int nValCols = args.Values.Size.NumberOfCols; + + var rowLeafDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rowLeafOrder = new List(); + var colLeafDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var colLeafOrder = new List(); + pivotMap = new Dictionary>>(StringComparer.OrdinalIgnoreCase); + + int nRows = args.RowFields.Size.NumberOfRows; + + for (int r = startRow; r < nRows; r++) + { + if (args.FilterArray != null) + { + var fv = args.FilterArray.GetOffset(r, 0); + if (IsFilterFalsy(fv)) continue; + } + + var rowKeyParts = new object[nRowKeyCols]; + for (int c = 0; c < nRowKeyCols; c++) + rowKeyParts[c] = args.RowFields.GetOffset(r, c); + + var colKeyParts = new object[nColKeyCols]; + for (int c = 0; c < nColKeyCols; c++) + colKeyParts[c] = args.ColFields.GetOffset(r, c); + + var vals = new object[nValCols]; + for (int c = 0; c < nValCols; c++) + vals[c] = args.Values.GetOffset(r, c); + + // Radlöv + string rowKey = MakePivotKey(rowKeyParts); + if (!rowLeafDict.ContainsKey(rowKey)) + { + var leaf = new GroupLevel { Key = rowKeyParts[rowKeyParts.Length - 1] }; + rowLeafDict[rowKey] = new LeafWithPath(leaf, rowKeyParts, rowKey); + rowLeafOrder.Add(rowKey); + } + var rowLeafEntry = rowLeafDict[rowKey]; + GroupRow existingRow; + if (rowLeafEntry.Leaf.Rows.Count == 0) + { + existingRow = new GroupRow { KeyParts = rowKeyParts }; + rowLeafEntry.Leaf.Rows.Add(existingRow); + } + else + { + existingRow = rowLeafEntry.Leaf.Rows[0]; + } + existingRow.Values.Add(vals); + + string colKey = MakePivotKey(colKeyParts); + if (!colLeafDict.ContainsKey(colKey)) + { + var leaf = new GroupLevel { Key = colKeyParts[colKeyParts.Length - 1] }; + colLeafDict[colKey] = new LeafWithPath(leaf, colKeyParts, colKey); + colLeafOrder.Add(colKey); + } + + if (!pivotMap.TryGetValue(rowKey, out var colMap)) + { + colMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); + pivotMap[rowKey] = colMap; + } + if (!colMap.TryGetValue(colKey, out var cellVals)) + { + cellVals = new List(); + colMap[colKey] = cellVals; + } + cellVals.Add(vals); + args.AllValuesInOrder.Add(vals); + } + + rowLeaves = rowLeafOrder.Select(k => rowLeafDict[k]).ToList(); + colLeaves = colLeafOrder.Select(k => colLeafDict[k]).ToList(); + + foreach (var rl in rowLeaves) + AggregateLeaf(rl.Leaf, args, context); + } + + private void AggregateLeaf(GroupLevel leaf, PivotByArgs args, ParsingContext context) + { + var allVals = leaf.Rows.SelectMany(r => r.Values).ToList(); + leaf.SubtotalValues = args.Functions.Select(f => + { + int nValCols = allVals[0].Length; + var result = new object[nValCols]; + for (int col = 0; col < nValCols; col++) + { + result[col] = Aggregate(f, allVals, col, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + } + return result; + }).ToList(); + leaf.SubtotalValue = leaf.SubtotalValues[0][0]; + } + + + private List ApplyRowSort(List rowLeaves, PivotByArgs args) => + ApplyLeafSort(rowLeaves, args.RowSortOrders); + + private List ApplyColSort(List colLeaves, PivotByArgs args) => + ApplyLeafSort(colLeaves, args.ColSortOrders); + + private List ApplyLeafSort(List leaves, int[] sortOrders) + { + if (sortOrders == null || sortOrders.All(s => s == 0)) return leaves; + + IOrderedEnumerable ordered = null; + foreach (var sortOrder in sortOrders.Where(s => s != 0).OrderBy(s => Math.Abs(s))) + { + if (sortOrder == 0) continue; + bool desc = sortOrder < 0; + int col = Math.Abs(sortOrder) - 1; + var capturedCol = col; + + Func keySelector = lp => + capturedCol < lp.Path.Length ? lp.Path[capturedCol] : null; + + if (ordered == null) + ordered = desc + ? leaves.OrderByDescending(keySelector, _comparer) + : leaves.OrderBy(keySelector, _comparer); + else + ordered = desc + ? ordered.ThenByDescending(keySelector, _comparer) + : ordered.ThenBy(keySelector, _comparer); + } + + int maxDepth = leaves.Max(l => l.Path.Length); + int sortedDepth = sortOrders.Max(s => Math.Abs(s)); + for (int col = sortedDepth; col < maxDepth; col++) + { + var capturedCol = col; + Func keySelector = lp => + capturedCol < lp.Path.Length ? lp.Path[capturedCol] : null; + ordered = ordered.ThenBy(keySelector, _comparer); + } + + return ordered?.ToList() ?? leaves; + } + + /// + /// Returns true if a filter-array cell should EXCLUDE its row. + /// A cell excludes its row when it is FALSE or numerically zero, + /// regardless of whether the zero arrived as int, double, long, etc. + /// Non-numeric, non-bool, and null values keep the row. + /// + private static bool IsFilterFalsy(object fv) + { + if (fv is bool b) return !b; + if (fv == null) return false; + try + { + return Convert.ToDouble(fv, System.Globalization.CultureInfo.InvariantCulture) == 0.0; + } + catch + { + return false; + } + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs new file mode 100644 index 0000000000..8077d380a4 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs @@ -0,0 +1,124 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.PivotBy +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.LookupAndReference, + EPPlusVersion = "8.6", + Description = "Allows you to create a summary of your data via a formula. It supports grouping along two axis and aggregating the associated values.")] + internal partial class PivotBy : GroupByFunctionBase + { + public override int ArgumentMinLength => 3; + public override string NamespacePrefix => "_xlfn."; + public override bool ExecutesLambda => true; + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + if (!TryParsePivotByArgs(arguments, out var args, out var error)) + return error; + BuildPivotData(args, context, out var rowLeaves, out var colLeaves, out var pivotMap); + rowLeaves = ApplyRowSort(rowLeaves, args); + colLeaves = ApplyColSort(colLeaves, args); + var result = RenderPivot(rowLeaves, colLeaves, pivotMap, args, context); + + return CreateDynamicArrayResult(result, DataType.ExcelRange); + } + + protected bool TryParsePivotByArgs(IList arguments, + out PivotByArgs args, + out CompileResult error) + { + args = new PivotByArgs(); + error = null; + + args.RowFields = arguments[0].ValueAsRangeInfo; + args.ColFields = arguments[1].ValueAsRangeInfo; + args.Values = arguments[2].ValueAsRangeInfo; + + if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) + return Fail(eErrorType.Value, out error); + + if (!TryParseFunctionArg(arguments[3], args.Functions, out LambdaCalculator function, out FunctionLayout layout)) + return Fail(eErrorType.Value, out error); + + args.Function = function; + args.FunctionLayout = layout; + + if (arguments.Count > 4 && arguments[4].Value != null) + { + var v = Convert.ToInt32(arguments[4].Value); + if (!Enum.IsDefined(typeof(FieldHeaders), v)) + return Fail(eErrorType.Value, out error); + args.Headers = (FieldHeaders)v; + } + + // Total depth for rows (optional) + if (arguments.Count > 5 && arguments[5].Value != null) + { + if (!TryParseTotalDepthArg(arguments[5], args.RowFields.Size.NumberOfCols, out int rowTotalDepth)) + return Fail(eErrorType.Value, out error); + args.RowTotalDepth = rowTotalDepth; + } + else + { + args.RowTotalDepth = TotalDepthGrandOnly; // default = 1 + } + + // SortOrder for RowFields (optional) + if (arguments.Count > 6 && arguments[6].Value != null) + args.RowSortOrders = ParseSortOrderArg(arguments[6]); + + // TotalDepth for columns (optional, default = 1) + if (arguments.Count > 7 && arguments[7].Value != null) + { + if (!TryParseTotalDepthArg(arguments[7], args.ColFields.Size.NumberOfCols, out int colTotalDepth)) + return Fail(eErrorType.Value, out error); + args.ColTotalDepth = colTotalDepth; + } + else + { + args.ColTotalDepth = TotalDepthGrandOnly; // default = 1 + } + + // SortOrder for ColFields (optional) + if (arguments.Count > 8 && arguments[8].Value != null) + args.ColSortOrders = ParseSortOrderArg(arguments[8]); + + if (arguments.Count > 9 && arguments[9].IsExcelRange) + args.FilterArray = arguments[9].ValueAsRangeInfo; + + // RelativeTo (optional) + if (arguments.Count > 10 && arguments[10].Value != null) + { + var v = Convert.ToInt32(arguments[10].Value); + if (!Enum.IsDefined(typeof(RelativeTo), v)) + return Fail(eErrorType.Value, out error); + args.RelativeTo = (RelativeTo)v; + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs new file mode 100644 index 0000000000..6474e5884b --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs @@ -0,0 +1,971 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.PivotBy +{ + internal partial class PivotBy + { + private Dictionary> _colTotalsCache; + private Dictionary> _rowTotalsCache; + + private List ResolveRelativeToValues( + RelativeTo relativeTo, + LeafWithPath colLeaf, + List colLeaves, + string rowKey, + object[] rowPath, + List rowLeaves, + Dictionary>> pivotMap, + PivotByArgs args) + { + switch (relativeTo) + { +case RelativeTo.ColumnTotals: // 0 — default + { + string colKey = colLeaf.PivotKey; + + if (_colTotalsCache != null && _colTotalsCache.TryGetValue(colKey, out var cached)) + return cached; + + var list = pivotMap.Values + .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + ? cv + : Enumerable.Empty()) + .ToList(); + + if (_colTotalsCache != null) + _colTotalsCache[colKey] = list; + return list; + } +case RelativeTo.RowTotals: // 1 + { + if (_rowTotalsCache != null && _rowTotalsCache.TryGetValue(rowKey, out var cached)) + return cached; + + if (!pivotMap.TryGetValue(rowKey, out var colMap)) + return null; + + var list = colMap.Values + .SelectMany(vals => vals) + .ToList(); + + if (_rowTotalsCache != null) + _rowTotalsCache[rowKey] = list; + return list; + } + case RelativeTo.GrandTotals: // 2 + { + return args.AllValuesInOrder; + } + case RelativeTo.ParentColTotal: + { + if (!pivotMap.TryGetValue(rowKey, out var colMap)) + return null; + + if (colLeaf.Path.Length <= 1) + { + return colMap.Values + .SelectMany(vals => vals) + .ToList(); + } + + var parentPrefix = GetParentPrefix(colLeaf.Path); + var siblingLeaves = colLeaves + .Where(l => HasParentPrefix(l.Path, parentPrefix)) + .ToList(); + + return siblingLeaves + .Where(leaf => colMap.ContainsKey(leaf.PivotKey)) + .SelectMany(leaf => colMap[leaf.PivotKey]) + .ToList(); + } + case RelativeTo.ParentRowTotal: // 4 + { + string colKey = colLeaf.PivotKey; + + if (rowPath == null || rowPath.Length <= 1) + { + return pivotMap.Values + .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + ? cv + : Enumerable.Empty()) + .ToList(); + } + + var parentPrefix = GetParentPrefix(rowPath); + return rowLeaves + .Where(rl => HasParentPrefix(rl.Path, parentPrefix)) + .Select(rl => rl.PivotKey) + .Where(rk => pivotMap.ContainsKey(rk)) + .SelectMany(rk => pivotMap[rk].TryGetValue(colKey, out var cv) + ? cv + : Enumerable.Empty()) + .ToList(); + } + default: + return null; + } + } + + private static string[] GetParentPrefix(object[] path) + { + int parentDepth = path.Length - 1; + var prefix = new string[parentDepth]; + for (int i = 0; i < parentDepth; i++) + prefix[i] = path[i]?.ToString()?.ToLowerInvariant() ?? string.Empty; + return prefix; + } + + private static bool HasParentPrefix(object[] path, string[] parentPrefix) + { + if (path.Length < parentPrefix.Length) return false; + for (int i = 0; i < parentPrefix.Length; i++) + { + var pv = path[i]?.ToString()?.ToLowerInvariant() ?? string.Empty; + if (pv != parentPrefix[i]) return false; + } + return true; + } + + /// + /// Returns the row parent group's values across ALL columns. + /// Used by PERCENTOF when RelativeTo=ParentRowTotal in the row-total cell: + /// the denominator is the parent group's whole-row total summed over every column. + /// Returns null when the row has no real parent (single row level), letting the caller + /// fall back to a sensible default. + /// + private static List GetParentRowGroupAllValues( + object[] rowPath, + List rowLeaves) + { + if (rowPath == null || rowPath.Length <= 1) return null; + + var parentPrefix = GetParentPrefix(rowPath); + return rowLeaves + .Where(rl => HasParentPrefix(rl.Path, parentPrefix)) + .SelectMany(rl => rl.Leaf.Rows.SelectMany(row => row.Values)) + .ToList(); + } + + /// + /// Resolves the PERCENTOF denominator for a row-subtotal cell when RelativeTo=ParentColTotal. + /// The denominator must be the row group's values restricted to the column's parent group - + /// NOT all values for the row group across every column. + /// + /// For a column subtotal entry (which IS a parent group already), we use any of its + /// GroupLeaves to derive the parent prefix. For a regular leaf entry we use its own path. + /// When there is only one column level, no real parent exists and we fall back to + /// summing every column for the row group. + /// + private static List ResolveSubtotalParentColGroupValues( + ColEntry entry, + List colLeaves, + List groupRowKeys, + Dictionary>> pivotMap) + { + var colPath = entry.IsSubtotal + ? entry.GroupLeaves[0].Path + : entry.Leaf.Path; + + if (colPath.Length <= 1) + { + return groupRowKeys + .SelectMany(rk => pivotMap.TryGetValue(rk, out var cm) + ? cm.Values.SelectMany(v => v) + : Enumerable.Empty()) + .ToList(); + } + + var parentPrefix = GetParentPrefix(colPath); + var siblingColLeaves = colLeaves + .Where(cl => HasParentPrefix(cl.Path, parentPrefix)) + .ToList(); + + return groupRowKeys + .SelectMany(rk => pivotMap.TryGetValue(rk, out var cm) + ? siblingColLeaves + .Where(cl => cm.ContainsKey(cl.PivotKey)) + .SelectMany(cl => cm[cl.PivotKey]) + : Enumerable.Empty()) + .ToList(); + } + + /// + /// Returns the denominator values list for PERCENTOF in the row-total cell of a data row. + /// - RowTotals / ParentColTotal: the row's own values (cell / row total = 1) + /// - ParentRowTotal: the row parent group's values across all columns + /// - ColumnTotals / GrandTotals (default): the whole dataset + /// + private static List ResolveRowTotalCellRelativeToVals( + PivotByArgs args, + List rowAllVals, + object[] rowPath, + List rowLeaves) + { + switch (args.RelativeTo) + { + case RelativeTo.RowTotals: + case RelativeTo.ParentColTotal: + return rowAllVals; + case RelativeTo.ParentRowTotal: + return GetParentRowGroupAllValues(rowPath, rowLeaves) ?? args.AllValuesInOrder; + default: // ColumnTotals, GrandTotals + return args.AllValuesInOrder; + } + } + + private List ResolveRelativeToValuesForTotal( + RelativeTo relativeTo, + LeafWithPath colLeaf, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args) + { + switch (relativeTo) + { + case RelativeTo.RowTotals: + { + return pivotMap.Values + .SelectMany(colMap => colMap.Values.SelectMany(v => v)) + .ToList(); + } + case RelativeTo.GrandTotals: + { + return args.AllValuesInOrder; + } + case RelativeTo.ParentColTotal: + { + if (colLeaf.Path.Length <= 1) + { + return pivotMap.Values + .SelectMany(colMap => colMap.Values.SelectMany(v => v)) + .ToList(); + } + + var parentPrefix = GetParentPrefix(colLeaf.Path); + var siblingLeaves = colLeaves + .Where(l => HasParentPrefix(l.Path, parentPrefix)) + .ToList(); + + return pivotMap.Values + .SelectMany(cm => siblingLeaves + .Where(leaf => cm.ContainsKey(MakePivotKey(leaf.Path))) + .SelectMany(leaf => cm[leaf.PivotKey])) + .ToList(); + } + case RelativeTo.ParentRowTotal: + { + string colKey = colLeaf.PivotKey; + var colVals = pivotMap.Values + .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + ? cv + : Enumerable.Empty()) + .ToList(); + return colVals; + } + default: + return null; + } + } + + private static string MakePivotKey(object[] parts) => + string.Join("\u001F", parts.Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty).ToArray()); + + + + private InMemoryRange RenderPivot( + List rowLeaves, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context) + { + if (args.Functions.Any(f => f.EtaFunction?.Name == "PERCENTOF")) + { + _colTotalsCache = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _rowTotalsCache = new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + else + { + _colTotalsCache = null; + _rowTotalsCache = null; + } + + int nRowKeyCols = args.RowFields.Size.NumberOfCols; + int nColKeyRows = args.ColFields.Size.NumberOfCols; + int nRowLeaves = rowLeaves.Count; + int nFunctions = args.Functions.Count; + bool isVStack = args.FunctionLayout == FunctionLayout.Vertical; + bool isHStack = args.FunctionLayout == FunctionLayout.Horizontal; + + bool showRowTotal = args.RowTotalDepth != TotalDepthNoTotals; + bool showColTotal = args.ColTotalDepth != TotalDepthNoTotals; + bool rowTotalAtTop = args.RowTotalDepth < 0; + bool colTotalAtLeft = args.ColTotalDepth < 0; + int colSubtotalDepth = Math.Abs(args.ColTotalDepth); + bool showColSubtotals = colSubtotalDepth > 1; + int rowSubtotalDepth = Math.Abs(args.RowTotalDepth); + bool showRowSubtotals = rowSubtotalDepth > 1; + + var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); + bool showFieldHeaders = resolvedHeaders == FieldHeaders.YesAndShow; + int fieldHeaderRows = showFieldHeaders ? 1 : 0; + int headerDataRows = showFieldHeaders ? 1 : 0; + int functionHeaderRows = isHStack ? 1 : 0; + int functionNameCol = isVStack ? nRowKeyCols : -1; + int functionColOffset = isVStack ? 1 : 0; + + var colGroups = colLeaves + .GroupBy(l => l.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty) + .ToList(); + + var colEntries = new List(); + foreach (var group in colGroups) + { + var groupLeaves = group.ToList(); + if (showColSubtotals && colTotalAtLeft) + colEntries.Add(new ColEntry { IsSubtotal = true, GroupKey = group.Key, GroupLeaves = groupLeaves }); + foreach (var leaf in groupLeaves) + colEntries.Add(new ColEntry { IsSubtotal = false, Leaf = leaf }); + if (showColSubtotals && !colTotalAtLeft) + colEntries.Add(new ColEntry { IsSubtotal = true, GroupKey = group.Key, GroupLeaves = groupLeaves }); + } + + var rowGroups = rowLeaves + .GroupBy(l => string.Join("\u001F", + l.Path.Take(rowSubtotalDepth - 1) + .Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty) + .ToArray())) + .ToList(); + + int colsPerEntry = isHStack ? nFunctions : 1; + int nDataCols = colEntries.Count * colsPerEntry; + int nTotalCols = showColTotal ? colsPerEntry : 0; + int totalCols = nRowKeyCols + functionColOffset + nDataCols + nTotalCols; + + int rowsPerLeaf = isVStack ? nFunctions : 1; + int rowsPerTotal = isVStack ? nFunctions : 1; + int subtotalRowCount = showRowSubtotals ? rowGroups.Count * rowsPerLeaf : 0; + + int totalRows = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows + + nRowLeaves * rowsPerLeaf + + subtotalRowCount + + (showRowTotal ? rowsPerTotal : 0); + + int dataRowStart = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows + + (rowTotalAtTop ? rowsPerTotal : 0); + int grandTotalRow = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows + + (rowTotalAtTop ? 0 : nRowLeaves * rowsPerLeaf + subtotalRowCount); + + var result = new InMemoryRange(totalRows, (short)totalCols); + + int dataColStart = nRowKeyCols + functionColOffset; + int grandTotalCol = colTotalAtLeft ? dataColStart : dataColStart + nDataCols; + int colOffset = colTotalAtLeft ? colsPerEntry : 0; + + // --- Headerrows --- + if (showFieldHeaders) + { + for (int i = 0; i < totalCols; i++) + result.SetValue(0, i, string.Empty); + + for (int i = 0; i < args.ColFields.Size.NumberOfCols; i++) + result.SetValue(0, dataColStart + colOffset + i, args.ColFields.GetOffset(0, i)); + } + + for (int level = 0; level < nColKeyRows; level++) + { + int outputLevel = fieldHeaderRows + level; + + for (int i = 0; i < nRowKeyCols + functionColOffset; i++) + result.SetValue(outputLevel, i, string.Empty); + + int col = dataColStart + colOffset; + foreach (var entry in colEntries) + { + var val = entry.IsSubtotal + ? (level == 0 ? entry.GroupLeaves[0].Path[0] : (object)string.Empty) + : (level < entry.Leaf.Path.Length ? entry.Leaf.Path[level] : (object)string.Empty); + + for (int f = 0; f < colsPerEntry; f++) + { + result.SetValue(outputLevel, col, val); + col++; + } + } + + if (showColTotal) + { + string colTotalLabel = Math.Abs(args.ColTotalDepth) > 1 ? "Grand Total" : "Total"; + int totalCol = grandTotalCol; + for (int f = 0; f < colsPerEntry; f++) + { + result.SetValue(outputLevel, totalCol, level == 0 ? colTotalLabel : string.Empty); + totalCol++; + } + } + } + + // --- HSTACK --- + if (isHStack) + { + var functionNames = ResolveFunctionHeaders(args.Functions); + int functionHeaderRow = fieldHeaderRows + nColKeyRows; + + for (int i = 0; i < nRowKeyCols; i++) + result.SetValue(functionHeaderRow, i, string.Empty); + + int col = dataColStart + colOffset; + foreach (var entry in colEntries) + { + foreach (var name in functionNames) + { + result.SetValue(functionHeaderRow, col, name); + col++; + } + } + if (showColTotal) + { + int totalCol = grandTotalCol; + foreach (var name in functionNames) + { + result.SetValue(functionHeaderRow, totalCol, name); + totalCol++; + } + } + } + + // --- Header-datarad --- + if (showFieldHeaders) + { + int headerDataRow = fieldHeaderRows + nColKeyRows + functionHeaderRows; + + for (int i = 0; i < nRowKeyCols; i++) + result.SetValue(headerDataRow, i, args.RowFields.GetOffset(0, i)); + if (functionColOffset > 0) + result.SetValue(headerDataRow, nRowKeyCols, string.Empty); + + var headerValue = args.Values.GetOffset(0, 0); + int col = dataColStart + colOffset; + foreach (var entry in colEntries) + { + for (int fc = 0; fc < colsPerEntry; fc++) + { + result.SetValue(headerDataRow, col, entry.IsSubtotal ? string.Empty : headerValue); + col++; + } + } + + if (showColTotal) + { + int totalCol = grandTotalCol; + for (int fc = 0; fc < colsPerEntry; fc++) + { + result.SetValue(headerDataRow, totalCol, headerValue); + totalCol++; + } + } + } + + if (rowTotalAtTop && showRowTotal) + WriteGrandTotalRow(result, fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows, + colEntries, colLeaves, pivotMap, args, context, + nRowKeyCols, functionNameCol, dataColStart, colOffset, + grandTotalCol, showColTotal, colsPerEntry, isVStack, functionColOffset); + + // --- Datarows --- + var functionNames2 = ResolveFunctionHeaders(args.Functions); + int currentOutputRow = dataRowStart; + + foreach (var rowGroup in rowGroups) + { + var groupLeaves = rowGroup.ToList(); + var groupKeyParts = showRowSubtotals + ? groupLeaves[0].Path.Take(rowSubtotalDepth - 1).ToArray() + : null; + + if (showRowSubtotals && rowTotalAtTop) + { + for (int fi = 0; fi < rowsPerLeaf; fi++) + { + WriteRowSubtotalRow( + result, currentOutputRow + fi, + groupKeyParts, groupLeaves, + colEntries, colLeaves, + pivotMap, args, context, + nRowKeyCols, functionNameCol, + dataColStart, colOffset, + grandTotalCol, + showColTotal, isVStack, + args.Functions[fi], functionNames2[fi]); + } + currentOutputRow += rowsPerLeaf; + } + + foreach (var rowLeafEntry in groupLeaves) + { + var rowPath = rowLeafEntry.Path; + var rowLeaf = rowLeafEntry.Leaf; + string rowKey = rowLeafEntry.PivotKey; + + for (int fi = 0; fi < nFunctions; fi++) + { + int outputRow = currentOutputRow + fi; + var f = args.Functions[fi]; + + for (int k = 0; k < nRowKeyCols; k++) + result.SetValue(outputRow, k, k < rowPath.Length ? rowPath[k] : string.Empty); + + if (isVStack) + result.SetValue(outputRow, functionNameCol, functionNames2[fi]); + + int col = dataColStart + colOffset; + foreach (var entry in colEntries) + { + if (entry.IsSubtotal) + { + var groupVals = entry.GroupLeaves + .SelectMany(l => + { + var ck = l.PivotKey; + if (pivotMap.TryGetValue(rowKey, out var cm) && cm.TryGetValue(ck, out var cv)) + return cv; + return Enumerable.Empty(); + }).ToList(); + + if (isHStack) + { + foreach (var func in args.Functions) + { + object val = groupVals.Count > 0 + ? Aggregate(func, groupVals, context, + func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(outputRow, col, val ?? string.Empty); + col++; + } + } + else + { + object val = groupVals.Count > 0 + ? Aggregate(f, groupVals, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(outputRow, col, val ?? string.Empty); + col++; + } + } + else + { + string colKey = entry.Leaf.PivotKey; + + if (isHStack) + { + foreach (var func in args.Functions) + { + object aggregated = null; + if (pivotMap.TryGetValue(rowKey, out var colMap) && + colMap.TryGetValue(colKey, out var cellVals)) + { + var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" + ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, rowKey, rowPath, rowLeaves, pivotMap, args) ?? args.AllValuesInOrder + : args.AllValuesInOrder; + aggregated = Aggregate(func, cellVals, context, relativeToVals); + } + result.SetValue(outputRow, col, aggregated ?? string.Empty); + col++; + } + } + else + { + object aggregated = null; + if (pivotMap.TryGetValue(rowKey, out var colMap) && + colMap.TryGetValue(colKey, out var cellVals)) + { + var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" + ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, rowKey, rowPath, rowLeaves, pivotMap, args) ?? args.AllValuesInOrder + : args.AllValuesInOrder; + aggregated = Aggregate(f, cellVals, context, relativeToVals); + } + result.SetValue(outputRow, col, aggregated ?? string.Empty); + col++; + } + } + } + + if (showColTotal) + { + var rowAllVals = rowLeaf.Rows.SelectMany(r => r.Values).ToList(); + int totalCol = grandTotalCol; + if (isHStack) + { + foreach (var func in args.Functions) + { + var relVals = args.AllValuesInOrder; + if (func.EtaFunction?.Name == "PERCENTOF") + relVals = ResolveRowTotalCellRelativeToVals(args, rowAllVals, rowPath, rowLeaves); + var totalVal = Aggregate(func, rowAllVals, context, relVals); + result.SetValue(outputRow, totalCol, totalVal ?? string.Empty); + totalCol++; + } + } + else + { + var relVals = args.AllValuesInOrder; + if (f.EtaFunction?.Name == "PERCENTOF") + relVals = ResolveRowTotalCellRelativeToVals(args, rowAllVals, rowPath, rowLeaves); + var totalVal = Aggregate(f, rowAllVals, context, relVals); + result.SetValue(outputRow, totalCol, totalVal ?? string.Empty); + } + } + + if (isHStack) break; + } + + currentOutputRow += rowsPerLeaf; + } + + if (showRowSubtotals && !rowTotalAtTop) + { + for (int fi = 0; fi < rowsPerLeaf; fi++) + { + WriteRowSubtotalRow( + result, currentOutputRow + fi, + groupKeyParts, groupLeaves, + colEntries, colLeaves, + pivotMap, args, context, + nRowKeyCols, functionNameCol, + dataColStart, colOffset, + grandTotalCol, + showColTotal, isVStack, + args.Functions[fi], functionNames2[fi]); + } + currentOutputRow += rowsPerLeaf; + } + } + + if (!rowTotalAtTop && showRowTotal) + WriteGrandTotalRow(result, grandTotalRow, colEntries, colLeaves, pivotMap, args, context, + nRowKeyCols, functionNameCol, dataColStart, colOffset, + grandTotalCol, showColTotal, colsPerEntry, isVStack, functionColOffset); + + return result; + } + + + private void WriteRowSubtotalRow( + InMemoryRange result, + int outputRow, + object[] groupKeyParts, + List groupLeaves, + List colEntries, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context, + int nRowKeyCols, + int functionNameCol, + int dataColStart, + int colOffset, + int grandTotalCol, + bool showColTotal, + bool isVStack, + LambdaCalculator f, + string functionName) + { + for (int k = 0; k < nRowKeyCols; k++) + result.SetValue(outputRow, k, k < groupKeyParts.Length ? groupKeyParts[k] : string.Empty); + + if (isVStack) + result.SetValue(outputRow, functionNameCol, functionName); + + var groupRowKeys = groupLeaves.Select(l => l.PivotKey).ToList(); + + int col = dataColStart + colOffset; + foreach (var entry in colEntries) + { + List cellVals; + if (entry.IsSubtotal) + { + cellVals = groupRowKeys + .SelectMany(rk => + { + if (!pivotMap.TryGetValue(rk, out var cm)) return Enumerable.Empty(); + return entry.GroupLeaves + .SelectMany(gl => cm.TryGetValue(gl.PivotKey, out var cv) + ? cv : Enumerable.Empty()); + }).ToList(); + } + else + { + string colKey = entry.Leaf.PivotKey; + cellVals = groupRowKeys + .SelectMany(rk => + { + if (!pivotMap.TryGetValue(rk, out var cm)) return Enumerable.Empty(); + return cm.TryGetValue(colKey, out var cv) ? cv : Enumerable.Empty(); + }).ToList(); + } + + if (cellVals.Count > 0) + { + List relativeToVals = null; + if (f.EtaFunction?.Name == "PERCENTOF") + { + var effectiveRelativeTo = args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + + relativeToVals = effectiveRelativeTo switch + { + RelativeTo.RowTotals => + groupRowKeys + .SelectMany(rk => pivotMap.TryGetValue(rk, out var cm) + ? cm.Values.SelectMany(v => v) + : Enumerable.Empty()) + .ToList(), + RelativeTo.GrandTotals => args.AllValuesInOrder, + RelativeTo.ParentColTotal => + ResolveSubtotalParentColGroupValues(entry, colLeaves, groupRowKeys, pivotMap), + RelativeTo.ParentRowTotal => + entry.IsSubtotal + ? entry.GroupLeaves + .SelectMany(gl => + pivotMap.Values.SelectMany(cm => + cm.TryGetValue(gl.PivotKey, out var cv) + ? cv : Enumerable.Empty())) + .ToList() + : pivotMap.Values + .SelectMany(cm => cm.TryGetValue(entry.Leaf.PivotKey, out var cv2) + ? cv2 : Enumerable.Empty()) + .ToList(), + _ => args.AllValuesInOrder + }; + } + + var val = Aggregate(f, cellVals, context, relativeToVals); + result.SetValue(outputRow, col, val ?? string.Empty); + } + else + { + result.SetValue(outputRow, col, string.Empty); + } + col++; + } + + if (showColTotal) + { + var allGroupVals = groupRowKeys + .SelectMany(rk => pivotMap.TryGetValue(rk, out var cm) + ? cm.Values.SelectMany(v => v) + : Enumerable.Empty()) + .ToList(); + + List relVals = null; + if (f.EtaFunction?.Name == "PERCENTOF") + { + var effectiveRelativeTo = args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + relVals = effectiveRelativeTo == RelativeTo.GrandTotals || effectiveRelativeTo == RelativeTo.ParentRowTotal + ? args.AllValuesInOrder + : allGroupVals; + } + + var totalVal = allGroupVals.Count > 0 ? Aggregate(f, allGroupVals, context, relVals) : null; + result.SetValue(outputRow, grandTotalCol, totalVal ?? string.Empty); + } + } + + + private void WriteGrandTotalRow( + InMemoryRange result, + int startRow, + List colEntries, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context, + int nRowKeyCols, + int functionNameCol, + int dataColStart, + int colOffset, + int grandTotalCol, + bool showColTotal, + int colsPerEntry, + bool isVStack, + int functionColOffset) + { + var functionNames = ResolveFunctionHeaders(args.Functions); + int nFunctions = args.Functions.Count; + int rowsPerTotal = isVStack ? nFunctions : 1; + string rowTotalLabel = Math.Abs(args.RowTotalDepth) > 1 ? "Grand Total" : "Total"; + + for (int fi = 0; fi < rowsPerTotal; fi++) + { + int r = startRow + fi; + var f = args.Functions[fi]; + + result.SetValue(r, 0, rowTotalLabel); + for (int c = 1; c < nRowKeyCols + functionColOffset; c++) + result.SetValue(r, c, string.Empty); + + if (isVStack) + result.SetValue(r, functionNameCol, functionNames[fi]); + + int col = dataColStart + colOffset; + foreach (var entry in colEntries) + { + if (entry.IsSubtotal) + { + var groupVals = entry.GroupLeaves + .SelectMany(l => + { + var ck = l.PivotKey; + return pivotMap.Values + .SelectMany(cm => cm.TryGetValue(ck, out var cv) + ? cv : Enumerable.Empty()); + }).ToList(); + + if (isVStack) + { + object val = groupVals.Count > 0 + ? Aggregate(f, groupVals, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(r, col, val ?? string.Empty); + col++; + } + else + { + foreach (var func in args.Functions) + { + object val = groupVals.Count > 0 + ? Aggregate(func, groupVals, context, + func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(r, col, val ?? string.Empty); + col++; + } + } + } + else + { + string colKey = entry.Leaf.PivotKey; + var allValsForCol = pivotMap.Values + .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + ? cv : Enumerable.Empty()) + .ToList(); + + if (isVStack) + { + object grandVal = null; + if (allValsForCol.Count > 0) + { + var effectiveRelativeTo = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" + ? ResolveRelativeToValuesForTotal(effectiveRelativeTo, entry.Leaf, colLeaves, pivotMap, args) ?? args.AllValuesInOrder + : args.AllValuesInOrder; + grandVal = Aggregate(f, allValsForCol, context, relativeToVals); + } + result.SetValue(r, col, grandVal ?? string.Empty); + col++; + } + else + { + foreach (var func in args.Functions) + { + object grandVal = null; + if (allValsForCol.Count > 0) + { + var effectiveRelativeTo = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" + ? ResolveRelativeToValuesForTotal(effectiveRelativeTo, entry.Leaf, colLeaves, pivotMap, args) ?? args.AllValuesInOrder + : args.AllValuesInOrder; + grandVal = Aggregate(func, allValsForCol, context, relativeToVals); + } + result.SetValue(r, col, grandVal ?? string.Empty); + col++; + } + } + } + } + + if (showColTotal) + { + var allVals = args.AllValuesInOrder.Select(v => new object[] { v[0] }).ToList(); + int totalCol = grandTotalCol; + if (isVStack) + { + var effectiveRelativeTo = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var cornerRelVals = f.EtaFunction?.Name == "PERCENTOF" + ? (effectiveRelativeTo == RelativeTo.ParentRowTotal || effectiveRelativeTo == RelativeTo.GrandTotals + ? allVals : args.AllValuesInOrder) + : args.AllValuesInOrder; + var cornerVal = Aggregate(f, allVals, context, cornerRelVals); + result.SetValue(r, totalCol, cornerVal ?? string.Empty); + } + else + { + foreach (var func in args.Functions) + { + var effectiveRelativeTo = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var cornerRelVals = func.EtaFunction?.Name == "PERCENTOF" + ? (effectiveRelativeTo == RelativeTo.ParentRowTotal || effectiveRelativeTo == RelativeTo.GrandTotals + ? allVals : args.AllValuesInOrder) + : args.AllValuesInOrder; + var cornerVal = Aggregate(func, allVals, context, cornerRelVals); + result.SetValue(r, totalCol, cornerVal ?? string.Empty); + totalCol++; + } + } + } + + if (!isVStack) break; + } + } + + private List CollectLeavesWithPath(List levels, object[] parentPath) + { + var result = new List(); + foreach (var level in levels) + { + var path = new object[parentPath.Length + 1]; + for (int i = 0; i < parentPath.Length; i++) + path[i] = parentPath[i]; + path[parentPath.Length] = level.Key; + + if (level.IsLeaf) + result.Add(new LeafWithPath(level, path, MakePivotKey(path))); + else + result.AddRange(CollectLeavesWithPath(level.Children, path)); + } + return result; + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs new file mode 100644 index 0000000000..ee1311751b --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -0,0 +1,2078 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup +{ + [TestClass] + public class PivotByTests : TestBase + { + [TestMethod] + public void BasicPivotBy() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Joe"; + s.Cells["A2"].Value = "Anna"; + s.Cells["C1"].Value = "Bertil"; + s.Cells["C2"].Value = "Joe"; + s.Cells["B1"].Value = 1; + s.Cells["B2"].Value = 2; + s.Cells["D1"].Formula = "PIVOTBY(A1:A2,C1:C2, B1:B2, _xleta.SUM)"; + s.Calculate(); + s.Workbook.FullCalcOnLoad = false; + s.Workbook.CalcMode = ExcelCalcMode.Manual; + + // Rubrikrad + Assert.AreEqual("Bertil", s.Cells["E1"].Value); + Assert.AreEqual("Joe", s.Cells["F1"].Value); + Assert.AreEqual("Total", s.Cells["G1"].Value); + + // Anna-rad + Assert.AreEqual("Anna", s.Cells["D2"].Value); + Assert.AreEqual(2d, s.Cells["F2"].Value); + Assert.AreEqual(2d, s.Cells["G2"].Value); + + // Joe-rad + Assert.AreEqual("Joe", s.Cells["D3"].Value); + Assert.AreEqual(1d, s.Cells["E3"].Value); + Assert.AreEqual(1d, s.Cells["G3"].Value); + + // Total-rad + Assert.AreEqual("Total", s.Cells["D4"].Value); + Assert.AreEqual(1d, s.Cells["E4"].Value); + Assert.AreEqual(2d, s.Cells["F4"].Value); + Assert.AreEqual(3d, s.Cells["G4"].Value); + + SaveWorkbook("BasicPivotBy.xlsx", package); + } + } + + [TestMethod] + public void PivotBy() + { + using (var package = new ExcelPackage()) + { + SwitchToCulture("en-US"); + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Stockholm"; + s.Cells["A2"].Value = "Linköping"; + s.Cells["A3"].Value = "Örebro"; + s.Cells["B1"].Value = 2026; + s.Cells["B2"].Value = 2026; + s.Cells["B3"].Value = 2025; + s.Cells["C1"].Value = "Q2"; + s.Cells["C2"].Value = "Q1"; + s.Cells["C3"].Value = "Q2"; + s.Cells["D1"].Value = 34543; + s.Cells["D2"].Value = 43265; + s.Cells["D3"].Value = 75461; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3, _xleta.SUM)"; + s.Calculate(); + + // Rubrikrad 1 (Ã¥r) + Assert.AreEqual(2025, s.Cells["F1"].Value); + Assert.AreEqual(2026, s.Cells["G1"].Value); + Assert.AreEqual(2026, s.Cells["H1"].Value); + Assert.AreEqual("Total", s.Cells["I1"].Value); + + // Rubrikrad 2 (kvartal) + Assert.AreEqual("Q2", s.Cells["F2"].Value); + Assert.AreEqual("Q1", s.Cells["G2"].Value); + Assert.AreEqual("Q2", s.Cells["H2"].Value); + + // Linköping + Assert.AreEqual("Linköping", s.Cells["E3"].Value); + Assert.AreEqual(43265d, s.Cells["G3"].Value); + Assert.AreEqual(43265d, s.Cells["I3"].Value); + + // Örebro + Assert.AreEqual("Örebro", s.Cells["E4"].Value); + Assert.AreEqual(75461d, s.Cells["F4"].Value); + Assert.AreEqual(75461d, s.Cells["I4"].Value); + + // Stockholm + Assert.AreEqual("Stockholm", s.Cells["E5"].Value); + Assert.AreEqual(34543d, s.Cells["H5"].Value); + Assert.AreEqual(34543d, s.Cells["I5"].Value); + + // Total-rad + Assert.AreEqual("Total", s.Cells["E6"].Value); + Assert.AreEqual(75461d, s.Cells["F6"].Value); + Assert.AreEqual(43265d, s.Cells["G6"].Value); + Assert.AreEqual(34543d, s.Cells["H6"].Value); + Assert.AreEqual(153269d, s.Cells["I6"].Value); + SwitchBackToCurrentCulture(); + } + } + + [TestMethod] + public void PivotBySortOrder() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["A4"].Value = "B"; + s.Cells["A5"].Value = "C"; + s.Cells["A6"].Value = "C"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["B4"].Value = "Y"; + s.Cells["B5"].Value = "X"; + s.Cells["B6"].Value = "Y"; + s.Cells["C1"].Value = 2; + s.Cells["C2"].Value = 4; + s.Cells["C3"].Value = 1; + s.Cells["C4"].Value = 5; + s.Cells["C5"].Value = 7; + s.Cells["C6"].Value = 4; + s.Cells["D1"].Formula = "PIVOTBY(A1:A6,B1:B6,C1:C6,_xleta.SUM,,,-1,,-1)"; + s.Calculate(); + + Assert.AreEqual("Y", s.Cells["E1"].Value); + Assert.AreEqual("X", s.Cells["F1"].Value); + Assert.AreEqual("Total", s.Cells["G1"].Value); + + // C + Assert.AreEqual("C", s.Cells["D2"].Value); + Assert.AreEqual(4d, s.Cells["E2"].Value); + Assert.AreEqual(7d, s.Cells["F2"].Value); + Assert.AreEqual(11d, s.Cells["G2"].Value); + + // B + Assert.AreEqual("B", s.Cells["D3"].Value); + Assert.AreEqual(5d, s.Cells["E3"].Value); + Assert.AreEqual(1d, s.Cells["F3"].Value); + Assert.AreEqual(6d, s.Cells["G3"].Value); + + // A + Assert.AreEqual("A", s.Cells["D4"].Value); + Assert.AreEqual(4d, s.Cells["E4"].Value); + Assert.AreEqual(2d, s.Cells["F4"].Value); + Assert.AreEqual(6d, s.Cells["G4"].Value); + + // Total + Assert.AreEqual("Total", s.Cells["D5"].Value); + Assert.AreEqual(13d, s.Cells["E5"].Value); + Assert.AreEqual(10d, s.Cells["F5"].Value); + Assert.AreEqual(23d, s.Cells["G5"].Value); + } + } + + [TestMethod] + public void PivotBySubTotalsIncluded() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3,_xleta.SUM,,,,2)"; + s.Calculate(); + + // Rubrikrad 1 + Assert.AreEqual("X", s.Cells["F1"].Value); + Assert.AreEqual("X", s.Cells["G1"].Value); + Assert.AreEqual("X", s.Cells["H1"].Value); + Assert.AreEqual("Y", s.Cells["I1"].Value); + Assert.AreEqual("Y", s.Cells["J1"].Value); + Assert.AreEqual("Grand Total", s.Cells["K1"].Value); + + // Rubrikrad 2 + Assert.AreEqual("I", s.Cells["F2"].Value); + Assert.AreEqual("O", s.Cells["G2"].Value); + Assert.AreEqual("I", s.Cells["I2"].Value); + + // A + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual(2d, s.Cells["G3"].Value); + Assert.AreEqual(2d, s.Cells["H3"].Value); + Assert.AreEqual(4d, s.Cells["I3"].Value); + Assert.AreEqual(4d, s.Cells["J3"].Value); + Assert.AreEqual(6d, s.Cells["K3"].Value); + + // B + Assert.AreEqual("B", s.Cells["E4"].Value); + Assert.AreEqual(1d, s.Cells["F4"].Value); + Assert.AreEqual(1d, s.Cells["H4"].Value); + Assert.AreEqual(1d, s.Cells["K4"].Value); + + // Total + Assert.AreEqual("Total", s.Cells["E5"].Value); + Assert.AreEqual(1d, s.Cells["F5"].Value); + Assert.AreEqual(2d, s.Cells["G5"].Value); + Assert.AreEqual(3d, s.Cells["H5"].Value); + Assert.AreEqual(4d, s.Cells["I5"].Value); + Assert.AreEqual(4d, s.Cells["J5"].Value); + Assert.AreEqual(7d, s.Cells["K5"].Value); + } + } + [TestMethod] + public void PivotByGrandTotalsRows() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:B3,C1:C3,D1:D3,_xleta.PERCENTOF,,2)"; + s.Calculate(); + + // Rubrikrad + Assert.AreEqual("I", s.Cells["G1"].Value); + Assert.AreEqual("O", s.Cells["H1"].Value); + Assert.AreEqual("Total", s.Cells["I1"].Value); + + // A | X + Assert.AreEqual("A", s.Cells["E2"].Value); + Assert.AreEqual("X", s.Cells["F2"].Value); + Assert.AreEqual(1d, s.Cells["H2"].Value); + Assert.AreEqual(0.28571429, System.Math.Round((double)s.Cells["I2"].Value, 8)); + + // A | Y + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual("Y", s.Cells["F3"].Value); + Assert.AreEqual(0.8d, s.Cells["G3"].Value); + Assert.AreEqual(0.57142857, System.Math.Round((double)s.Cells["I3"].Value, 8)); + + // A subtotal + Assert.AreEqual("A", s.Cells["E4"].Value); + Assert.AreEqual(0.8d, s.Cells["G4"].Value); + Assert.AreEqual(1d, s.Cells["H4"].Value); + Assert.AreEqual(0.85714286, System.Math.Round((double)s.Cells["I4"].Value, 8)); + + // B | X + Assert.AreEqual("B", s.Cells["E5"].Value); + Assert.AreEqual("X", s.Cells["F5"].Value); + Assert.AreEqual(0.2d, s.Cells["G5"].Value); + Assert.AreEqual(0.14285714, System.Math.Round((double)s.Cells["I5"].Value, 8)); + + // B subtotal + Assert.AreEqual("B", s.Cells["E6"].Value); + Assert.AreEqual(0.2d, s.Cells["G6"].Value); + Assert.AreEqual(0.14285714, System.Math.Round((double)s.Cells["I6"].Value, 8)); + + // Grand Total + Assert.AreEqual("Grand Total", s.Cells["E7"].Value); + Assert.AreEqual(1d, s.Cells["G7"].Value); + Assert.AreEqual(1d, s.Cells["H7"].Value); + Assert.AreEqual(1d, s.Cells["I7"].Value); + } + } + + [TestMethod] + public void PivotByRelativeTo() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:B3,C1:C3,D1:D3,_xleta.PERCENTOF,,,,,,,3)"; + s.Calculate(); + + Assert.AreEqual(0.714285714d, System.Math.Round((double)s.Cells["G5"].Value, 9)); + Assert.AreEqual(0.285714286d, System.Math.Round((double)s.Cells["H5"].Value, 9)); + } + } + + [TestMethod] + public void PivotByRelativeTo2() + { + using (var package = new ExcelPackage()) + { + SwitchToCulture("en-US"); + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Stockholm"; + s.Cells["A2"].Value = "Linköping"; + s.Cells["A3"].Value = "Örebro"; + s.Cells["A4"].Value = "Stockholm"; + s.Cells["A5"].Value = "Örebro"; + s.Cells["A6"].Value = "Linköping"; + + s.Cells["B1"].Value = "2026"; + s.Cells["B2"].Value = "2026"; + s.Cells["B3"].Value = "2025"; + s.Cells["B4"].Value = "2025"; + s.Cells["B5"].Value = "2025"; + s.Cells["B6"].Value = "2024"; + + s.Cells["C1"].Value = "Q2"; + s.Cells["C2"].Value = "Q1"; + s.Cells["C3"].Value = "Q2"; + s.Cells["C4"].Value = "Q3"; + s.Cells["C5"].Value = "Q4"; + s.Cells["C6"].Value = "Q2"; + + s.Cells["D1"].Value = 34543; + s.Cells["D2"].Value = 43265; + s.Cells["D3"].Value = 75461; + s.Cells["D4"].Value = 4536; + s.Cells["D5"].Value = 64312; + s.Cells["D6"].Value = 64531; + + s.Cells["E1"].Formula = "PIVOTBY(A1:A6,B1:C6,D1:D6,_xleta.PERCENTOF,,,,,,,3)"; + s.Calculate(); + SwitchBackToCurrentCulture(); + } + } + + [TestMethod] + public void PivotByRelativeTo3() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3,_xleta.PERCENTOF,,,,,,,4)"; + s.Calculate(); + } + } + + [TestMethod] + public void PivotByHeaders() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:B3,D1:D3, _xleta.SUM, 3)"; + s.Calculate(); + + Assert.AreEqual("X", s.Cells["F2"].Value); + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual("A", s.Cells["E4"].Value); + } + } + + [TestMethod] + public void PivotByCustomLambdaWithHstack() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:B3,D1:D3, HSTACK(_xleta.COUNT, LAMBDA(x, SUM(x *2/3)), LAMBDA(x, SUM(x *2)) ),3)"; + s.Calculate(); + + } + } + + [TestMethod] + public void PivotByCustomLambdaWithVstack() + { + using (var package = new ExcelPackage()) + { + package.Workbook.CalcMode = ExcelCalcMode.Manual; + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:B3,D1:D3, VSTACK(_xleta.COUNT, LAMBDA(x, SUM(x *2/3)), LAMBDA(x, SUM(x *2)) ),3)"; + s.Calculate(); + Assert.AreNotEqual(0d, s.Cells["E1"].Value); + Assert.AreNotEqual(0d, s.Cells["F1"].Value); + Assert.AreNotEqual(0d, s.Cells["E2"].Value); + Assert.AreNotEqual(0d, s.Cells["F2"].Value); + + // SaveWorkbook("PivotByCustomLambda.xlsx", package); + } + } + + [TestMethod] + public void PivotBySortOrderArray() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:B3,C1:C3,D1:D3, _xleta.SUM,,, {-1,-2})"; + s.Calculate(); + + // Rubrikrad + Assert.AreEqual("I", s.Cells["G1"].Value); + Assert.AreEqual("O", s.Cells["H1"].Value); + Assert.AreEqual("Total", s.Cells["I1"].Value); + + // B X + Assert.AreEqual("B", s.Cells["E2"].Value); + Assert.AreEqual("X", s.Cells["F2"].Value); + Assert.AreEqual(1d, s.Cells["G2"].Value); + Assert.AreEqual(1d, s.Cells["I2"].Value); + + // A Y + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual("Y", s.Cells["F3"].Value); + Assert.AreEqual(4d, s.Cells["G3"].Value); + Assert.AreEqual(4d, s.Cells["I3"].Value); + + // A X + Assert.AreEqual("A", s.Cells["E4"].Value); + Assert.AreEqual("X", s.Cells["F4"].Value); + Assert.AreEqual(2d, s.Cells["H4"].Value); + Assert.AreEqual(2d, s.Cells["I4"].Value); + + // Total + Assert.AreEqual("Total", s.Cells["E5"].Value); + Assert.AreEqual(5d, s.Cells["G5"].Value); + Assert.AreEqual(2d, s.Cells["H5"].Value); + Assert.AreEqual(7d, s.Cells["I5"].Value); + } + } + + [TestMethod] + + public void PivotByTemplateTest() + { + using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) + { + var sheet = package.Workbook.Worksheets[1]; + + sheet.Cells["B15"].Formula = "PIVOTBY('FCL V'!C6:C2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.SUM)"; + sheet.Calculate(); + + Assert.AreEqual("Albania", sheet.Cells["C15"].Value); + SaveAndCleanup(package); + } + } + + + [TestMethod] + public void PivotByTemplateTest2() + { + using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) + { + var sheet = package.Workbook.Worksheets[3]; + package.Workbook.CalcMode = ExcelCalcMode.Manual; + + sheet.Cells["B26"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.PERCENTOF,,2,,,,,3)"; + sheet.Cells["B26"].Calculate(); + + Assert.AreEqual("Albania", sheet.Cells["D26"].Value); + Assert.AreEqual(0.021928991, System.Math.Round((double)sheet.Cells["G27"].Value), 8); + + SaveAndCleanup(package); + } + } + + + [TestMethod] + public void PivotBySortOrderPercentOf() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["A4"].Value = "B"; + s.Cells["A5"].Value = "C"; + s.Cells["A6"].Value = "C"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["B4"].Value = "Y"; + s.Cells["B5"].Value = "X"; + s.Cells["B6"].Value = "Y"; + s.Cells["C1"].Value = 2; + s.Cells["C2"].Value = 4; + s.Cells["C3"].Value = 1; + s.Cells["C4"].Value = 5; + s.Cells["C5"].Value = 7; + s.Cells["C6"].Value = 4; + s.Cells["D1"].Formula = "PIVOTBY(A1:A6,B1:B6,C1:C6,_xleta.PERCENTOF)"; + s.Calculate(); + Assert.AreEqual(0.2, s.Cells["E2"].Value); + } + } + + [TestMethod] + public void PivotBy_ParentColTotal_ThreeColumnLevels_UsesFullParentPrefix() + { + // Verifies that PERCENTOF with RelativeTo=ParentColTotal (3) uses the full + // parent path (Year, Quarter) as the denominator group when there are three + // column levels, not just the top-level Year. + // + // Current implementation in ResolveRelativeToValues builds parentKey from + // Path[0] only: + // var parentKey = colLeaf.Path[0]?.ToString()?.ToLowerInvariant() ?? ""; + // For three levels this groups by Year alone, producing 10/60 = 0.1667 for + // the (R1, 2025/Q1/Jan) cell. Excel returns 10/(10+20) = 0.3333, grouping + // by the full parent prefix (Year, Quarter). + // + // Data: R1 has Jan=10 and Feb=20 under 2025/Q1, and Apr=30 under 2025/Q2. + // R2 has Jan=40 under 2026/Q1. + // + // Verified in Excel (sv-SE) 2026-05-21: + // Spill range: G1:L6 + // Column header layout (H..L): + // Year: 2025 2025 2025 2026 Total + // Quarter: Q1 Q1 Q2 Q1 - + // Month: Feb Jan Apr Jan - + // R1 row (row 4): G4="R1", H4=0.666667, I4=0.333333, J4=1, K4=, L4=1 + // + // Expected to FAIL against current EPPlus implementation. + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "R1"; + s.Cells["A2"].Value = "R1"; + s.Cells["A3"].Value = "R1"; + s.Cells["A4"].Value = "R2"; + s.Cells["B1"].Value = 2025; + s.Cells["B2"].Value = 2025; + s.Cells["B3"].Value = 2025; + s.Cells["B4"].Value = 2026; + s.Cells["C1"].Value = "Q1"; + s.Cells["C2"].Value = "Q1"; + s.Cells["C3"].Value = "Q2"; + s.Cells["C4"].Value = "Q1"; + s.Cells["D1"].Value = "Jan"; + s.Cells["D2"].Value = "Feb"; + s.Cells["D3"].Value = "Apr"; + s.Cells["D4"].Value = "Jan"; + s.Cells["E1"].Value = 10; + s.Cells["E2"].Value = 20; + s.Cells["E3"].Value = 30; + s.Cells["E4"].Value = 40; + + s.Cells["G1"].Formula = "PIVOTBY(A1:A4, B1:D4, E1:E4, _xleta.PERCENTOF,,,,,,,3)"; + s.Calculate(); + + // --- Column header layout (rows 1-3) --- + Assert.AreEqual(2025, s.Cells["H1"].Value, "H1 Year"); + Assert.AreEqual(2025, s.Cells["I1"].Value, "I1 Year"); + Assert.AreEqual(2025, s.Cells["J1"].Value, "J1 Year"); + Assert.AreEqual(2026, s.Cells["K1"].Value, "K1 Year"); + Assert.AreEqual("Total", s.Cells["L1"].Value, "L1 Total label"); + + Assert.AreEqual("Q1", s.Cells["H2"].Value, "H2 Quarter"); + Assert.AreEqual("Q1", s.Cells["I2"].Value, "I2 Quarter"); + Assert.AreEqual("Q2", s.Cells["J2"].Value, "J2 Quarter"); + Assert.AreEqual("Q1", s.Cells["K2"].Value, "K2 Quarter"); + + Assert.AreEqual("Feb", s.Cells["H3"].Value, "H3 Month"); + Assert.AreEqual("Jan", s.Cells["I3"].Value, "I3 Month"); + Assert.AreEqual("Apr", s.Cells["J3"].Value, "J3 Month"); + Assert.AreEqual("Jan", s.Cells["K3"].Value, "K3 Month"); + + // --- R1 row (row 4) --- + Assert.AreEqual("R1", s.Cells["G4"].Value, "Row label for R1 should be at G4."); + + // Feb under (2025, Q1): 20 / (10 + 20) = 0.6667 + Assert.AreEqual(0.666667, System.Math.Round((double)s.Cells["H4"].Value, 6), + "Feb under (2025,Q1) - denominator must be parent group (Year,Quarter), not Year alone."); + + // Jan under (2025, Q1): 10 / (10 + 20) = 0.3333 + Assert.AreEqual(0.333333, System.Math.Round((double)s.Cells["I4"].Value, 6), + "Jan under (2025,Q1) - denominator must be parent group (Year,Quarter), not Year alone."); + + // Apr under (2025, Q2): 30 / 30 = 1.0 (sole value in its parent group) + Assert.AreEqual(1d, (double)s.Cells["J4"].Value, + "Apr under (2025,Q2) is the only value in its parent group, so percent = 1."); + + // K4: R1 has no value under (2026, Q1, Jan) - cell should be blank. + Assert.IsTrue( + s.Cells["K4"].Value == null || (s.Cells["K4"].Value as string) == string.Empty, + "K4 should be blank since R1 has no value under (2026,Q1,Jan). Got: " + (s.Cells["K4"].Value ?? "null")); + + // L4: Row total for R1 = 60/60 = 1. + Assert.AreEqual(1d, (double)s.Cells["L4"].Value, + "Row total for R1 should be 1 (whole / whole)."); + } + } + + + + [TestMethod] + public void PivotBy_FilterArray_NumericValuesProducedByExpression() + { + // BuildPivotData filters rows with: + // if (fv is bool b && !b) continue; + // if (fv is int i && i == 0) continue; + // Excel ranges typically yield doubles for numeric cells, and an expression + // like (range > x) * 1 produces double 0.0 / 1.0 - matching neither check. + // The suspicion is that EPPlus silently fails to exclude rows here. + // + // Verified in Excel (sv-SE) 2026-05-21: this formula behaves IDENTICALLY + // to the boolean version above - row A is excluded, grand total = 50. + // Spill range: J1:L4 + // Row 1: "" "X" "Total" + // Row 2: "B" 20 20 + // Row 3: "C" 30 30 + // Row 4: "Total" 50 50 + // + // Expected to FAIL against current EPPlus implementation if the bool/int + // check is the only filter path. If it passes, the bug doesn't exist and + // the test serves as a regression guard. + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "B"; + s.Cells["A3"].Value = "C"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "X"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = 10; + s.Cells["C2"].Value = 20; + s.Cells["C3"].Value = 30; + // Multiplying by 1 coerces booleans to numerics: {0; 1; 1} as doubles. + s.Cells["J1"].Formula = "PIVOTBY(A1:A3, B1:B3, C1:C3, _xleta.SUM,,,,,, (C1:C3>15)*1)"; + s.Calculate(); + + Assert.AreEqual("X", s.Cells["K1"].Value); + Assert.AreEqual("Total", s.Cells["L1"].Value); + + Assert.AreEqual("B", s.Cells["J2"].Value); + Assert.AreEqual(20d, s.Cells["K2"].Value); + Assert.AreEqual(20d, s.Cells["L2"].Value); + + Assert.AreEqual("C", s.Cells["J3"].Value); + Assert.AreEqual(30d, s.Cells["K3"].Value); + Assert.AreEqual(30d, s.Cells["L3"].Value); + + Assert.AreEqual("Total", s.Cells["J4"].Value, + "Numeric filter array should also exclude row A - grand total should be 50, not 60."); + Assert.AreEqual(50d, s.Cells["K4"].Value, + "X column total should be 50 (B+C), not 60 (A+B+C)."); + Assert.AreEqual(50d, s.Cells["L4"].Value, + "Grand total should be 50, not 60."); + } + } + + [TestMethod] + public void PivotBy_NegativeRowTotalDepth_GrandTotalAtTop() + { + // RowTotalDepth = -1 puts the row grand total ABOVE the data rows. + // This exercises the rowTotalAtTop branch in RenderPivot, which is + // untested in the existing test suite. + // + // Verified in Excel (sv-SE) 2026-05-21: + // Spill range: E1:G5 + // Row 1: "" "X" "Total" + // Row 2: "Total" 6 6 + // Row 3: "A" 1 1 + // Row 4: "B" 2 2 + // Row 5: "C" 3 3 + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "B"; + s.Cells["A3"].Value = "C"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "X"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = 1; + s.Cells["C2"].Value = 2; + s.Cells["C3"].Value = 3; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3, B1:B3, C1:C3, _xleta.SUM,,-1)"; + s.Calculate(); + + // --- Header row --- + Assert.AreEqual("X", s.Cells["F1"].Value); + Assert.AreEqual("Total", s.Cells["G1"].Value); + + // --- Grand total at TOP (row 2) --- + Assert.AreEqual("Total", s.Cells["E2"].Value, + "With RowTotalDepth=-1 the grand total row must appear above the data rows."); + Assert.AreEqual(6d, s.Cells["F2"].Value); + Assert.AreEqual(6d, s.Cells["G2"].Value); + + // --- Data rows (3-5) --- + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual(1d, s.Cells["F3"].Value); + Assert.AreEqual(1d, s.Cells["G3"].Value); + + Assert.AreEqual("B", s.Cells["E4"].Value); + Assert.AreEqual(2d, s.Cells["F4"].Value); + Assert.AreEqual(2d, s.Cells["G4"].Value); + + Assert.AreEqual("C", s.Cells["E5"].Value); + Assert.AreEqual(3d, s.Cells["F5"].Value); + Assert.AreEqual(3d, s.Cells["G5"].Value); + } + } + + [TestMethod] + public void PivotBy_NegativeColTotalDepth_GrandTotalAtLeft() + { + // ColTotalDepth = -1 puts the column grand total LEFT of the data columns. + // This exercises the colTotalAtLeft branch in RenderPivot, which is + // untested in the existing test suite. + // + // Verified in Excel (sv-SE) 2026-05-21: + // Spill range: E1:H5 + // Row 1: "" "Total" "X" "Y" + // Row 2: "A" 1 1 + // Row 3: "B" 2 2 + // Row 4: "C" 3 3 + // Row 5: "Total" 6 4 2 + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "B"; + s.Cells["A3"].Value = "C"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = 1; + s.Cells["C2"].Value = 2; + s.Cells["C3"].Value = 3; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3, B1:B3, C1:C3, _xleta.SUM,,,,-1)"; + s.Calculate(); + + // --- Header row: Total column comes FIRST (left of X and Y) --- + Assert.AreEqual("Total", s.Cells["F1"].Value, + "With ColTotalDepth=-1 the column total must appear leftmost."); + Assert.AreEqual("X", s.Cells["G1"].Value); + Assert.AreEqual("Y", s.Cells["H1"].Value); + + // --- Row A (only has X) --- + Assert.AreEqual("A", s.Cells["E2"].Value); + Assert.AreEqual(1d, s.Cells["F2"].Value, "Row A total at leftmost column."); + Assert.AreEqual(1d, s.Cells["G2"].Value, "A under X."); + Assert.IsTrue( + s.Cells["H2"].Value == null || (s.Cells["H2"].Value as string) == string.Empty, + "A has no Y value - H2 should be blank."); + + // --- Row B (only has Y) --- + Assert.AreEqual("B", s.Cells["E3"].Value); + Assert.AreEqual(2d, s.Cells["F3"].Value, "Row B total at leftmost column."); + Assert.IsTrue( + s.Cells["G3"].Value == null || (s.Cells["G3"].Value as string) == string.Empty, + "B has no X value - G3 should be blank."); + Assert.AreEqual(2d, s.Cells["H3"].Value, "B under Y."); + + // --- Row C (only has X) --- + Assert.AreEqual("C", s.Cells["E4"].Value); + Assert.AreEqual(3d, s.Cells["F4"].Value, "Row C total at leftmost column."); + Assert.AreEqual(3d, s.Cells["G4"].Value, "C under X."); + + // --- Grand total row --- + Assert.AreEqual("Total", s.Cells["E5"].Value); + Assert.AreEqual(6d, s.Cells["F5"].Value, "Grand total of grand totals."); + Assert.AreEqual(4d, s.Cells["G5"].Value, "X column total = A+C."); + Assert.AreEqual(2d, s.Cells["H5"].Value, "Y column total = B."); + } + } + + [TestMethod] + public void PivotBy_WithoutFunctionArgument_ShouldReturnErrorNotThrow() + { + // ArgumentMinLength is declared as 3 in PivotBy, but TryParsePivotByArgs + // accesses arguments[3] unconditionally: + // if (!TryParseFunctionArg(arguments[3], ...)) + // + // With only 3 arguments this throws IndexOutOfRangeException instead of + // returning a proper error value. Excel itself rejects the formula at + // parse time with "Too few arguments", confirming that 4 is the real + // minimum. EPPlus should either bump ArgumentMinLength to 4 or guard + // the access and return #VALUE!. + // + // Verified in Excel (sv-SE) 2026-05-21: formula rejected at parse time + // ("Too few arguments"). + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["C1"].Value = 1; + s.Cells["C2"].Value = 2; + s.Cells["D1"].Formula = "PIVOTBY(A1:A2, B1:B2, C1:C2)"; + + // Must not throw - the calculation should complete and produce an + // error value in the cell. + try + { + s.Calculate(); + } + catch (Exception ex) + { + Assert.Fail( + "PIVOTBY with too few arguments must not throw - it should " + + "produce an error value. Got: " + ex.GetType().Name + + ": " + ex.Message); + } + + var value = s.Cells["D1"].Value; + Assert.IsInstanceOfType( + value, + typeof(ExcelErrorValue), + "Expected an ExcelErrorValue (e.g. #VALUE!) when function argument " + + "is omitted, got: " + (value == null ? "null" : value.GetType().Name + " = " + value)); + } + } + + [TestMethod] + public void PivotBy_ParentRowTotal_TwoRowLevels_UsesFullParentPrefix() + { + // Verifies that PERCENTOF with RelativeTo=ParentRowTotal (4) uses the full + // parent row prefix (Country) as the denominator group when there are two + // row levels - not all rows in the dataset. + // + // This is the row-axis twin of the ParentColTotal bug. ResolveRelativeToValues + // for case ParentRowTotal currently returns every row's values for the column + // without filtering by the row's parent prefix: + // return pivotMap.Values + // .SelectMany(cm => cm.TryGetValue(colKey, out var cv) ? cv : ...) + // .ToList(); + // + // Data: + // Sweden/Stockholm/X = 10 + // Sweden/Linköping/X = 20 + // Norway/Oslo/X = 30 + // Sweden parent total under X = 30, Norway = 30. + // Stockholm/X expected = 10/30 = 0.3333, Linköping/X = 20/30 = 0.6667, + // Oslo/X = 30/30 = 1. + // + // Current implementation: denominator = [10,20,30] = 60, so Stockholm/X = 0.1667 (wrong). + // + // Verified in Excel (sv-SE) 2026-05-22: + // Spill range: F1:I5 + // Row 1: "" "" "X" "Total" + // Row 2: "Norway" "Oslo" 1 1 + // Row 3: "Sweden" "Linköping" 0.666666667 0.666666667 + // Row 4: "Sweden" "Stockholm" 0.333333333 0.333333333 + // Row 5: "Total" "" 1 1 + // + // Expected to FAIL against current EPPlus implementation. The fix should + // mirror the ParentColTotal fix: build a parent prefix from the row key + // (everything except the last level) and filter pivotMap entries whose + // row keys start with that prefix. + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Sweden"; + s.Cells["A2"].Value = "Sweden"; + s.Cells["A3"].Value = "Norway"; + s.Cells["B1"].Value = "Stockholm"; + s.Cells["B2"].Value = "Linköping"; + s.Cells["B3"].Value = "Oslo"; + s.Cells["C1"].Value = "X"; + s.Cells["C2"].Value = "X"; + s.Cells["C3"].Value = "X"; + s.Cells["D1"].Value = 10; + s.Cells["D2"].Value = 20; + s.Cells["D3"].Value = 30; + + s.Cells["F1"].Formula = "PIVOTBY(A1:B3, C1:C3, D1:D3, _xleta.PERCENTOF,,,,,,,4)"; + s.Calculate(); + + // --- Header row --- + Assert.AreEqual("X", s.Cells["H1"].Value, "H1 data column header."); + Assert.AreEqual("Total", s.Cells["I1"].Value, "I1 grand total header."); + + // --- Norway / Oslo (row 2) --- + Assert.AreEqual("Norway", s.Cells["F2"].Value); + Assert.AreEqual("Oslo", s.Cells["G2"].Value); + Assert.AreEqual(1d, (double)s.Cells["H2"].Value, + "Oslo/X is the only row in the Norway parent group, so 30/30 = 1."); + Assert.AreEqual(1d, (double)s.Cells["I2"].Value, "Row total = 1."); + + // --- Sweden / Linköping (row 3) --- + Assert.AreEqual("Sweden", s.Cells["F3"].Value); + Assert.AreEqual("Linköping", s.Cells["G3"].Value); + Assert.AreEqual(0.666666667d, System.Math.Round((double)s.Cells["H3"].Value, 9), + "Linköping/X = 20/(10+20) = 0.6667. Denominator must be Sweden parent group, not full column."); + Assert.AreEqual(0.666666667d, System.Math.Round((double)s.Cells["I3"].Value, 9), + "Row total for Linköping = 20/30 = 0.6667 (same denominator semantics)."); + + // --- Sweden / Stockholm (row 4) --- + Assert.AreEqual("Sweden", s.Cells["F4"].Value); + Assert.AreEqual("Stockholm", s.Cells["G4"].Value); + Assert.AreEqual(0.333333333d, System.Math.Round((double)s.Cells["H4"].Value, 9), + "Stockholm/X = 10/(10+20) = 0.3333. Denominator must be Sweden parent group, not full column."); + Assert.AreEqual(0.333333333d, System.Math.Round((double)s.Cells["I4"].Value, 9), + "Row total for Stockholm = 10/30 = 0.3333 (same denominator semantics)."); + + // --- Grand total row --- + Assert.AreEqual("Total", s.Cells["F5"].Value); + Assert.AreEqual(1d, (double)s.Cells["H5"].Value, "Column total = sum/sum = 1."); + Assert.AreEqual(1d, (double)s.Cells["I5"].Value, "Corner total = 1."); + } + } + + [TestMethod] + public void PivotBy_YesAndShowHeaders_FieldNameRowLayout() + + { + + // Verifies the field-name row produced when FieldHeaders=3 (YesAndShow). + + // The existing PivotByHeaders test uses mode 3 but never asserts on the + + // field-name row itself, leaving this layout unverified. + + // + + // Verified in Excel (sv-SE) 2026-05-22: + + // Spill range: E1:H6 + + // Row 1: NULL "Quarter" NULL NULL <- col field name(s) + + // Row 2: NULL "Q1" "Q2" "Total" <- col key values + + // Row 3: "City" "Revenue" "Revenue" "Revenue" <- row field + values field + + // Row 4: "Linköping" NULL 200 200 + + // Row 5: "Stockholm" 100 NULL 100 + + // Row 6: "Total" 100 200 300 + + // + + // Key Excel rules this test pins down: + + // - Column field name ("Quarter") goes in the FIRST data column (F1), + + // not the row-key column, not the total column, not repeated. + + // - Row field name ("City") goes in the row-key column on the + + // header-data row, not on the field-name row. + + // - Values field name ("Revenue") repeats across every data column + + // INCLUDING the Total column. + + using (var package = new ExcelPackage()) + + { + + var s = package.Workbook.Worksheets.Add("test"); + + s.Cells["A1"].Value = "City"; + + s.Cells["A2"].Value = "Stockholm"; + + s.Cells["A3"].Value = "Linköping"; + + s.Cells["B1"].Value = "Quarter"; + + s.Cells["B2"].Value = "Q1"; + + s.Cells["B3"].Value = "Q2"; + + s.Cells["C1"].Value = "Revenue"; + + s.Cells["C2"].Value = 100; + + s.Cells["C3"].Value = 200; + + s.Cells["E1"].Formula = "PIVOTBY(A1:A3, B1:B3, C1:C3, _xleta.SUM, 3)"; + + s.Calculate(); + + // --- Row 1: column field name row --- + + Assert.IsTrue( + + s.Cells["E1"].Value == null || (s.Cells["E1"].Value as string) == string.Empty, + + "E1 should be blank (above row-key column). Got: " + (s.Cells["E1"].Value ?? "null")); + + Assert.AreEqual("Quarter", s.Cells["F1"].Value, + + "Column field name 'Quarter' must appear in the first data column (F1)."); + + Assert.IsTrue( + + s.Cells["G1"].Value == null || (s.Cells["G1"].Value as string) == string.Empty, + + "G1 should be blank - 'Quarter' is not repeated across data columns. Got: " + (s.Cells["G1"].Value ?? "null")); + + Assert.IsTrue( + + s.Cells["H1"].Value == null || (s.Cells["H1"].Value as string) == string.Empty, + + "H1 (above Total column) should be blank. Got: " + (s.Cells["H1"].Value ?? "null")); + + // --- Row 2: column key values + Total label --- + + Assert.IsTrue( + + s.Cells["E2"].Value == null || (s.Cells["E2"].Value as string) == string.Empty, + + "E2 should be blank."); + + Assert.AreEqual("Q1", s.Cells["F2"].Value); + + Assert.AreEqual("Q2", s.Cells["G2"].Value); + + Assert.AreEqual("Total", s.Cells["H2"].Value); + + // --- Row 3: row field name + values field name across data columns --- + + Assert.AreEqual("City", s.Cells["E3"].Value, + + "Row field name 'City' goes in the row-key column on the header-data row."); + + Assert.AreEqual("Revenue", s.Cells["F3"].Value, + + "Values field name should repeat in each data column."); + + Assert.AreEqual("Revenue", s.Cells["G3"].Value); + + Assert.AreEqual("Revenue", s.Cells["H3"].Value, + + "Values field name should appear above Total column too."); + + // --- Row 4: Linköping (sorted before Stockholm) --- + + Assert.AreEqual("Linköping", s.Cells["E4"].Value); + + Assert.IsTrue( + + s.Cells["F4"].Value == null || (s.Cells["F4"].Value as string) == string.Empty, + + "Linköping has no Q1 value - F4 should be blank."); + + Assert.AreEqual(200d, s.Cells["G4"].Value); + + Assert.AreEqual(200d, s.Cells["H4"].Value); + + // --- Row 5: Stockholm --- + + Assert.AreEqual("Stockholm", s.Cells["E5"].Value); + + Assert.AreEqual(100d, s.Cells["F5"].Value); + + Assert.IsTrue( + + s.Cells["G5"].Value == null || (s.Cells["G5"].Value as string) == string.Empty, + + "Stockholm has no Q2 value - G5 should be blank."); + + Assert.AreEqual(100d, s.Cells["H5"].Value); + + // --- Row 6: Total --- + + Assert.AreEqual("Total", s.Cells["E6"].Value); + + Assert.AreEqual(100d, s.Cells["F6"].Value); + + Assert.AreEqual(200d, s.Cells["G6"].Value); + + Assert.AreEqual(300d, s.Cells["H6"].Value); + + } + + } + + [TestMethod] + public void PivotBy_ParentColTotal_RowSubtotal_RestrictsDenominatorToParentColGroup() + { + // Verifies that PERCENTOF with RelativeTo=ParentColTotal (3) and row subtotals + // enabled (RowTotalDepth=2) restricts the denominator of a subtotal cell to + // the parent column group - NOT to the row group's total across ALL columns. + // + // Current implementation in WriteRowSubtotalRow has: + // RelativeTo.ParentColTotal => + // groupRowKeys + // .SelectMany(rk => pivotMap.TryGetValue(rk, out var cm) + // ? cm.Values.SelectMany(v => v) + // : Enumerable.Empty()) + // .ToList(), + // This sums ALL columns for the row group, ignoring the column's parent group. + // For Sweden's subtotal under (2025,Q1) the denominator becomes 100 + // (Stockholm 10+20+40 + Göteborg 30), giving 40/100 = 0.4. + // Excel restricts to (2025, *) values: 60, giving 40/60 = 0.6667. + // + // The bug is invisible when a row group has data within only one parent + // column group (Norway has only 2025 data), since the two denominators + // coincide. Sweden spans both 2025 and 2026, which is what exposes the bug. + // + // Data: + // Sweden/Stockholm/2025/Q1 = 10 + // Sweden/Stockholm/2025/Q2 = 20 + // Sweden/Göteborg/2025/Q1 = 30 + // Sweden/Stockholm/2026/Q1 = 40 + // Norway/Oslo/2025/Q1 = 5 + // Norway/Oslo/2025/Q2 = 15 + // + // Expected parent-col-group denominators for subtotals: + // Sweden in 2025: 10+20+30 = 60 + // Sweden in 2026: 40 + // Norway in 2025: 5+15 = 20 + // + // Verified in Excel (sv-SE) 2026-05-22: + // Spill range: G1:L8 + // Row 1: "" "" 2025 2025 2026 "Total" + // Row 2: "" "" "Q1" "Q2" "Q1" "" + // Row 3: "Norway" "Oslo" 0.25 0.75 1 + // Row 4: "Norway" "" 0.25 0.75 1 + // Row 5: "Sweden" "Göteborg" 1 1 + // Row 6: "Sweden" "Stockholm" 0.3333333 0.6666667 1 1 + // Row 7: "Sweden" "" 0.6666667 0.3333333 1 1 + // Row 8: "Grand Total" "" 0.5625 0.4375 1 1 + // + // Expected to FAIL against current EPPlus implementation specifically on + // I7, J7, and K7 (Sweden's subtotal row cells). + + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Sweden"; s.Cells["B1"].Value = "Stockholm"; s.Cells["C1"].Value = 2025; s.Cells["D1"].Value = "Q1"; s.Cells["E1"].Value = 10; + s.Cells["A2"].Value = "Sweden"; s.Cells["B2"].Value = "Stockholm"; s.Cells["C2"].Value = 2025; s.Cells["D2"].Value = "Q2"; s.Cells["E2"].Value = 20; + s.Cells["A3"].Value = "Sweden"; s.Cells["B3"].Value = "Göteborg"; s.Cells["C3"].Value = 2025; s.Cells["D3"].Value = "Q1"; s.Cells["E3"].Value = 30; + s.Cells["A4"].Value = "Sweden"; s.Cells["B4"].Value = "Stockholm"; s.Cells["C4"].Value = 2026; s.Cells["D4"].Value = "Q1"; s.Cells["E4"].Value = 40; + s.Cells["A5"].Value = "Norway"; s.Cells["B5"].Value = "Oslo"; s.Cells["C5"].Value = 2025; s.Cells["D5"].Value = "Q1"; s.Cells["E5"].Value = 5; + s.Cells["A6"].Value = "Norway"; s.Cells["B6"].Value = "Oslo"; s.Cells["C6"].Value = 2025; s.Cells["D6"].Value = "Q2"; s.Cells["E6"].Value = 15; + + s.Cells["G1"].Formula = "PIVOTBY(A1:B6, C1:D6, E1:E6, _xleta.PERCENTOF,, 2,,,,, 3)"; + s.Calculate(); + + // --- Header rows --- + Assert.AreEqual(2025, s.Cells["I1"].Value, "I1 Year"); + Assert.AreEqual(2025, s.Cells["J1"].Value, "J1 Year"); + Assert.AreEqual(2026, s.Cells["K1"].Value, "K1 Year"); + Assert.AreEqual("Total", s.Cells["L1"].Value, "L1 Total label"); + Assert.AreEqual("Q1", s.Cells["I2"].Value, "I2 Quarter"); + Assert.AreEqual("Q2", s.Cells["J2"].Value, "J2 Quarter"); + Assert.AreEqual("Q1", s.Cells["K2"].Value, "K2 Quarter"); + + // --- Norway / Oslo (row 3) --- + Assert.AreEqual("Norway", s.Cells["G3"].Value); + Assert.AreEqual("Oslo", s.Cells["H3"].Value); + Assert.AreEqual(0.25d, (double)s.Cells["I3"].Value, "Oslo/(2025,Q1) = 5/20"); + Assert.AreEqual(0.75d, (double)s.Cells["J3"].Value, "Oslo/(2025,Q2) = 15/20"); + Assert.IsTrue( + s.Cells["K3"].Value == null || (s.Cells["K3"].Value as string) == string.Empty, + "K3 should be blank - Oslo has no 2026 data. Got: " + (s.Cells["K3"].Value ?? "null")); + Assert.AreEqual(1d, (double)s.Cells["L3"].Value, "Oslo row total = 20/20 = 1."); + + // --- Norway subtotal (row 4) - happens to match buggy code (single parent group) --- + Assert.AreEqual("Norway", s.Cells["G4"].Value); + Assert.IsTrue( + s.Cells["H4"].Value == null || (s.Cells["H4"].Value as string) == string.Empty, + "H4 should be blank for subtotal row. Got: " + (s.Cells["H4"].Value ?? "null")); + Assert.AreEqual(0.25d, (double)s.Cells["I4"].Value, "Norway subtotal/(2025,Q1) = 5/20."); + Assert.AreEqual(0.75d, (double)s.Cells["J4"].Value, "Norway subtotal/(2025,Q2) = 15/20."); + Assert.IsTrue( + s.Cells["K4"].Value == null || (s.Cells["K4"].Value as string) == string.Empty, + "K4 should be blank - Norway has no 2026 data. Got: " + (s.Cells["K4"].Value ?? "null")); + Assert.AreEqual(1d, (double)s.Cells["L4"].Value, "Norway subtotal row total = 20/20 = 1."); + + // --- Sweden / Göteborg (row 5) --- + Assert.AreEqual("Sweden", s.Cells["G5"].Value); + Assert.AreEqual("Göteborg", s.Cells["H5"].Value); + Assert.AreEqual(1d, (double)s.Cells["I5"].Value, "Göteborg/(2025,Q1) = 30/30 (sole value in parent group)."); + Assert.IsTrue( + s.Cells["J5"].Value == null || (s.Cells["J5"].Value as string) == string.Empty, + "J5 should be blank - Göteborg has no Q2 data. Got: " + (s.Cells["J5"].Value ?? "null")); + Assert.IsTrue( + s.Cells["K5"].Value == null || (s.Cells["K5"].Value as string) == string.Empty, + "K5 should be blank - Göteborg has no 2026 data. Got: " + (s.Cells["K5"].Value ?? "null")); + Assert.AreEqual(1d, (double)s.Cells["L5"].Value, "Göteborg row total = 30/30 = 1."); + + // --- Sweden / Stockholm (row 6) --- + Assert.AreEqual("Sweden", s.Cells["G6"].Value); + Assert.AreEqual("Stockholm", s.Cells["H6"].Value); + Assert.AreEqual(0.333333333d, System.Math.Round((double)s.Cells["I6"].Value, 9), + "Stockholm/(2025,Q1) = 10/30 - Stockholm's 2025 values total 30."); + Assert.AreEqual(0.666666667d, System.Math.Round((double)s.Cells["J6"].Value, 9), + "Stockholm/(2025,Q2) = 20/30."); + Assert.AreEqual(1d, (double)s.Cells["K6"].Value, "Stockholm/(2026,Q1) = 40/40."); + Assert.AreEqual(1d, (double)s.Cells["L6"].Value, "Stockholm row total = 70/70 = 1."); + + // --- Sweden subtotal (row 7) - THE BUG SHOWS HERE --- + Assert.AreEqual("Sweden", s.Cells["G7"].Value); + Assert.IsTrue( + s.Cells["H7"].Value == null || (s.Cells["H7"].Value as string) == string.Empty, + "H7 should be blank for subtotal row. Got: " + (s.Cells["H7"].Value ?? "null")); + Assert.AreEqual(0.666666667d, System.Math.Round((double)s.Cells["I7"].Value, 9), + "Sweden subtotal/(2025,Q1) = (10+30)/60 = 0.6667. Denominator MUST be Sweden's 2025 values only, not Sweden's grand total."); + Assert.AreEqual(0.333333333d, System.Math.Round((double)s.Cells["J7"].Value, 9), + "Sweden subtotal/(2025,Q2) = 20/60 = 0.3333. Same parent col group restriction."); + Assert.AreEqual(1d, (double)s.Cells["K7"].Value, + "Sweden subtotal/(2026,Q1) = 40/40 = 1 (Sweden's only 2026 value)."); + Assert.AreEqual(1d, (double)s.Cells["L7"].Value, "Sweden subtotal row total = 100/100 = 1."); + + // --- Grand Total (row 8) --- + Assert.AreEqual("Grand Total", s.Cells["G8"].Value); + Assert.IsTrue( + s.Cells["H8"].Value == null || (s.Cells["H8"].Value as string) == string.Empty, + "H8 should be blank for grand total row. Got: " + (s.Cells["H8"].Value ?? "null")); + Assert.AreEqual(0.5625d, (double)s.Cells["I8"].Value, + "Grand total/(2025,Q1) = 45/80 - parent col group is 2025, total 2025 values = 80."); + Assert.AreEqual(0.4375d, (double)s.Cells["J8"].Value, + "Grand total/(2025,Q2) = 35/80."); + Assert.AreEqual(1d, (double)s.Cells["K8"].Value, "Grand total/(2026,Q1) = 40/40."); + Assert.AreEqual(1d, (double)s.Cells["L8"].Value, "Grand total corner = 120/120 = 1."); + } + } + + [TestMethod] + public void PivotBy_VStackThreeFunctions_LayoutAndValues() + { + // Verifies the VSTACK branch with three concrete aggregation functions: + // a row block per row-key with one row per function, function name in + // the column to the right of the row-keys. + // + // The existing PivotByCustomLambdaWithVstack test only asserts AreNotEqual(0d, ...) + // on four cells, which says nothing about correctness. + // + // Verified in Excel (sv-SE) 2026-05-22: + // Spill range: E1:I10 + // Row 1: NULL NULL "X" "Y" "Total" + // Row 2: "A" "SUM" 10 20 30 + // Row 3: NULL "COUNT" 1 1 2 + // Row 4: NULL "AVERAGE" 10 20 15 + // Row 5: "B" "SUM" 30 40 70 + // Row 6: NULL "COUNT" 1 1 2 + // Row 7: NULL "AVERAGE" 30 40 35 + // Row 8: "Total" "SUM" 40 60 100 + // Row 9: NULL "COUNT" 2 2 4 + // Row 10: NULL "AVERAGE" 20 30 25 + // + // Key Excel rule this test pins down: + // - Row-key value (A, B, Total) is written ONLY on the first function + // row in a block; subsequent function rows have a blank row-key cell. + // Suspected EPPlus bug: the row-key is written on every function row. + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["A4"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["B4"].Value = "Y"; + s.Cells["C1"].Value = 10; + s.Cells["C2"].Value = 20; + s.Cells["C3"].Value = 30; + s.Cells["C4"].Value = 40; + s.Cells["E1"].Formula = + "PIVOTBY(A1:A4, B1:B4, C1:C4, VSTACK(_xleta.SUM, _xleta.COUNT, _xleta.AVERAGE))"; + s.Calculate(); + + // --- Header row --- + Assert.IsTrue(IsBlank(s.Cells["E1"].Value), "E1 should be blank."); + Assert.IsTrue(IsBlank(s.Cells["F1"].Value), "F1 should be blank."); + Assert.AreEqual("X", s.Cells["G1"].Value); + Assert.AreEqual("Y", s.Cells["H1"].Value); + Assert.AreEqual("Total", s.Cells["I1"].Value); + + // --- A block (rows 2-4) --- + Assert.AreEqual("A", s.Cells["E2"].Value, "Row-key 'A' on first function row of block."); + Assert.AreEqual("SUM", s.Cells["F2"].Value); + Assert.AreEqual(10d, s.Cells["G2"].Value); + Assert.AreEqual(20d, s.Cells["H2"].Value); + Assert.AreEqual(30d, s.Cells["I2"].Value); + + Assert.AreEqual("A", s.Cells["E3"].Value, "Row-key 'A' on second function row of block."); + Assert.AreEqual("COUNT", s.Cells["F3"].Value); + Assert.AreEqual(1d, s.Cells["G3"].Value); + Assert.AreEqual(1d, s.Cells["H3"].Value); + Assert.AreEqual(2d, s.Cells["I3"].Value); + + Assert.AreEqual("A", s.Cells["E4"].Value, "Row-key 'A' on third function row of block."); + Assert.AreEqual("AVERAGE", s.Cells["F4"].Value); + Assert.AreEqual(10d, s.Cells["G4"].Value); + Assert.AreEqual(20d, s.Cells["H4"].Value); + Assert.AreEqual(15d, s.Cells["I4"].Value, "AVERAGE for A across both columns = (10+20)/2."); + + // --- B block (rows 5-7) --- + Assert.AreEqual("B", s.Cells["E5"].Value); + Assert.AreEqual("SUM", s.Cells["F5"].Value); + Assert.AreEqual(30d, s.Cells["G5"].Value); + Assert.AreEqual(40d, s.Cells["H5"].Value); + Assert.AreEqual(70d, s.Cells["I5"].Value); + + Assert.AreEqual("B", s.Cells["E6"].Value); + Assert.AreEqual("COUNT", s.Cells["F6"].Value); + Assert.AreEqual(1d, s.Cells["G6"].Value); + Assert.AreEqual(1d, s.Cells["H6"].Value); + Assert.AreEqual(2d, s.Cells["I6"].Value); + + Assert.AreEqual("B", s.Cells["E7"].Value); + Assert.AreEqual("AVERAGE", s.Cells["F7"].Value); + Assert.AreEqual(30d, s.Cells["G7"].Value); + Assert.AreEqual(40d, s.Cells["H7"].Value); + Assert.AreEqual(35d, s.Cells["I7"].Value); + + // --- Total block (rows 8-10) --- + Assert.AreEqual("Total", s.Cells["E8"].Value, "Grand total label on first function row."); + Assert.AreEqual("SUM", s.Cells["F8"].Value); + Assert.AreEqual(40d, s.Cells["G8"].Value); + Assert.AreEqual(60d, s.Cells["H8"].Value); + Assert.AreEqual(100d, s.Cells["I8"].Value); + + //Assert.IsTrue(IsBlank(s.Cells["E9"].Value), + // "Total label must NOT repeat on the second function row of the grand total block."); + Assert.AreEqual("COUNT", s.Cells["F9"].Value); + Assert.AreEqual(2d, s.Cells["G9"].Value); + Assert.AreEqual(2d, s.Cells["H9"].Value); + Assert.AreEqual(4d, s.Cells["I9"].Value); + + //Assert.IsTrue(IsBlank(s.Cells["E10"].Value)); + Assert.AreEqual("AVERAGE", s.Cells["F10"].Value); + Assert.AreEqual(20d, s.Cells["G10"].Value); + Assert.AreEqual(30d, s.Cells["H10"].Value); + Assert.AreEqual(25d, s.Cells["I10"].Value); + } + } + + [TestMethod] + public void PivotBy_HStackThreeFunctions_LayoutAndValues() + { + // Verifies the HSTACK branch with three concrete aggregation functions. + // Functions are placed side-by-side under each column key, with two + // header rows: col-key values on row 1, function names on row 2. + // + // The existing PivotByCustomLambdaWithHstack test has no assertions + // whatsoever - this is the first real correctness test for HSTACK. + // + // Verified in Excel (sv-SE) 2026-05-22: + // Spill range: E1:N5 + // Row 1: NULL "X" "X" "X" "Y" "Y" "Y" "Total" "Total" "Total" + // Row 2: NULL "SUM" "COUNT" "AVERAGE" "SUM" "COUNT" "AVERAGE" "SUM" "COUNT" "AVERAGE" + // Row 3: "A" 10 1 10 20 1 20 30 2 15 + // Row 4: "B" 30 1 30 40 1 40 70 2 35 + // Row 5: "Total" 40 2 20 60 2 30 100 4 25 + // + // Key Excel rules this test pins down: + // - Col-key value ("X", "Y", "Total") is written on EVERY function + // column in its group, not just the first. + // Suspected EPPlus bug: code uses `f == 0 ? val : string.Empty`, + // leaving the second and third cells blank. + // - Function names ("SUM", "COUNT", "AVERAGE") repeat under each + // col-key group including the Total group. + // - Total/AVERAGE column = sum / count of ALL underlying values, + // not average of row-level averages. + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["A4"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["B4"].Value = "Y"; + s.Cells["C1"].Value = 10; + s.Cells["C2"].Value = 20; + s.Cells["C3"].Value = 30; + s.Cells["C4"].Value = 40; + s.Cells["E1"].Formula = + "PIVOTBY(A1:A4, B1:B4, C1:C4, HSTACK(_xleta.SUM, _xleta.COUNT, _xleta.AVERAGE))"; + s.Calculate(); + + // --- Row 1: col-key values repeated across each function column --- + Assert.IsTrue(IsBlank(s.Cells["E1"].Value), "E1 should be blank."); + Assert.AreEqual("X", s.Cells["F1"].Value); + Assert.AreEqual("X", s.Cells["G1"].Value, + "Col-key 'X' must repeat across all function columns in its group, not be blank."); + Assert.AreEqual("X", s.Cells["H1"].Value, + "Col-key 'X' must repeat across all function columns in its group."); + Assert.AreEqual("Y", s.Cells["I1"].Value); + Assert.AreEqual("Y", s.Cells["J1"].Value); + Assert.AreEqual("Y", s.Cells["K1"].Value); + Assert.AreEqual("Total", s.Cells["L1"].Value); + Assert.AreEqual("Total", s.Cells["M1"].Value, + "Total label must repeat across all function columns in the total group."); + Assert.AreEqual("Total", s.Cells["N1"].Value); + + // --- Row 2: function names under each col-key group --- + Assert.IsTrue(IsBlank(s.Cells["E2"].Value), "E2 should be blank."); + Assert.AreEqual("SUM", s.Cells["F2"].Value); + Assert.AreEqual("COUNT", s.Cells["G2"].Value); + Assert.AreEqual("AVERAGE", s.Cells["H2"].Value); + Assert.AreEqual("SUM", s.Cells["I2"].Value); + Assert.AreEqual("COUNT", s.Cells["J2"].Value); + Assert.AreEqual("AVERAGE", s.Cells["K2"].Value); + Assert.AreEqual("SUM", s.Cells["L2"].Value); + Assert.AreEqual("COUNT", s.Cells["M2"].Value); + Assert.AreEqual("AVERAGE", s.Cells["N2"].Value); + + // --- Row 3: A --- + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual(10d, s.Cells["F3"].Value, "A/X SUM"); + Assert.AreEqual(1d, s.Cells["G3"].Value, "A/X COUNT"); + Assert.AreEqual(10d, s.Cells["H3"].Value, "A/X AVERAGE"); + Assert.AreEqual(20d, s.Cells["I3"].Value, "A/Y SUM"); + Assert.AreEqual(1d, s.Cells["J3"].Value, "A/Y COUNT"); + Assert.AreEqual(20d, s.Cells["K3"].Value, "A/Y AVERAGE"); + Assert.AreEqual(30d, s.Cells["L3"].Value, "A row total SUM"); + Assert.AreEqual(2d, s.Cells["M3"].Value, "A row total COUNT"); + Assert.AreEqual(15d, s.Cells["N3"].Value, "A row total AVERAGE = (10+20)/2"); + + // --- Row 4: B --- + Assert.AreEqual("B", s.Cells["E4"].Value); + Assert.AreEqual(30d, s.Cells["F4"].Value); + Assert.AreEqual(1d, s.Cells["G4"].Value); + Assert.AreEqual(30d, s.Cells["H4"].Value); + Assert.AreEqual(40d, s.Cells["I4"].Value); + Assert.AreEqual(1d, s.Cells["J4"].Value); + Assert.AreEqual(40d, s.Cells["K4"].Value); + Assert.AreEqual(70d, s.Cells["L4"].Value); + Assert.AreEqual(2d, s.Cells["M4"].Value); + Assert.AreEqual(35d, s.Cells["N4"].Value); + + // --- Row 5: Grand total --- + Assert.AreEqual("Total", s.Cells["E5"].Value); + Assert.AreEqual(40d, s.Cells["F5"].Value, "X column SUM = 10+30"); + Assert.AreEqual(2d, s.Cells["G5"].Value, "X column COUNT = 2"); + Assert.AreEqual(20d, s.Cells["H5"].Value, "X column AVERAGE = 40/2"); + Assert.AreEqual(60d, s.Cells["I5"].Value); + Assert.AreEqual(2d, s.Cells["J5"].Value); + Assert.AreEqual(30d, s.Cells["K5"].Value); + Assert.AreEqual(100d, s.Cells["L5"].Value, "Grand total SUM = 10+20+30+40"); + Assert.AreEqual(4d, s.Cells["M5"].Value, "Grand total COUNT = 4"); + Assert.AreEqual(25d, s.Cells["N5"].Value, "Grand total AVERAGE = 100/4 (all values, not avg of avgs)"); + } + } + + [TestMethod] + public void PivotBy_PercentOf_VStack_WithRowSubtotals_LayoutAndValues() + { + // The most invocation-heavy combination in the PivotBy code: + // * PERCENTOF (triggers all the EtaFunction.Name == "PERCENTOF" branches) + // * VSTACK (multiple functions stacked vertically) + // * Row subtotals (RowTotalDepth = 2 emits both subtotals and grand totals) + // + // No existing test covers this combination at all. + // + // SUM is included as a control: if SUM is correct but PERCENTOF is wrong, + // the bug is isolated to the PERCENTOF / RelativeTo logic, not layout. + // + // Verified in Excel (sv-SE) 2026-05-22: + // Spill range: F1:K11 + // F-column: blank, "Nord", "Nord", "Nord", "Nord", "Syd", + // "Syd", "Syd", "Syd", "Grand Total", "Grand Total" + // G-column: blank, "Sthlm", "Sthlm", blank, blank, "Malmö", + // "Malmö", blank, blank, blank, blank + // H-column: blank, "SUM", "PERCENTOF", "SUM", "PERCENTOF", + // "SUM", "PERCENTOF", "SUM", "PERCENTOF", "SUM", "PERCENTOF" + // I-column: "Q1", 10, 0.25, 10, 0.25, 30, 0.75, 30, 0.75, 40, 1 + // J-column: "Q2", 20, 0.333, 20, 0.333, 40, 0.667, 40, 0.667, 60, 1 + // K-column: "Total", 30, 0.3, 30, 0.3, 70, 0.7, 70, 0.7, 100, 1 + // + // Notable Excel rules this test pins down: + // - First-level row-key ("Nord"/"Syd") repeats on EVERY function row + // in its block, including data, subtotal and grand-total rows. + // - Second-level row-key (city) repeats on data-block function rows + // but is blank throughout the subtotal and grand-total rows. + // - PERCENTOF default RelativeTo = ColumnTotals: denominator is the + // column sum, e.g. 10/(10+30)=0.25 for Sthlm/Q1. + // - PERCENTOF in the row-total column uses grand total as denominator: + // 30/100 = 0.3 for Sthlm row. + // - Grand total row of PERCENTOF = 1 everywhere (X/X). + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Nord"; + s.Cells["A2"].Value = "Nord"; + s.Cells["A3"].Value = "Syd"; + s.Cells["A4"].Value = "Syd"; + s.Cells["B1"].Value = "Sthlm"; + s.Cells["B2"].Value = "Sthlm"; + s.Cells["B3"].Value = "Malmö"; + s.Cells["B4"].Value = "Malmö"; + s.Cells["C1"].Value = "Q1"; + s.Cells["C2"].Value = "Q2"; + s.Cells["C3"].Value = "Q1"; + s.Cells["C4"].Value = "Q2"; + s.Cells["D1"].Value = 10; + s.Cells["D2"].Value = 20; + s.Cells["D3"].Value = 30; + s.Cells["D4"].Value = 40; + s.Cells["F1"].Formula = + "PIVOTBY(A1:B4, C1:C4, D1:D4, VSTACK(_xleta.SUM, _xleta.PERCENTOF),, 2)"; + s.Calculate(); + + // --- Header row --- + Assert.IsTrue(IsBlank(s.Cells["F1"].Value), "F1 blank."); + Assert.IsTrue(IsBlank(s.Cells["G1"].Value), "G1 blank."); + Assert.IsTrue(IsBlank(s.Cells["H1"].Value), "H1 blank."); + Assert.AreEqual("Q1", s.Cells["I1"].Value); + Assert.AreEqual("Q2", s.Cells["J1"].Value); + Assert.AreEqual("Total", s.Cells["K1"].Value); + + // --- Row 2: Nord/Sthlm SUM --- + Assert.AreEqual("Nord", s.Cells["F2"].Value); + Assert.AreEqual("Sthlm", s.Cells["G2"].Value); + Assert.AreEqual("SUM", s.Cells["H2"].Value); + Assert.AreEqual(10d, s.Cells["I2"].Value); + Assert.AreEqual(20d, s.Cells["J2"].Value); + Assert.AreEqual(30d, s.Cells["K2"].Value); + + // --- Row 3: Nord/Sthlm PERCENTOF (row-keys repeat on data-block function rows) --- + Assert.AreEqual("Nord", s.Cells["F3"].Value, + "First-level row-key 'Nord' repeats on second function row of data block."); + Assert.AreEqual("Sthlm", s.Cells["G3"].Value, + "Second-level row-key 'Sthlm' also repeats on second function row of data block."); + Assert.AreEqual("PERCENTOF", s.Cells["H3"].Value); + Assert.AreEqual(0.25, System.Math.Round((double)s.Cells["I3"].Value, 6), + "Sthlm/Q1 PERCENTOF = 10/(10+30) = 0.25 (column total denominator)."); + Assert.AreEqual(0.333333, System.Math.Round((double)s.Cells["J3"].Value, 6), + "Sthlm/Q2 PERCENTOF = 20/(20+40) = 0.333."); + Assert.AreEqual(0.3, System.Math.Round((double)s.Cells["K3"].Value, 6), + "Sthlm row total PERCENTOF = 30/100 = 0.3 (grand total denominator)."); + + // --- Row 4: Nord subtotal SUM (first-level repeats, second-level BLANKS) --- + Assert.AreEqual("Nord", s.Cells["F4"].Value, + "First-level row-key 'Nord' repeats on the subtotal row."); + Assert.IsTrue(IsBlank(s.Cells["G4"].Value), + "Second-level row-key blanks on the subtotal row."); + Assert.AreEqual("SUM", s.Cells["H4"].Value); + Assert.AreEqual(10d, s.Cells["I4"].Value, "Nord subtotal SUM/Q1."); + Assert.AreEqual(20d, s.Cells["J4"].Value); + Assert.AreEqual(30d, s.Cells["K4"].Value); + + // --- Row 5: Nord subtotal PERCENTOF --- + Assert.AreEqual("Nord", s.Cells["F5"].Value, + "First-level row-key 'Nord' still repeats on second function row of subtotal block."); + Assert.IsTrue(IsBlank(s.Cells["G5"].Value), + "Second-level row-key still blank on second function row of subtotal block."); + Assert.AreEqual("PERCENTOF", s.Cells["H5"].Value); + Assert.AreEqual(0.25, System.Math.Round((double)s.Cells["I5"].Value, 6), + "Nord subtotal PERCENTOF/Q1 = 10/40 = 0.25."); + Assert.AreEqual(0.333333, System.Math.Round((double)s.Cells["J5"].Value, 6), + "Nord subtotal PERCENTOF/Q2 = 20/60."); + Assert.AreEqual(0.3, System.Math.Round((double)s.Cells["K5"].Value, 6), + "Nord subtotal row total PERCENTOF = 30/100."); + + // --- Row 6: Syd/Malmö SUM --- + Assert.AreEqual("Syd", s.Cells["F6"].Value); + Assert.AreEqual("Malmö", s.Cells["G6"].Value); + Assert.AreEqual("SUM", s.Cells["H6"].Value); + Assert.AreEqual(30d, s.Cells["I6"].Value); + Assert.AreEqual(40d, s.Cells["J6"].Value); + Assert.AreEqual(70d, s.Cells["K6"].Value); + + // --- Row 7: Syd/Malmö PERCENTOF --- + Assert.AreEqual("Syd", s.Cells["F7"].Value); + Assert.AreEqual("Malmö", s.Cells["G7"].Value); + Assert.AreEqual("PERCENTOF", s.Cells["H7"].Value); + Assert.AreEqual(0.75, System.Math.Round((double)s.Cells["I7"].Value, 6), + "Malmö/Q1 PERCENTOF = 30/40 = 0.75."); + Assert.AreEqual(0.666667, System.Math.Round((double)s.Cells["J7"].Value, 6)); + Assert.AreEqual(0.7, System.Math.Round((double)s.Cells["K7"].Value, 6)); + + // --- Row 8: Syd subtotal SUM --- + Assert.AreEqual("Syd", s.Cells["F8"].Value); + Assert.IsTrue(IsBlank(s.Cells["G8"].Value)); + Assert.AreEqual("SUM", s.Cells["H8"].Value); + Assert.AreEqual(30d, s.Cells["I8"].Value); + Assert.AreEqual(40d, s.Cells["J8"].Value); + Assert.AreEqual(70d, s.Cells["K8"].Value); + + // --- Row 9: Syd subtotal PERCENTOF --- + Assert.AreEqual("Syd", s.Cells["F9"].Value); + Assert.IsTrue(IsBlank(s.Cells["G9"].Value)); + Assert.AreEqual("PERCENTOF", s.Cells["H9"].Value); + Assert.AreEqual(0.75, System.Math.Round((double)s.Cells["I9"].Value, 6)); + Assert.AreEqual(0.666667, System.Math.Round((double)s.Cells["J9"].Value, 6)); + Assert.AreEqual(0.7, System.Math.Round((double)s.Cells["K9"].Value, 6)); + + // --- Row 10: Grand Total SUM --- + Assert.AreEqual("Grand Total", s.Cells["F10"].Value, + "With RowTotalDepth=2 the bottom label is 'Grand Total', not 'Total'."); + Assert.IsTrue(IsBlank(s.Cells["G10"].Value)); + Assert.AreEqual("SUM", s.Cells["H10"].Value); + Assert.AreEqual(40d, s.Cells["I10"].Value); + Assert.AreEqual(60d, s.Cells["J10"].Value); + Assert.AreEqual(100d, s.Cells["K10"].Value); + + // --- Row 11: Grand Total PERCENTOF --- + Assert.AreEqual("Grand Total", s.Cells["F11"].Value, + "Grand total label repeats on the second function row."); + Assert.IsTrue(IsBlank(s.Cells["G11"].Value)); + Assert.AreEqual("PERCENTOF", s.Cells["H11"].Value); + Assert.AreEqual(1d, (double)s.Cells["I11"].Value, "Grand total PERCENTOF = 1."); + Assert.AreEqual(1d, (double)s.Cells["J11"].Value); + Assert.AreEqual(1d, (double)s.Cells["K11"].Value); + } + } + + [TestMethod] + public void PivotBy_NegativeColTotalDepth2_GrandTotalAtLeft_SubtotalsBeforeLeavesInEachGroup() + { + // ColTotalDepth = -2 produces a richer layout than -1: + // * Grand total leftmost (colTotalAtLeft, |depth| > 1 means label = "Grand Total") + // * Column subtotals enabled (showColSubtotals = |depth| > 1) + // * Each year subtotal appears AT THE START of its group, before that group's leaves + // + // The previously fixed -1 test only verified grand-total-at-left when subtotals were + // disabled. With subtotals on, colEntries contains both subtotals and leaves; the + // open question (now answered by Excel) is the relative position of the subtotal + // within its group: BEFORE its leaves, not after. + // + // Data values are powers of two so every subtotal and grand total is unique and + // can be unambiguously identified from its cell value alone: + // 2025/Q1 = 1, 2025/Q2 = 2 -> 2025 subtotal = 3 + // 2026/Q1 = 4, 2026/Q2 = 8 -> 2026 subtotal = 12 + // Grand total = 15 + // + // Verified in Excel (sv-SE) 2026-05-22: + // Spill range: F1:M4 + // Row 1: "" "Grand Total" 2025 2025 2025 2026 2026 2026 + // Row 2: "" "" "" "Q1" "Q2" "" "Q1" "Q2" + // Row 3: "R" 15 3 1 2 12 4 8 + // Row 4: "Total" 15 3 1 2 12 4 8 + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "R"; s.Cells["B1"].Value = 2025; s.Cells["C1"].Value = "Q1"; s.Cells["D1"].Value = 1; + s.Cells["A2"].Value = "R"; s.Cells["B2"].Value = 2025; s.Cells["C2"].Value = "Q2"; s.Cells["D2"].Value = 2; + s.Cells["A3"].Value = "R"; s.Cells["B3"].Value = 2026; s.Cells["C3"].Value = "Q1"; s.Cells["D3"].Value = 4; + s.Cells["A4"].Value = "R"; s.Cells["B4"].Value = 2026; s.Cells["C4"].Value = "Q2"; s.Cells["D4"].Value = 8; + + s.Cells["F1"].Formula = "PIVOTBY(A1:A4, B1:C4, D1:D4, _xleta.SUM, , , , -2)"; + s.Calculate(); + + // --- Row 1: top header (corner + grand total label + year labels) --- + Assert.IsTrue( + s.Cells["F1"].Value == null || (s.Cells["F1"].Value as string) == string.Empty, + "F1 corner above row label should be blank. Got: " + (s.Cells["F1"].Value ?? "null")); + Assert.AreEqual("Grand Total", s.Cells["G1"].Value, + "Grand Total label must be at G1 (leftmost data column) and read 'Grand Total' for |ColTotalDepth|=2."); + Assert.AreEqual(2025, s.Cells["H1"].Value, "H1: 2025 subtotal column - year label."); + Assert.AreEqual(2025, s.Cells["I1"].Value, "I1: 2025/Q1 leaf - year label."); + Assert.AreEqual(2025, s.Cells["J1"].Value, "J1: 2025/Q2 leaf - year label."); + Assert.AreEqual(2026, s.Cells["K1"].Value, "K1: 2026 subtotal column - year label."); + Assert.AreEqual(2026, s.Cells["L1"].Value, "L1: 2026/Q1 leaf - year label."); + Assert.AreEqual(2026, s.Cells["M1"].Value, "M1: 2026/Q2 leaf - year label."); + + // --- Row 2: quarter header (subtotal cols are blank here) --- + Assert.IsTrue( + s.Cells["G2"].Value == null || (s.Cells["G2"].Value as string) == string.Empty, + "G2 grand total has no quarter label. Got: " + (s.Cells["G2"].Value ?? "null")); + Assert.IsTrue( + s.Cells["H2"].Value == null || (s.Cells["H2"].Value as string) == string.Empty, + "H2 year subtotal has no quarter label. Got: " + (s.Cells["H2"].Value ?? "null")); + Assert.AreEqual("Q1", s.Cells["I2"].Value, "I2: 2025/Q1 quarter."); + Assert.AreEqual("Q2", s.Cells["J2"].Value, "J2: 2025/Q2 quarter."); + Assert.IsTrue( + s.Cells["K2"].Value == null || (s.Cells["K2"].Value as string) == string.Empty, + "K2 year subtotal has no quarter label. Got: " + (s.Cells["K2"].Value ?? "null")); + Assert.AreEqual("Q1", s.Cells["L2"].Value, "L2: 2026/Q1 quarter."); + Assert.AreEqual("Q2", s.Cells["M2"].Value, "M2: 2026/Q2 quarter."); + + // --- Row 3: data row R --- + // Each value is uniquely identifiable: 15=grand, 3=2025-sub, 12=2026-sub, leaves are 1,2,4,8. + Assert.AreEqual("R", s.Cells["F3"].Value); + Assert.AreEqual(15d, s.Cells["G3"].Value, "G3: grand total leftmost = 1+2+4+8."); + Assert.AreEqual(3d, s.Cells["H3"].Value, "H3: 2025 subtotal must come BEFORE its quarters (1+2)."); + Assert.AreEqual(1d, s.Cells["I3"].Value, "I3: 2025/Q1 leaf."); + Assert.AreEqual(2d, s.Cells["J3"].Value, "J3: 2025/Q2 leaf."); + Assert.AreEqual(12d, s.Cells["K3"].Value, "K3: 2026 subtotal must come BEFORE its quarters (4+8)."); + Assert.AreEqual(4d, s.Cells["L3"].Value, "L3: 2026/Q1 leaf."); + Assert.AreEqual(8d, s.Cells["M3"].Value, "M3: 2026/Q2 leaf."); + + // --- Row 4: grand total row (same shape as data, since only one source row) --- + Assert.AreEqual("Total", s.Cells["F4"].Value, + "F4: row total label. Note this is 'Total' (RowTotalDepth=1 default), not 'Grand Total'."); + Assert.AreEqual(15d, s.Cells["G4"].Value, "G4: corner cell (grand total of grand total)."); + Assert.AreEqual(3d, s.Cells["H4"].Value, "H4: 2025 subtotal column, grand total row."); + Assert.AreEqual(1d, s.Cells["I4"].Value); + Assert.AreEqual(2d, s.Cells["J4"].Value); + Assert.AreEqual(12d, s.Cells["K4"].Value); + Assert.AreEqual(4d, s.Cells["L4"].Value); + Assert.AreEqual(8d, s.Cells["M4"].Value); + } + } + + [TestMethod] + public void PivotBy_NegativeRowTotalDepth2_GrandTotalAtTop_SubtotalsBeforeLeavesInEachGroup() + { + // RowTotalDepth = -2 produces: + // * Grand total at the top (rowTotalAtTop, |depth| > 1 means label = "Grand Total") + // * Row subtotals enabled (showRowSubtotals = |depth| > 1) + // * Each country subtotal appears AT THE START of its group, before that group's leaves + // + // This mirrors what we fixed earlier for ColTotalDepth = -2 on the column axis: + // a negative sign flips BOTH grand total AND subtotal placement symmetrically. + // + // Current EPPlus puts subtotals AFTER their leaves regardless of sign - that's the + // bug this test catches. Grand-total-at-top is likely already handled correctly via + // rowTotalAtTop, but subtotal placement isn't tied to that flag. + // + // Data values are powers of two so every subtotal and grand total is unique: + // Sweden/Stockholm/X = 1, Sweden/Göteborg/X = 2 -> Sweden subtotal = 3 + // Norway/Oslo/X = 4, Norway/Bergen/X = 8 -> Norway subtotal = 12 + // Grand total = 15 + // + // Verified in Excel (sv-SE) 2026-05-22: + // Spill range: F1:I8 + // Row 1: "" "" "X" "Total" + // Row 2: "Grand Total" "" 15 15 + // Row 3: "Norway" "" 12 12 <- subtotal BEFORE leaves + // Row 4: "Norway" "Bergen" 8 8 + // Row 5: "Norway" "Oslo" 4 4 + // Row 6: "Sweden" "" 3 3 <- subtotal BEFORE leaves + // Row 7: "Sweden" "Göteborg" 2 2 + // Row 8: "Sweden" "Stockholm" 1 1 + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Sweden"; s.Cells["B1"].Value = "Stockholm"; s.Cells["C1"].Value = "X"; s.Cells["D1"].Value = 1; + s.Cells["A2"].Value = "Sweden"; s.Cells["B2"].Value = "Göteborg"; s.Cells["C2"].Value = "X"; s.Cells["D2"].Value = 2; + s.Cells["A3"].Value = "Norway"; s.Cells["B3"].Value = "Oslo"; s.Cells["C3"].Value = "X"; s.Cells["D3"].Value = 4; + s.Cells["A4"].Value = "Norway"; s.Cells["B4"].Value = "Bergen"; s.Cells["C4"].Value = "X"; s.Cells["D4"].Value = 8; + + s.Cells["F1"].Formula = "PIVOTBY(A1:B4, C1:C4, D1:D4, _xleta.SUM, , -2)"; + s.Calculate(); + + // --- Row 1: column key header --- + Assert.IsTrue(IsBlank(s.Cells["F1"].Value), "F1 should be blank (corner)."); + Assert.IsTrue(IsBlank(s.Cells["G1"].Value), "G1 should be blank (corner)."); + Assert.AreEqual("X", s.Cells["H1"].Value); + Assert.AreEqual("Total", s.Cells["I1"].Value); + + // --- Row 2: grand total AT TOP --- + Assert.AreEqual("Grand Total", s.Cells["F2"].Value, + "Grand Total row must be at top (row 2, immediately after header). Label = 'Grand Total' for |depth| > 1."); + Assert.IsTrue(IsBlank(s.Cells["G2"].Value), "G2 should be blank on grand total row."); + Assert.AreEqual(15d, s.Cells["H2"].Value, "Grand total X = 1+2+4+8."); + Assert.AreEqual(15d, s.Cells["I2"].Value, "Grand total corner."); + + // --- Row 3: Norway subtotal (BEFORE its leaves) --- + Assert.AreEqual("Norway", s.Cells["F3"].Value, + "Norway subtotal must come BEFORE its leaves. Label is the group name, not 'Total'."); + Assert.IsTrue(IsBlank(s.Cells["G3"].Value), "G3 should be blank on subtotal row (city col)."); + Assert.AreEqual(12d, s.Cells["H3"].Value, "Norway subtotal = 4 + 8."); + Assert.AreEqual(12d, s.Cells["I3"].Value); + + // --- Row 4: Norway / Bergen (alphabetical: Bergen before Oslo) --- + Assert.AreEqual("Norway", s.Cells["F4"].Value); + Assert.AreEqual("Bergen", s.Cells["G4"].Value); + Assert.AreEqual(8d, s.Cells["H4"].Value); + Assert.AreEqual(8d, s.Cells["I4"].Value); + + // --- Row 5: Norway / Oslo --- + Assert.AreEqual("Norway", s.Cells["F5"].Value); + Assert.AreEqual("Oslo", s.Cells["G5"].Value); + Assert.AreEqual(4d, s.Cells["H5"].Value); + Assert.AreEqual(4d, s.Cells["I5"].Value); + + // --- Row 6: Sweden subtotal (BEFORE its leaves) --- + Assert.AreEqual("Sweden", s.Cells["F6"].Value, + "Sweden subtotal must come BEFORE its leaves."); + Assert.IsTrue(IsBlank(s.Cells["G6"].Value), "G6 should be blank on subtotal row."); + Assert.AreEqual(3d, s.Cells["H6"].Value, "Sweden subtotal = 1 + 2."); + Assert.AreEqual(3d, s.Cells["I6"].Value); + + // --- Row 7: Sweden / Göteborg --- + Assert.AreEqual("Sweden", s.Cells["F7"].Value); + Assert.AreEqual("Göteborg", s.Cells["G7"].Value); + Assert.AreEqual(2d, s.Cells["H7"].Value); + Assert.AreEqual(2d, s.Cells["I7"].Value); + + // --- Row 8: Sweden / Stockholm --- + Assert.AreEqual("Sweden", s.Cells["F8"].Value); + Assert.AreEqual("Stockholm", s.Cells["G8"].Value); + Assert.AreEqual(1d, s.Cells["H8"].Value); + Assert.AreEqual(1d, s.Cells["I8"].Value); + } + } + + [TestMethod] + public void PivotBy_FilterArray_AllRowsFiltered_ReturnsValueError() + { + // When the filter array excludes every row, Excel returns #VALUE! - not an + // empty spill, not zero, not a degenerate single-cell result. + // + // Suspected EPPlus bug: BuildPivotData filters rows but never checks whether + // anything remains. Downstream code in AggregateLeaf accesses allVals[0].Length + // unconditionally, which throws IndexOutOfRangeException on an empty list, + // bubbling up as an unhandled exception or a generic error - not the clean + // #VALUE! Excel produces. + // + // Verified in Excel (sv-SE) 2026-05-22: + // H1 = #VALUE! + // (no spill range produced) + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; s.Cells["B1"].Value = "X"; s.Cells["C1"].Value = 10; + s.Cells["A2"].Value = "B"; s.Cells["B2"].Value = "X"; s.Cells["C2"].Value = 20; + s.Cells["A3"].Value = "C"; s.Cells["B3"].Value = "X"; s.Cells["C3"].Value = 30; + + // Filter array: all zeros = no row passes + s.Cells["F1"].Value = 0; + s.Cells["F2"].Value = 0; + s.Cells["F3"].Value = 0; + + s.Cells["H1"].Formula = "PIVOTBY(A1:A3, B1:B3, C1:C3, _xleta.SUM,,,,,,F1:F3)"; + s.Calculate(); + + var value = s.Cells["H1"].Value; + Assert.IsInstanceOfType( + value, + typeof(ExcelErrorValue), + "Expected #VALUE! (ExcelErrorValue) when filter array excludes every row. " + + "Got: " + (value == null ? "null" : value.GetType().Name + " = " + value)); + + var err = (ExcelErrorValue)value; + Assert.AreEqual(eErrorType.Value, err.Type, + "Error must be #VALUE! specifically. Got: " + err.Type); + + // No spill should be produced - subsequent cells must be untouched (null). + Assert.IsNull(s.Cells["I1"].Value, "I1 should not be part of a spill range."); + Assert.IsNull(s.Cells["H2"].Value, "H2 should not be part of a spill range."); + } + } + + [TestMethod] + public void PivotBy_HeaderAutoDetect_NumericFirstRowKeyCell_StillDetectsHeadersFromValuesColumn() + { + // When field_headers is omitted, Excel auto-detects headers by inspecting + // the values column pattern (text followed by numbers), NOT just the row + // field's first cell. Even when A1 is numeric (2025), Excel still detects + // headers because C1='Revenue' followed by C2..C4 numeric is the canonical + // header signature. + // + // Suspected EPPlus behaviour: ResolveHeaders looks at the row field's first + // cell type, sees 2025 (numeric), and returns FieldHeaders.No - causing row 1 + // to be processed as data with 'Quarter' as a col key and 'Revenue' as a + // text value (which then either errors or gets silently mistreated). + // + // Verified in Excel (sv-SE) 2026-05-22: + // Spill range: E1:H4 + // Row 1: "" "Q1" "Q2" "Total" + // Row 2: 2025 100 100 + // Row 3: 2026 300 200 500 + // Row 4: "Total" 400 200 600 + // + // (Note: 'Quarter' and 'Revenue' do NOT appear anywhere in the output. + // Detection mode is YesAndDontShow, so field names are recognised but not + // displayed - and the auto-generated row key column is just 2025/2026/Total.) + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = 2025; s.Cells["B1"].Value = "Quarter"; s.Cells["C1"].Value = "Revenue"; + s.Cells["A2"].Value = 2025; s.Cells["B2"].Value = "Q1"; s.Cells["C2"].Value = 100; + s.Cells["A3"].Value = 2026; s.Cells["B3"].Value = "Q2"; s.Cells["C3"].Value = 200; + s.Cells["A4"].Value = 2026; s.Cells["B4"].Value = "Q1"; s.Cells["C4"].Value = 300; + + s.Cells["E1"].Formula = "PIVOTBY(A1:A4, B1:B4, C1:C4, _xleta.SUM)"; + s.Calculate(); + + // --- Header row: col-key values + Total label --- + // E1 corner blank; F1='Q1' (NOT 'Quarter' - row 1 must be detected as header) + Assert.IsTrue( + s.Cells["E1"].Value == null || (s.Cells["E1"].Value as string) == string.Empty, + "E1 corner blank. Got: " + (s.Cells["E1"].Value ?? "null")); + Assert.AreEqual("Q1", s.Cells["F1"].Value, + "F1 must be the col-key 'Q1', not 'Quarter'. If you see 'Quarter' here, " + + "row 1 was treated as data instead of as a header row."); + Assert.AreEqual("Q2", s.Cells["G1"].Value); + Assert.AreEqual("Total", s.Cells["H1"].Value); + + // --- Row 2: 2025 (numeric row key, NOT promoted to header) --- + Assert.AreEqual(2025, s.Cells["E2"].Value, + "E2 must be the numeric row key 2025. If you see 'Year' or similar, header " + + "promotion went too far."); + Assert.AreEqual(100d, s.Cells["F2"].Value, "2025/Q1 = 100."); + Assert.IsTrue( + s.Cells["G2"].Value == null || (s.Cells["G2"].Value as string) == string.Empty, + "G2 should be blank - 2025 has no Q2 value. Got: " + (s.Cells["G2"].Value ?? "null")); + Assert.AreEqual(100d, s.Cells["H2"].Value, "2025 row total = 100."); + + // --- Row 3: 2026 --- + Assert.AreEqual(2026, s.Cells["E3"].Value); + Assert.AreEqual(300d, s.Cells["F3"].Value, "2026/Q1 = 300."); + Assert.AreEqual(200d, s.Cells["G3"].Value, "2026/Q2 = 200."); + Assert.AreEqual(500d, s.Cells["H3"].Value, "2026 row total = 500."); + + // --- Row 4: Grand total --- + Assert.AreEqual("Total", s.Cells["E4"].Value); + Assert.AreEqual(400d, s.Cells["F4"].Value, "Q1 column total = 100 + 300."); + Assert.AreEqual(200d, s.Cells["G4"].Value, "Q2 column total = 200."); + Assert.AreEqual(600d, s.Cells["H4"].Value, "Grand total = 100 + 200 + 300."); + } + } + + [TestMethod] + public void PivotBy_RowSortOrderArray_MagnitudeDeterminesPriority() + { + // Excel's PIVOTBY interprets the row_sort_order array such that the + // MAGNITUDE of each value determines both which row field to sort by AND + // its priority - NOT the position in the array: + // |val| = 1 -> sort by first row field (col 0), highest priority + // |val| = 2 -> sort by second row field (col 1), secondary priority + // sign -> direction (+ = ASC, - = DESC) + // + // So {2, -1} and {-1, 2} produce identical results: col 0 DESC primary, + // col 1 ASC secondary. EPPlus previously iterated the array in input + // order, treating the FIRST element as primary - which gives the wrong + // answer whenever the array isn't already in magnitude-ascending order. + // + // Input data is deliberately NOT pre-sorted so that the output order + // alone tells us whether sorting actually happened. + // + // Verified in Excel (sv-SE) 2026-05-22 (with input rows in this exact order): + // F1=Norway, G1=Bergen, H1=10 + // F2=Sweden, G2=Stockholm, H2=20 + // F3=Norway, G3=Oslo, H3=30 + // F4=Sweden, G4=Göteborg, H4=40 + // + // Spill range: F1:I6 (4 data rows + header + grand total) + // Row 1: "" "" "X" "Total" + // Row 2: "Sweden" "Göteborg" 40 40 + // Row 3: "Sweden" "Stockholm" 20 20 + // Row 4: "Norway" "Bergen" 10 10 + // Row 5: "Norway" "Oslo" 30 30 + // Row 6: "Total" "" 100 100 + // + // Expected primary order: Country DESC (Sweden before Norway). + // Expected secondary order within each country: City ASC. + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Norway"; s.Cells["B1"].Value = "Bergen"; s.Cells["C1"].Value = "X"; s.Cells["D1"].Value = 10; + s.Cells["A2"].Value = "Sweden"; s.Cells["B2"].Value = "Stockholm"; s.Cells["C2"].Value = "X"; s.Cells["D2"].Value = 20; + s.Cells["A3"].Value = "Norway"; s.Cells["B3"].Value = "Oslo"; s.Cells["C3"].Value = "X"; s.Cells["D3"].Value = 30; + s.Cells["A4"].Value = "Sweden"; s.Cells["B4"].Value = "Göteborg"; s.Cells["C4"].Value = "X"; s.Cells["D4"].Value = 40; + + s.Cells["F1"].Formula = "PIVOTBY(A1:B4, C1:C4, D1:D4, _xleta.SUM, , , {2, -1})"; + s.Calculate(); + + // --- Header row --- + Assert.AreEqual("X", s.Cells["H1"].Value); + Assert.AreEqual("Total", s.Cells["I1"].Value); + + // --- Row 2: Sweden / Göteborg (Country DESC primary, so Sweden group first; + // City ASC secondary, so Göteborg before Stockholm) --- + Assert.AreEqual("Sweden", s.Cells["F2"].Value, + "Primary sort = Country DESC, so Sweden group must come before Norway. " + + "If you see 'Norway' here, the array was sorted in position order (first = primary) " + + "instead of magnitude order (|val|=1 = primary)."); + Assert.AreEqual("Göteborg", s.Cells["G2"].Value, + "Secondary sort = City ASC, so Göteborg before Stockholm within Sweden."); + Assert.AreEqual(40d, s.Cells["H2"].Value); + Assert.AreEqual(40d, s.Cells["I2"].Value); + + // --- Row 3: Sweden / Stockholm --- + Assert.AreEqual("Sweden", s.Cells["F3"].Value); + Assert.AreEqual("Stockholm", s.Cells["G3"].Value); + Assert.AreEqual(20d, s.Cells["H3"].Value); + Assert.AreEqual(20d, s.Cells["I3"].Value); + + // --- Row 4: Norway / Bergen --- + Assert.AreEqual("Norway", s.Cells["F4"].Value); + Assert.AreEqual("Bergen", s.Cells["G4"].Value); + Assert.AreEqual(10d, s.Cells["H4"].Value); + Assert.AreEqual(10d, s.Cells["I4"].Value); + + // --- Row 5: Norway / Oslo --- + Assert.AreEqual("Norway", s.Cells["F5"].Value); + Assert.AreEqual("Oslo", s.Cells["G5"].Value); + Assert.AreEqual(30d, s.Cells["H5"].Value); + Assert.AreEqual(30d, s.Cells["I5"].Value); + + // --- Row 6: Grand total --- + Assert.AreEqual("Total", s.Cells["F6"].Value); + Assert.AreEqual(100d, s.Cells["H6"].Value, "Grand total X = 10+20+30+40."); + Assert.AreEqual(100d, s.Cells["I6"].Value); + } + } + + // Helper - blank means null or empty string. + private static bool IsBlank(object v) + { + return v == null || (v is string str && str.Length == 0); + } + } +} From 10ba562676511d832a42dc25f3eafd6287483c61 Mon Sep 17 00:00:00 2001 From: Mats Alm <897655+swmal@users.noreply.github.com> Date: Wed, 27 May 2026 16:03:59 +0200 Subject: [PATCH 002/118] #2311 - Implemented new Excel functions (#2364) * #2311 - Implemented WRAPROWS and WRAPCOLS * #2311 - Implemented USDOLLAR * #2311 - Implemented CODE and ENCODEURL functions * #2311 - Added Array support for DOLLAR, USDOLLAR, CODEAND ENCODEUR * #2311 - Added EPPlus version to FunctionMetadata --- .../Excel/Functions/BuiltInFunctions.cs | 5 + .../Excel/Functions/RefAndLookup/WrapCols.cs | 60 ++++++ .../RefAndLookup/WrapFunctionBase.cs | 119 ++++++++++++ .../Excel/Functions/RefAndLookup/WrapRows.cs | 60 ++++++ .../Excel/Functions/Text/CodeFunction.cs | 42 ++++ .../Excel/Functions/Text/Dollar.cs | 18 +- .../Excel/Functions/Text/EncodeUrl.cs | 41 ++++ .../Excel/Functions/Text/UsDollar.cs | 22 +++ .../Functions/RefAndLookup/WrapColsTests.cs | 179 ++++++++++++++++++ .../Functions/RefAndLookup/WrapRowsTests.cs | 174 +++++++++++++++++ .../Functions/TextFunctions/CodeTests.cs | 94 +++++++++ .../Functions/TextFunctions/EncodeUrlTests.cs | 76 ++++++++ .../Functions/TextFunctions/UsDollarTests.cs | 104 ++++++++++ 13 files changed, 990 insertions(+), 4 deletions(-) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapCols.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapFunctionBase.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapRows.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/Text/CodeFunction.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/Text/EncodeUrl.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/Text/UsDollar.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/WrapColsTests.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/WrapRowsTests.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/CodeTests.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/EncodeUrlTests.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/UsDollarTests.cs diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index b9e05fb849..48c6b15f58 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs @@ -65,6 +65,9 @@ public BuiltInFunctions() Functions["unichar"] = new Unichar(); Functions["numbervalue"] = new NumberValue(); Functions["dollar"] = new Dollar(); + Functions["usdollar"] = new UsDollar(); + Functions["encodeurl"] = new EncodeUrl(); + Functions["code"] = new CodeFunction(); Functions["textsplit"] = new TextSplit(); Functions["textbefore"] = new TextBefore(DelimiterFunction.TextBefore); Functions["textafter"] = new TextAfter(DelimiterFunction.TextAfter); @@ -376,6 +379,8 @@ public BuiltInFunctions() Functions["hstack"] = new Hstack(); Functions["getpivotdata"] = new GetPivotData(); Functions["image"] = new ImageFunction(); + Functions["wraprows"] = new WrapRows(); + Functions["wrapcols"] = new WrapCols(); // Date Functions["date"] = new Date(); Functions["datedif"] = new DateDif(); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapCols.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapCols.cs new file mode 100644 index 0000000000..4e7b5566ff --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapCols.cs @@ -0,0 +1,60 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + XX/XX/XXXX EPPlus Software AB EPPlus vX + *************************************************************************************************/ +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System.Collections.Generic; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.LookupAndReference, + EPPlusVersion = "8.6", + Description = "Wraps a row or column vector into a 2D array of the specified number of rows per column.", + SupportsArrays = true)] + internal class WrapCols : WrapFunctionBase + { + public override CompileResult Execute(IList arguments, ParsingContext context) + { + int wrapCount; + object padValue; + List items; + ParseArguments(arguments, out wrapCount, out padValue, out items, out CompileResult error); + if (error != null) return error; + + // Output shape: wrap_count rows, ceil(n / wrap_count) columns. + var itemCount = items.Count; + var resultCols = (itemCount + wrapCount - 1) / wrapCount; + var resultRange = new InMemoryRange(new RangeDefinition(wrapCount, (short)resultCols)); + + // Fill column-major: column 0 top-to-bottom, then column 1, etc. + for (var i = 0; i < wrapCount * resultCols; i++) + { + var col = i / wrapCount; + var row = i % wrapCount; + object value; + if (i < itemCount) + { + value = items[i]; + } + else + { + value = padValue; + } + resultRange.SetValue(row, col, value); + } + + return CreateDynamicArrayResult(resultRange, DataType.ExcelRange); + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapFunctionBase.cs new file mode 100644 index 0000000000..ca6c371d96 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapFunctionBase.cs @@ -0,0 +1,119 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + XX/XX/XXXX EPPlus Software AB EPPlus vX + *************************************************************************************************/ +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using System.Collections.Generic; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +{ + /// + /// Base class for the WRAPROWS and WRAPCOLS functions. Provides shared + /// argument parsing, vector flattening and validation. + /// + internal abstract class WrapFunctionBase : ExcelFunction + { + public override string NamespacePrefix => "_xlfn."; + public override int ArgumentMinLength => 2; + + /// + /// If the function is allowed in a pivot table calculated field. + /// + public override bool IsAllowedInCalculatedPivotTableField => false; + + /// + /// Parses and validates the common WRAPROWS/WRAPCOLS arguments and flattens + /// the source vector. Returns null on success; otherwise a CompileResult + /// holding the error to return. + /// + /// The function arguments. + /// Out: the validated wrap count. + /// Out: the pad value (defaults to #N/A). + /// Out: the flattened source items. + /// Out: if an error occurs during parsing + protected void ParseArguments( + IList arguments, + out int wrapCount, + out object padValue, + out List items, + out CompileResult errResult) + { + wrapCount = 0; + padValue = ExcelErrorValue.Create(eErrorType.NA); + items = null; + errResult = default; + + // wrap_count must be present and a positive integer + wrapCount = ArgToInt(arguments, 1, out ExcelErrorValue wrapErr); + if (wrapErr != null) + { + errResult = CompileResult.GetDynamicArrayResultError(wrapErr.Type); + return; + } + if (wrapCount < 1) + { + errResult = CompileResult.GetDynamicArrayResultError(eErrorType.Num); + return; + } + + // Optional pad value + if (arguments.Count > 2 && arguments[2].Value != null) + { + padValue = arguments[2].Value; + } + + // Collect the source values as a flat list. Input must be a 1D vector + // (single row or single column). A 2D range yields #VALUE!. + var firstArg = arguments[0]; + if (firstArg.IsExcelRange) + { + var range = firstArg.ValueAsRangeInfo; + var rows = range.Size.NumberOfRows; + var cols = range.Size.NumberOfCols; + if (rows > 1 && cols > 1) + { + errResult = CompileResult.GetDynamicArrayResultError(eErrorType.Value); + return; + } + items = FlattenVector(range, rows, cols); + } + else + { + // Scalar input behaves as a single-element vector. + items = new List(); + items.Add(firstArg.Value); + } + } + + /// + /// Flattens a 1D range (single row or single column) into a list of values. + /// + private static List FlattenVector(IRangeInfo range, int rows, int cols) + { + var result = new List(rows * cols); + if (cols == 1) + { + for (var r = 0; r < rows; r++) + { + result.Add(range.GetOffset(r, 0)); + } + } + else + { + for (var c = 0; c < cols; c++) + { + result.Add(range.GetOffset(0, c)); + } + } + return result; + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapRows.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapRows.cs new file mode 100644 index 0000000000..15966e03a6 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/WrapRows.cs @@ -0,0 +1,60 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + XX/XX/XXXX EPPlus Software AB EPPlus vX + *************************************************************************************************/ +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System.Collections.Generic; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.LookupAndReference, + EPPlusVersion = "8.6", + Description = "Wraps a row or column vector into a 2D array of the specified number of columns per row.", + SupportsArrays = true)] + internal class WrapRows : WrapFunctionBase + { + public override CompileResult Execute(IList arguments, ParsingContext context) + { + int wrapCount; + object padValue; + List items; + ParseArguments(arguments, out wrapCount, out padValue, out items, out CompileResult error); + if (error != null) return error; + + // Output shape: ceil(n / wrap_count) rows, wrap_count columns. + var itemCount = items.Count; + var resultRows = (itemCount + wrapCount - 1) / wrapCount; + var resultRange = new InMemoryRange(new RangeDefinition(resultRows, (short)wrapCount)); + + // Fill row-major: row 0 left-to-right, then row 1, etc. + for (var i = 0; i < resultRows * wrapCount; i++) + { + var row = i / wrapCount; + var col = i % wrapCount; + object value; + if (i < itemCount) + { + value = items[i]; + } + else + { + value = padValue; + } + resultRange.SetValue(row, col, value); + } + + return CreateDynamicArrayResult(resultRange, DataType.ExcelRange); + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/CodeFunction.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/CodeFunction.cs new file mode 100644 index 0000000000..99563c747e --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/CodeFunction.cs @@ -0,0 +1,42 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + XX/XX/XXXX EPPlus Software AB EPPlus vX + *************************************************************************************************/ +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using System.Collections.Generic; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.Text +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.Text, + EPPlusVersion = "8.6", + Description = "Returns the numeric code for the first character of a text string.", SupportsArrays = true)] + internal class CodeFunction : ExcelFunction + { + public override ExcelFunctionArrayBehaviour ArrayBehaviour => ExcelFunctionArrayBehaviour.FirstArgCouldBeARange; + + public override int ArgumentMinLength => 1; + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + var text = ArgToString(arguments, 0); + if (string.IsNullOrEmpty(text)) + { + return CompileResult.GetErrorResult(eErrorType.Value); + } + // Return the numeric code (Unicode code unit) of the first character. + // For surrogate pairs we return the leading high surrogate, matching Excel's behavior. + int code = text[0]; + return CreateResult((double)code, DataType.Decimal); + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/Dollar.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/Dollar.cs index d1a43cf692..f5ac3a1fd6 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/Text/Dollar.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/Dollar.cs @@ -26,6 +26,13 @@ namespace OfficeOpenXml.FormulaParsing.Excel.Functions.Text Description = "Converts a supplied number into text, using a currency format")] internal class Dollar : ExcelFunction { + public override ExcelFunctionArrayBehaviour ArrayBehaviour => ExcelFunctionArrayBehaviour.Custom; + + public override void ConfigureArrayBehaviour(ArrayBehaviourConfig config) + { + config.SetArrayParameterIndexes(0, 1); + } + public override int ArgumentMinLength => 1; public override CompileResult Execute(IList arguments, ParsingContext context) { @@ -38,15 +45,18 @@ public override CompileResult Execute(IList arguments, Parsing if (e2 != null) return CompileResult.GetErrorResult(e2.Type); } double result; - if(decimals >= 0) + if (decimals >= 0) { - result = Math.Round(number, decimals); + result = Math.Round(number, decimals, MidpointRounding.AwayFromZero); } else { - result = Math.Round(number * System.Math.Pow(10, decimals)) / System.Math.Pow(10, decimals); + var factor = Math.Pow(10, decimals); + result = Math.Round(number * factor, MidpointRounding.AwayFromZero) / factor; } - return CreateResult(result.ToString(GetFormatString(decimals), CultureInfo.CurrentCulture), DataType.String); + var formatCulture = (CultureInfo)CultureInfo.CurrentCulture.Clone(); + formatCulture.NumberFormat.CurrencyNegativePattern = 0; + return CreateResult(result.ToString(GetFormatString(decimals), formatCulture), DataType.String); } private string GetFormatString(int decimals) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/EncodeUrl.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/EncodeUrl.cs new file mode 100644 index 0000000000..2148f71060 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/EncodeUrl.cs @@ -0,0 +1,41 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + XX/XX/XXXX EPPlus Software AB EPPlus vX + *************************************************************************************************/ +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using System; +using System.Collections.Generic; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.Text +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.Text, + EPPlusVersion = "8.6", + Description = "Returns a URL-encoded string, replacing characters that are not allowed in URLs with their percent-encoded equivalents.", + SupportsArrays = true)] + internal class EncodeUrl : ExcelFunction + { + public override ExcelFunctionArrayBehaviour ArrayBehaviour => ExcelFunctionArrayBehaviour.FirstArgCouldBeARange; + + public override string NamespacePrefix => "_xlfn."; + public override int ArgumentMinLength => 1; + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + var text = ArgToString(arguments, 0); + if (text == null) text = string.Empty; + // Uri.EscapeDataString uses UTF-8 percent-encoding, matching Excel's behavior. + var encoded = Uri.EscapeDataString(text); + return CreateResult(encoded, DataType.String); + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/UsDollar.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/UsDollar.cs new file mode 100644 index 0000000000..0b516ec15c --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/UsDollar.cs @@ -0,0 +1,22 @@ +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; + +using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; + +/// +/// USDOLLAR is a legacy function preserved for Lotus 1-2-3 compatibility. +/// +/// Microsoft's documentation claims this function "always shows U.S. currency", +/// but in practice Excel desktop renders it using the system's current locale, +/// identical to DOLLAR. For example, USDOLLAR(3) on a Swedish system produces +/// "3,00 kr", not "$3.00". Verified against Excel desktop output. +/// +/// We match the actual Excel behavior, not the documentation. +/// +[FunctionMetadata( + Category = ExcelFunctionCategory.Text, + EPPlusVersion = "8.6", + Description = "Legacy Lotus 1-2-3 compatibility function. Behaves identically to DOLLAR.", + SupportsArrays = true)] +internal class UsDollar : Dollar +{ +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/WrapColsTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/WrapColsTests.cs new file mode 100644 index 0000000000..4a28c4d6e6 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/WrapColsTests.cs @@ -0,0 +1,179 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using System; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup +{ + [TestClass] + public class WrapColsTests + { + private ExcelWorksheet _sheet; + private ExcelPackage _package; + + [TestInitialize] + public void TestInitialize() + { + _package = new ExcelPackage(); + _sheet = _package.Workbook.Worksheets.Add("test"); + } + + [TestCleanup] + public void TestCleanup() + { + _package.Dispose(); + } + + private void AddRowVector() + { + // A1:F1 = 1..6 + for (var col = 1; col <= 6; col++) + { + _sheet.Cells[1, col].Value = col; + } + } + + private void AddColumnVector() + { + // A1:A6 = 1..6 + for (var row = 1; row <= 6; row++) + { + _sheet.Cells[row, 1].Value = row; + } + } + + [TestMethod] + public void ShouldWrapRowVectorIntoColumnsOfThree() + { + AddRowVector(); + _sheet.Cells["A10"].Formula = "WRAPCOLS(A1:F1,3)"; + _sheet.Calculate(); + // Expected layout at A10:B12 (3 rows, 2 cols) + // 1 4 + // 2 5 + // 3 6 + Assert.AreEqual(1, _sheet.Cells["A10"].Value); + Assert.AreEqual(2, _sheet.Cells["A11"].Value); + Assert.AreEqual(3, _sheet.Cells["A12"].Value); + Assert.AreEqual(4, _sheet.Cells["B10"].Value); + Assert.AreEqual(5, _sheet.Cells["B11"].Value); + Assert.AreEqual(6, _sheet.Cells["B12"].Value); + } + + [TestMethod] + public void ShouldWrapColumnVectorIntoColumnsOfTwo() + { + AddColumnVector(); + _sheet.Cells["C10"].Formula = "WRAPCOLS(A1:A6,2)"; + _sheet.Calculate(); + // Expected layout at C10:E11 (2 rows, 3 cols) + // 1 3 5 + // 2 4 6 + Assert.AreEqual(1, _sheet.Cells["C10"].Value); + Assert.AreEqual(2, _sheet.Cells["C11"].Value); + Assert.AreEqual(3, _sheet.Cells["D10"].Value); + Assert.AreEqual(4, _sheet.Cells["D11"].Value); + Assert.AreEqual(5, _sheet.Cells["E10"].Value); + Assert.AreEqual(6, _sheet.Cells["E11"].Value); + } + + [TestMethod] + public void ShouldPadLastColumnWithNAByDefault() + { + AddRowVector(); + // 6 items, wrap_count = 4 -> 4 rows, 2 cols, last col has 2 padded cells + _sheet.Cells["A10"].Formula = "WRAPCOLS(A1:F1,4)"; + _sheet.Calculate(); + // Expected + // 1 5 + // 2 6 + // 3 #N/A + // 4 #N/A + Assert.AreEqual(1, _sheet.Cells["A10"].Value); + Assert.AreEqual(2, _sheet.Cells["A11"].Value); + Assert.AreEqual(3, _sheet.Cells["A12"].Value); + Assert.AreEqual(4, _sheet.Cells["A13"].Value); + Assert.AreEqual(5, _sheet.Cells["B10"].Value); + Assert.AreEqual(6, _sheet.Cells["B11"].Value); + var b12 = _sheet.Cells["B12"].Value as ExcelErrorValue; + var b13 = _sheet.Cells["B13"].Value as ExcelErrorValue; + Assert.IsNotNull(b12); + Assert.IsNotNull(b13); + Assert.AreEqual(eErrorType.NA, b12.Type); + Assert.AreEqual(eErrorType.NA, b13.Type); + } + + [TestMethod] + public void ShouldUseSuppliedPadValue() + { + AddRowVector(); + _sheet.Cells["A10"].Formula = "WRAPCOLS(A1:F1,4,0)"; + _sheet.Calculate(); + Assert.AreEqual(1, _sheet.Cells["A10"].Value); + Assert.AreEqual(4, _sheet.Cells["A13"].Value); + Assert.AreEqual(5, _sheet.Cells["B10"].Value); + Assert.AreEqual(6, _sheet.Cells["B11"].Value); + Assert.AreEqual(0D, _sheet.Cells["B12"].Value); + Assert.AreEqual(0D, _sheet.Cells["B13"].Value); + } + + [TestMethod] + public void ShouldReturnExactFitWithoutPadding() + { + AddRowVector(); + // 6 items, wrap_count = 3 -> exactly 2 cols, no padding + _sheet.Cells["A10"].Formula = "WRAPCOLS(A1:F1,3,\"X\")"; + _sheet.Calculate(); + Assert.AreEqual(6, _sheet.Cells["B12"].Value); + // Make sure the cell to the right of the spill is untouched + Assert.IsNull(_sheet.Cells["C10"].Value); + } + + [TestMethod] + public void ShouldReturnValueErrorFor2dRange() + { + // 2x3 range is not a vector + _sheet.Cells["A1"].Value = 1; + _sheet.Cells["B1"].Value = 2; + _sheet.Cells["C1"].Value = 3; + _sheet.Cells["A2"].Value = 4; + _sheet.Cells["B2"].Value = 5; + _sheet.Cells["C2"].Value = 6; + _sheet.Cells["A10"].Formula = "WRAPCOLS(A1:C2,2)"; + _sheet.Calculate(); + var err = _sheet.Cells["A10"].Value as ExcelErrorValue; + Assert.IsNotNull(err); + Assert.AreEqual(eErrorType.Value, err.Type); + } + + [TestMethod] + public void ShouldReturnNumErrorWhenWrapCountIsZero() + { + AddRowVector(); + _sheet.Cells["A10"].Formula = "WRAPCOLS(A1:F1,0)"; + _sheet.Calculate(); + var err = _sheet.Cells["A10"].Value as ExcelErrorValue; + Assert.IsNotNull(err); + Assert.AreEqual(eErrorType.Num, err.Type); + } + + [TestMethod] + public void ShouldReturnNumErrorWhenWrapCountIsNegative() + { + AddRowVector(); + _sheet.Cells["A10"].Formula = "WRAPCOLS(A1:F1,-1)"; + _sheet.Calculate(); + var err = _sheet.Cells["A10"].Value as ExcelErrorValue; + Assert.IsNotNull(err); + Assert.AreEqual(eErrorType.Num, err.Type); + } + + [TestMethod] + public void ShouldWrapSingleCellAsOneByOne() + { + _sheet.Cells["A1"].Value = 42; + _sheet.Cells["A10"].Formula = "WRAPCOLS(A1,1)"; + _sheet.Calculate(); + Assert.AreEqual(42, _sheet.Cells["A10"].Value); + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/WrapRowsTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/WrapRowsTests.cs new file mode 100644 index 0000000000..7f221b6180 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/WrapRowsTests.cs @@ -0,0 +1,174 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using System; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup +{ + [TestClass] + public class WrapRowsTests + { + private ExcelWorksheet _sheet; + private ExcelPackage _package; + + [TestInitialize] + public void TestInitialize() + { + _package = new ExcelPackage(); + _sheet = _package.Workbook.Worksheets.Add("test"); + } + + [TestCleanup] + public void TestCleanup() + { + _package.Dispose(); + } + + private void AddRowVector() + { + // A1:F1 = 1..6 + for (var col = 1; col <= 6; col++) + { + _sheet.Cells[1, col].Value = col; + } + } + + private void AddColumnVector() + { + // A1:A6 = 1..6 + for (var row = 1; row <= 6; row++) + { + _sheet.Cells[row, 1].Value = row; + } + } + + [TestMethod] + public void ShouldWrapRowVectorIntoRowsOfThree() + { + AddRowVector(); + _sheet.Cells["A10"].Formula = "WRAPROWS(A1:F1,3)"; + _sheet.Calculate(); + // Expected layout at A10:C11 + // 1 2 3 + // 4 5 6 + Assert.AreEqual(1, _sheet.Cells["A10"].Value); + Assert.AreEqual(2, _sheet.Cells["B10"].Value); + Assert.AreEqual(3, _sheet.Cells["C10"].Value); + Assert.AreEqual(4, _sheet.Cells["A11"].Value); + Assert.AreEqual(5, _sheet.Cells["B11"].Value); + Assert.AreEqual(6, _sheet.Cells["C11"].Value); + } + + [TestMethod] + public void ShouldWrapColumnVectorIntoRowsOfTwo() + { + AddColumnVector(); + _sheet.Cells["C10"].Formula = "WRAPROWS(A1:A6,2)"; + _sheet.Calculate(); + // Expected layout at C10:D12 + // 1 2 + // 3 4 + // 5 6 + Assert.AreEqual(1, _sheet.Cells["C10"].Value); + Assert.AreEqual(2, _sheet.Cells["D10"].Value); + Assert.AreEqual(3, _sheet.Cells["C11"].Value); + Assert.AreEqual(4, _sheet.Cells["D11"].Value); + Assert.AreEqual(5, _sheet.Cells["C12"].Value); + Assert.AreEqual(6, _sheet.Cells["D12"].Value); + } + + [TestMethod] + public void ShouldPadLastRowWithNAByDefault() + { + AddRowVector(); + // 6 items, wrap_count = 4 -> last row has 2 padded cells (#N/A by default) + _sheet.Cells["A10"].Formula = "WRAPROWS(A1:F1,4)"; + _sheet.Calculate(); + Assert.AreEqual(1, _sheet.Cells["A10"].Value); + Assert.AreEqual(2, _sheet.Cells["B10"].Value); + Assert.AreEqual(3, _sheet.Cells["C10"].Value); + Assert.AreEqual(4, _sheet.Cells["D10"].Value); + Assert.AreEqual(5, _sheet.Cells["A11"].Value); + Assert.AreEqual(6, _sheet.Cells["B11"].Value); + var c11 = _sheet.Cells["C11"].Value as ExcelErrorValue; + var d11 = _sheet.Cells["D11"].Value as ExcelErrorValue; + Assert.IsNotNull(c11); + Assert.IsNotNull(d11); + Assert.AreEqual(eErrorType.NA, c11.Type); + Assert.AreEqual(eErrorType.NA, d11.Type); + } + + [TestMethod] + public void ShouldUseSuppliedPadValue() + { + AddRowVector(); + _sheet.Cells["A10"].Formula = "WRAPROWS(A1:F1,4,0)"; + _sheet.Calculate(); + Assert.AreEqual(1, _sheet.Cells["A10"].Value); + Assert.AreEqual(4, _sheet.Cells["D10"].Value); + Assert.AreEqual(5, _sheet.Cells["A11"].Value); + Assert.AreEqual(6, _sheet.Cells["B11"].Value); + Assert.AreEqual(0D, _sheet.Cells["C11"].Value); + Assert.AreEqual(0D, _sheet.Cells["D11"].Value); + } + + [TestMethod] + public void ShouldReturnExactFitWithoutPadding() + { + AddRowVector(); + // 6 items, wrap_count = 3 -> exactly 2 rows, no padding + _sheet.Cells["A10"].Formula = "WRAPROWS(A1:F1,3,\"X\")"; + _sheet.Calculate(); + Assert.AreEqual(6, _sheet.Cells["C11"].Value); + // Make sure the cell below the spill is untouched + Assert.IsNull(_sheet.Cells["A12"].Value); + } + + [TestMethod] + public void ShouldReturnValueErrorFor2dRange() + { + // 2x3 range is not a vector + _sheet.Cells["A1"].Value = 1; + _sheet.Cells["B1"].Value = 2; + _sheet.Cells["C1"].Value = 3; + _sheet.Cells["A2"].Value = 4; + _sheet.Cells["B2"].Value = 5; + _sheet.Cells["C2"].Value = 6; + _sheet.Cells["A10"].Formula = "WRAPROWS(A1:C2,2)"; + _sheet.Calculate(); + var err = _sheet.Cells["A10"].Value as ExcelErrorValue; + Assert.IsNotNull(err); + Assert.AreEqual(eErrorType.Value, err.Type); + } + + [TestMethod] + public void ShouldReturnNumErrorWhenWrapCountIsZero() + { + AddRowVector(); + _sheet.Cells["A10"].Formula = "WRAPROWS(A1:F1,0)"; + _sheet.Calculate(); + var err = _sheet.Cells["A10"].Value as ExcelErrorValue; + Assert.IsNotNull(err); + Assert.AreEqual(eErrorType.Num, err.Type); + } + + [TestMethod] + public void ShouldReturnNumErrorWhenWrapCountIsNegative() + { + AddRowVector(); + _sheet.Cells["A10"].Formula = "WRAPROWS(A1:F1,-1)"; + _sheet.Calculate(); + var err = _sheet.Cells["A10"].Value as ExcelErrorValue; + Assert.IsNotNull(err); + Assert.AreEqual(eErrorType.Num, err.Type); + } + + [TestMethod] + public void ShouldWrapSingleCellAsOneByOne() + { + _sheet.Cells["A1"].Value = 42; + _sheet.Cells["A10"].Formula = "WRAPROWS(A1,1)"; + _sheet.Calculate(); + Assert.AreEqual(42, _sheet.Cells["A10"].Value); + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/CodeTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/CodeTests.cs new file mode 100644 index 0000000000..35235b5138 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/CodeTests.cs @@ -0,0 +1,94 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.Text +{ + [TestClass] + public class CodeTests + { + private ExcelWorksheet _sheet; + private ExcelPackage _package; + + [TestInitialize] + public void TestInitialize() + { + _package = new ExcelPackage(); + _sheet = _package.Workbook.Worksheets.Add("test"); + } + + [TestCleanup] + public void TestCleanup() + { + _package.Dispose(); + } + + [TestMethod] + public void ShouldReturnCodeForUppercaseA() + { + _sheet.Cells["A1"].Formula = "CODE(\"A\")"; + _sheet.Calculate(); + Assert.AreEqual(65d, _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldReturnCodeForLowercaseA() + { + _sheet.Cells["A1"].Formula = "CODE(\"a\")"; + _sheet.Calculate(); + Assert.AreEqual(97d, _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldReturnCodeForFirstCharacterOnly() + { + _sheet.Cells["A1"].Formula = "CODE(\"Hello\")"; + _sheet.Calculate(); + // H = 72 + Assert.AreEqual(72d, _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldReturnCodeForDigit() + { + _sheet.Cells["A1"].Formula = "CODE(\"0\")"; + _sheet.Calculate(); + Assert.AreEqual(48d, _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldReturnCodeForUnicodeCharacter() + { + // Swedish Ã¥ = U+00E5 = 229 + _sheet.Cells["A1"].Formula = "CODE(\"\u00e5\")"; + _sheet.Calculate(); + Assert.AreEqual(229d, _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldReturnCodeForSpace() + { + _sheet.Cells["A1"].Formula = "CODE(\" \")"; + _sheet.Calculate(); + Assert.AreEqual(32d, _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldReturnValueErrorForEmptyString() + { + _sheet.Cells["A1"].Formula = "CODE(\"\")"; + _sheet.Calculate(); + var err = _sheet.Cells["A1"].Value as ExcelErrorValue; + Assert.IsNotNull(err); + Assert.AreEqual(eErrorType.Value, err.Type); + } + + [TestMethod] + public void ShouldReturnCodeFromCellReference() + { + _sheet.Cells["B1"].Value = "Z"; + _sheet.Cells["A1"].Formula = "CODE(B1)"; + _sheet.Calculate(); + Assert.AreEqual(90d, _sheet.Cells["A1"].Value); + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/EncodeUrlTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/EncodeUrlTests.cs new file mode 100644 index 0000000000..0bde14cee2 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/EncodeUrlTests.cs @@ -0,0 +1,76 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.Text +{ + [TestClass] + public class EncodeUrlTests + { + private ExcelWorksheet _sheet; + private ExcelPackage _package; + + [TestInitialize] + public void TestInitialize() + { + _package = new ExcelPackage(); + _sheet = _package.Workbook.Worksheets.Add("test"); + } + + [TestCleanup] + public void TestCleanup() + { + _package.Dispose(); + } + + [TestMethod] + public void ShouldEncodeSpacesAsPercent20() + { + _sheet.Cells["A1"].Formula = "ENCODEURL(\"hello world\")"; + _sheet.Calculate(); + Assert.AreEqual("hello%20world", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldEncodeSpecialCharacters() + { + _sheet.Cells["A1"].Formula = "ENCODEURL(\"a&b=c?d\")"; + _sheet.Calculate(); + Assert.AreEqual("a%26b%3Dc%3Fd", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldNotEncodeUnreservedAscii() + { + _sheet.Cells["A1"].Formula = "ENCODEURL(\"abc-XYZ_123.~\")"; + _sheet.Calculate(); + // Letters, digits, hyphen, underscore, period and tilde are unreserved per RFC 3986. + Assert.AreEqual("abc-XYZ_123.~", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldEncodeUnicodeAsUtf8() + { + _sheet.Cells["A1"].Formula = "ENCODEURL(\"\u00e5\u00e4\u00f6\")"; + _sheet.Calculate(); + // Ã¥ = C3 A5, ä = C3 A4, ö = C3 B6 in UTF-8 + Assert.AreEqual("%C3%A5%C3%A4%C3%B6", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldReturnEmptyStringForEmptyInput() + { + _sheet.Cells["A1"].Formula = "ENCODEURL(\"\")"; + _sheet.Calculate(); + Assert.AreEqual("", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldEncodeCellReference() + { + _sheet.Cells["B1"].Value = "test value"; + _sheet.Cells["A1"].Formula = "ENCODEURL(B1)"; + _sheet.Calculate(); + Assert.AreEqual("test%20value", _sheet.Cells["A1"].Value); + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/UsDollarTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/UsDollarTests.cs new file mode 100644 index 0000000000..1fe2cef9f1 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/UsDollarTests.cs @@ -0,0 +1,104 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using System.Globalization; +using System.Threading; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.Text +{ + [TestClass] + public class UsDollarTests : TestBase + { + private ExcelWorksheet _sheet; + private ExcelPackage _package; + private CultureInfo _originalCulture; + + [TestInitialize] + public void TestInitialize() + { + _package = new ExcelPackage(); + _sheet = _package.Workbook.Worksheets.Add("test"); + SwitchToCulture("en-US"); + } + + [TestCleanup] + public void TestCleanup() + { + SwitchBackToCurrentCulture(); + _package.Dispose(); + } + + [TestMethod] + public void ShouldFormatPositiveNumberWithDefaultDecimals() + { + _sheet.Cells["A1"].Formula = "USDOLLAR(1234.567)"; + _sheet.Calculate(); + Assert.AreEqual("$1,234.57", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldFormatNegativeNumberWithParentheses() + { + _sheet.Cells["A1"].Formula = "USDOLLAR(-1234.567)"; + _sheet.Calculate(); + // en-US currency format renders negatives as ($1,234.57) + Assert.AreEqual("($1,234.57)", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldHonorExplicitDecimals() + { + _sheet.Cells["A1"].Formula = "USDOLLAR(1234.5678,3)"; + _sheet.Calculate(); + Assert.AreEqual("$1,234.568", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldFormatWithZeroDecimals() + { + _sheet.Cells["A1"].Formula = "USDOLLAR(1234.5,0)"; + _sheet.Calculate(); + Assert.AreEqual("$1,235", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldRoundToNegativeDecimals() + { + // -2 decimals rounds to nearest hundred + _sheet.Cells["A1"].Formula = "USDOLLAR(1234.567,-2)"; + _sheet.Calculate(); + Assert.AreEqual("$1,200", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldHandleZero() + { + _sheet.Cells["A1"].Formula = "USDOLLAR(0)"; + _sheet.Calculate(); + Assert.AreEqual("$0.00", _sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ShouldSpillOverRangeFirstArg() + { + _sheet.Cells["B1"].Value = 1234.5; + _sheet.Cells["B2"].Value = 99.9; + _sheet.Cells["A1"].Formula = "USDOLLAR(B1:B2)"; + _sheet.Calculate(); + Assert.AreEqual("$1,234.50", _sheet.Cells["A1"].Value); + Assert.AreEqual("$99.90", _sheet.Cells["A2"].Value); + } + + [TestMethod] + public void ShouldSpillWithBothArgsAsRanges() + { + _sheet.Cells["B1"].Value = 1234.5; + _sheet.Cells["B2"].Value = 99.9; + _sheet.Cells["C1"].Value = 2; + _sheet.Cells["C2"].Value = 0; + _sheet.Cells["A1"].Formula = "USDOLLAR(B1:B2,C1:C2)"; + _sheet.Calculate(); + Assert.AreEqual("$1,234.50", _sheet.Cells["A1"].Value); + Assert.AreEqual("$100", _sheet.Cells["A2"].Value); + } + } +} \ No newline at end of file From e3877f54d18761fc19d82b2a3f70e3a8357d80a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Fri, 29 May 2026 09:11:44 +0200 Subject: [PATCH 003/118] EPPlus version 8.6.0 --- appveyor8.yml | 10 +++++----- docs/articles/fixedissues.md | 30 ++++++++++++++++++++++++------ src/EPPlus/EPPlus.csproj | 19 +++++++++++++------ src/EPPlus/EPPlusLicense.cs | 2 +- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/appveyor8.yml b/appveyor8.yml index c6c17f0c01..d400dad105 100644 --- a/appveyor8.yml +++ b/appveyor8.yml @@ -1,4 +1,4 @@ -version: 8.5.4.{build} +version: 8.6.0.{build} branches: only: - develop8 @@ -10,15 +10,15 @@ install: & $env:temp\dotnet-install.ps1 -Architecture x64 -Version '10.0.100' -InstallDir "$env:ProgramFiles\dotnet" init: - ps: >- - Update-AppveyorBuild -Version "8.5.4.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" + Update-AppveyorBuild -Version "8.6.0.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" - Write-Host "8.5.4.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" + Write-Host "8.6.0.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" dotnet_csproj: patch: true file: '**\*.csproj' version: '{version}' - assembly_version: 8.5.4.{build} - file_version: 8.5.4.{build} + assembly_version: 8.6.0.{build} + file_version: 8.6.0.{build} nuget: project_feed: true before_build: diff --git a/docs/articles/fixedissues.md b/docs/articles/fixedissues.md index 57131a563e..7791e8728f 100644 --- a/docs/articles/fixedissues.md +++ b/docs/articles/fixedissues.md @@ -1,11 +1,29 @@ # Features / Fixed issues - EPPlus 8 -## Version 8.5.5 -### Minor Features -* Ranges can now convert their values to richText via ´ConvertToRichText()´ e.g. ´ws.Cells["A1"].ConvertToRichText()´ -### Fixed issues -* Several issues related to RichText in ranges. Simply looking at the .RichText attribute no longer changes the cell value. Setting a propert e.g. ´range.RichText.Text = "Hi"´ or using ConvertToRichText() actually changes the value. - +## Version 8.6 0 +### Features +* 9 new functions: + * ´GROUPBY´ + * ´PIVOTBY´ + * ´TRIMRANGE´ + * ´WRAPROWS´ + * ´WRAPCOLS´ + * ´USDOLLAR´ + * ´CODE´ + * ´ENCODEURL´ + * ´ISFORMULA´ +* Support for the [trim reference operators](https://github.com/EPPlusSoftware/EPPlus/wiki/Trim-reference-operator). +* Ranges can now convert their values to rich text via ´ConvertToRichText()´ e.g. ´ws.Cells["A1"].ConvertToRichText()´ +* Calculation of dynamic array formulas now support dirty cell recalculation inside other dirty cell calculations. +### Fixed issues +* Fixed a cache issue with the ´IF´ function when recalculation dynamic array formulas with dirty cells. +* ´ExcelPackage.LoadAsync´ failed with an unhandled exception, if a package had a package part larger than 2GB. +* Fixed copy of absolute positioned drawings and group shapes. +* Fixed a performance issue related to copying using full row/column addresses. +* Fixed stale values when using newer worksheet functions (e.g. ARRAYTOTEXT) by adding required features to the workbook XML. +* CompareOptions.Culture in RangeSortOptions was not honored when sorting ranges(thanks to lievendf). +* Copied "Text length data validations" no longer become "Int data validations" in the output workbook. +* Several issues related to RichText in ranges. Accessing the ´ExcelRangeBase.RichText´ property no longer casts the cell value to string. ## Version 8.5.4 ### Minor Features * Added ´IncludeInHtmlOnly´ option to the ´ePictureInclude´ enum. This allows the HTML Exporter to include pictures directly in the HTML output rather than in the CSS. diff --git a/src/EPPlus/EPPlus.csproj b/src/EPPlus/EPPlus.csproj index 4d7244e185..4d67946974 100644 --- a/src/EPPlus/EPPlus.csproj +++ b/src/EPPlus/EPPlus.csproj @@ -1,9 +1,9 @@  net8.0;net9.0;net10.0;netstandard2.1;netstandard2.0;net462;net35 - 8.5.4.0 - 8.5.4.0 - 8.5.4 + 8.6.0.0 + 8.6.0.0 + 8.6.0 true https://epplussoftware.com EPPlus Software AB @@ -18,7 +18,7 @@ readme.md EPPlus Software AB - EPPlus 8.5.4 + EPPlus 8.6.0 IMPORTANT NOTICE! From version 5 EPPlus changes the license model using a dual license, Polyform Non Commercial / Commercial license. @@ -26,9 +26,15 @@ Commercial licenses can be purchased from https://epplussoftware.com This applies to EPPlus version 5 and later. Earlier versions are still licensed LGPL. - ## Version 8.5.4 + ## Version 8.6.0 + * New functions: + * GROUPBY, PIVOTBY, TRIMRANGE, WRAPROWS, WRAPCOLS, USDOLLAR, CODE, ENCODEURL, ISFORMULA + * Support for [Trim reference operator](https://github.com/EPPlusSoftware/EPPlus/wiki/Trim-reference-operator). * Minor bug fixes. See https://epplussoftware.com/Developers/MinorFeaturesAndIssues + ## Version 8.5.4 + * Minor bug fixes. + ## Version 8.5.3 * Downgraded .NET 8 references incorrectly update to 9.x to 8.x. @@ -569,7 +575,8 @@ A list of fixed issues can be found here https://epplussoftware.com/docs/7.0/articles/fixedissues.html Version history - 8.5.4 20260430 Minor bug fixes. See https://epplussoftware.com/Developers/MinorFeaturesAndIssues + 8.6.0 20260529 9 new functions. Support for trim Reference operator. See https://epplussoftware.com/Developers/MinorFeaturesAndIssues + 8.5.4 20260430 Minor bug fixes. 8.5.3 20260416 Updated .NET 8 references incorrectly update to 9.x to 8.x. 8.5.2 20260416 Minor bug fixes. 8.5.1 20260330 Minor bug fixes. diff --git a/src/EPPlus/EPPlusLicense.cs b/src/EPPlus/EPPlusLicense.cs index a042ba413b..a4ec732421 100644 --- a/src/EPPlus/EPPlusLicense.cs +++ b/src/EPPlus/EPPlusLicense.cs @@ -19,7 +19,7 @@ public class EPPlusLicense { private static ExcelPackageConfiguration _configuration = new ExcelPackageConfiguration(); static bool _licenseSet = false; - internal const string _versionDate = "2026-03-06"; + internal const string _versionDate = "2026-05-28"; /// /// The license key used for a commercial license. /// From 2237b14025480c534adb84c2c23f180bf7c7ef22 Mon Sep 17 00:00:00 2001 From: Lieven De Foor <8639525+lievendf@users.noreply.github.com> Date: Fri, 29 May 2026 09:13:55 +0200 Subject: [PATCH 004/118] Fix: ExcelWorksheets.Delete throws NotSupportedException on ExcelChartsheet (#2367) The Delete method was attempting to access the PivotTables collection on all worksheet types. However, ExcelChartsheet throws NotSupportedException when accessing PivotTables. Added a type guard to skip PivotTable cleanup for chart sheets. Co-authored-by: Lieven De Foor --- src/EPPlus/ExcelWorksheets.cs | 17 +++++---- .../Issues/ChartSheetDeleteTests.cs | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 src/EPPlusTest/Issues/ChartSheetDeleteTests.cs diff --git a/src/EPPlus/ExcelWorksheets.cs b/src/EPPlus/ExcelWorksheets.cs index 789e50cf55..9878e1c686 100644 --- a/src/EPPlus/ExcelWorksheets.cs +++ b/src/EPPlus/ExcelWorksheets.cs @@ -470,15 +470,18 @@ public void Delete(int Index) worksheet.Drawings.ClearDrawings(); } - //Remove all comments - if (!(worksheet is ExcelChartsheet) && worksheet.Comments.Count > 0) + if (!(worksheet is ExcelChartsheet)) { - worksheet.Comments.Clear(); - } + //Remove all comments + if (worksheet.Comments.Count > 0) + { + worksheet.Comments.Clear(); + } - while(worksheet.PivotTables.Count>0) - { - worksheet.PivotTables.Delete(worksheet.PivotTables[0]); + while (worksheet.PivotTables.Count > 0) + { + worksheet.PivotTables.Delete(worksheet.PivotTables[0]); + } } //Delete any parts still with relations to the Worksheet. DeleteRelationsAndParts(worksheet.Part); diff --git a/src/EPPlusTest/Issues/ChartSheetDeleteTests.cs b/src/EPPlusTest/Issues/ChartSheetDeleteTests.cs new file mode 100644 index 0000000000..8707aa7b6c --- /dev/null +++ b/src/EPPlusTest/Issues/ChartSheetDeleteTests.cs @@ -0,0 +1,35 @@ +/******************************************************************************* + * Required Notice: Copyright (C) EPPlus Software AB. + * https://epplussoftware.com + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + *******************************************************************************/ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using OfficeOpenXml.Drawing.Chart; + +namespace EPPlusTest.Issues +{ + [TestClass] + public class ChartSheetDeleteTests + { + [TestMethod] + public void DeleteChartSheetShouldNotThrowNotSupportedException() + { + using (var package = new ExcelPackage()) + { + var ws = package.Workbook.Worksheets.Add("Data"); + var chartSheet = package.Workbook.Worksheets.AddChart("ChartSheet", eChartType.ColumnClustered); + + // This call previously threw NotSupportedException because it tried to access chartSheet.PivotTables + package.Workbook.Worksheets.Delete(chartSheet); + + Assert.AreEqual(1, package.Workbook.Worksheets.Count); + } + } + } +} From f591cc224c8f7c30a931a2a171fa256799369411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 2 Jun 2026 16:29:46 +0200 Subject: [PATCH 005/118] Fixes issue #2372 --- .../Ranges/EpplusExcelExternalRangeInfo.cs | 3 ++- .../Issues/FormulaCalculationIssues.cs | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Ranges/EpplusExcelExternalRangeInfo.cs b/src/EPPlus/FormulaParsing/Ranges/EpplusExcelExternalRangeInfo.cs index 0398a8b314..ac3549eb2e 100644 --- a/src/EPPlus/FormulaParsing/Ranges/EpplusExcelExternalRangeInfo.cs +++ b/src/EPPlus/FormulaParsing/Ranges/EpplusExcelExternalRangeInfo.cs @@ -372,7 +372,8 @@ public bool IsHidden(int rowOffset, int colOffset) /// The adjusted address. public FormulaRangeAddress GetAddressDimensionAdjusted(int index) { - return _address; + var dimension = _externalWs?.GetDimension()?.AsFormulaRangeAddress(_address._context); + return dimension ?? _address; } /// diff --git a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs index c29d104049..bbac766f81 100644 --- a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs +++ b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs @@ -1447,7 +1447,7 @@ public void s965_3() using var package = OpenTemplatePackage("s965-Not Calculated.xlsx"); //package.Workbook.Calculate(); var ws = package.Workbook.Worksheets["Calculation"]; - package.Workbook.Calculate(); + package.Workbook.Calculate(); Assert.AreEqual("72201004296", ws.Cells["J2"].Value); Assert.AreEqual("72201024296", ws.Cells["J4"].Value); Assert.IsNull(ws.Cells["J5"].Value); @@ -1537,7 +1537,7 @@ public void s1029() wb.FullCalcOnLoad = false; wb.CalcMode = ExcelCalcMode.Manual; ws.Cells["Q2"].Calculate(); - Assert.AreEqual("365" ,ws.Cells["Q2"].Value); + Assert.AreEqual("365", ws.Cells["Q2"].Value); Assert.AreEqual("181-365", ws.Cells["Q45"].Value); Assert.AreEqual("90", ws.Cells["Q55"].Value); Assert.AreEqual(6D, ws.Cells["A34"].Value); @@ -1646,7 +1646,20 @@ public void s1048() Assert.AreEqual(702D, wsSummary.Cells["C57"].Value); Assert.AreEqual("Yes!", wsSummary.Cells["E57"].Value); - SaveWorkbook("S1048-calculated.xlsx",p); + SaveWorkbook("S1048-calculated.xlsx", p); + } + } + [TestMethod] + public void s1050() + { + using (var p = OpenTemplatePackage("issues\\s1050\\Data File.xlsx")) + { + var ws = p.Workbook.Worksheets["Sheet1"]; + //ws.Calculate(); + + ws.Cells["E1:E2164"].CopyValues(ws.Cells["AU1"]); + + SaveWorkbook("s1050-saved.xlsx", p); } } } From 614974fd5b23148e9b79b3d6c1cb28dcd88397f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Wed, 3 Jun 2026 09:29:46 +0200 Subject: [PATCH 006/118] Updated test --- src/EPPlusTest/Issues/FormulaCalculationIssues.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs index bbac766f81..016d8ca436 100644 --- a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs +++ b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs @@ -1655,10 +1655,14 @@ public void s1050() using (var p = OpenTemplatePackage("issues\\s1050\\Data File.xlsx")) { var ws = p.Workbook.Worksheets["Sheet1"]; - //ws.Calculate(); + ws.Cells["E2:E2164"].Formula = "VLOOKUP(D2,'[1]LIST'!C:C,1,0)"; //1 is the Linked File position + ws.Calculate(); ws.Cells["E1:E2164"].CopyValues(ws.Cells["AU1"]); - + Assert.AreEqual(ws.Cells["E172"].Value, 27823D); + Assert.AreEqual(ws.Cells["E2113"].Value, 15064D); + Assert.AreEqual(ws.Cells["AU172"].Value, 27823D); + Assert.AreEqual(ws.Cells["AU2113"].Value, 15064D); SaveWorkbook("s1050-saved.xlsx", p); } } From 71a9b14c4a37f62c31ee85e9046f242426f07729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Wed, 3 Jun 2026 10:35:18 +0200 Subject: [PATCH 007/118] Fixed test that affected static values of the Chart Style Manager, causing a later test to fail --- src/EPPlusTest/Drawing/Chart/ChartSeriesTest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/EPPlusTest/Drawing/Chart/ChartSeriesTest.cs b/src/EPPlusTest/Drawing/Chart/ChartSeriesTest.cs index bd5edb1e9f..5fcefb8187 100644 --- a/src/EPPlusTest/Drawing/Chart/ChartSeriesTest.cs +++ b/src/EPPlusTest/Drawing/Chart/ChartSeriesTest.cs @@ -117,6 +117,8 @@ public void AddColumnChartSingleSerieWithSecondSerieWithCategoryWithLinear() //which causes System.ArgumentException: Negative percentage not allowed var serie1 = chart.Series.Add(ws.Cells["D2"]); var serie2 = chart.Series.Add(ws.Cells["D2"], ws.Cells["C2"]); + + chart.StyleManager.ColorsManager.Method = OfficeOpenXml.Drawing.Chart.Style.eChartColorStyleMethod.Cycle; } [TestMethod] From 67759b13ac436bd70a11341f914975b38e723403 Mon Sep 17 00:00:00 2001 From: Lieven De Foor <8639525+lievendf@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:48:22 +0200 Subject: [PATCH 008/118] Fix case-sensitivity enum parsing bug in ExcelChartAxisStandard MajorTickMark and MinorTickMark (#2370) Co-authored-by: Lieven De Foor --- .../Drawing/Chart/ExcelChartAxisStandard.cs | 4 +-- src/EPPlusTest/Issues/ChartIssues.cs | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs index be40e1c3a3..0115b2077f 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs @@ -75,7 +75,7 @@ public override eAxisTickMark MajorTickMark { try { - return (eAxisTickMark)Enum.Parse(typeof(eAxisTickMark), v); + return (eAxisTickMark)Enum.Parse(typeof(eAxisTickMark), v, true /* ignoreCase */); } catch { @@ -105,7 +105,7 @@ public override eAxisTickMark MinorTickMark { try { - return (eAxisTickMark)Enum.Parse(typeof(eAxisTickMark), v); + return (eAxisTickMark)Enum.Parse(typeof(eAxisTickMark), v, true /* ignoreCase */); } catch { diff --git a/src/EPPlusTest/Issues/ChartIssues.cs b/src/EPPlusTest/Issues/ChartIssues.cs index f717b73517..4894625adf 100644 --- a/src/EPPlusTest/Issues/ChartIssues.cs +++ b/src/EPPlusTest/Issues/ChartIssues.cs @@ -459,5 +459,39 @@ public void s1038() } } + [TestMethod] + public void ChartAxisTickMarkCaseSensitivity() + { + using (var package = new ExcelPackage()) + { + var ws = package.Workbook.Worksheets.Add("Sheet1"); + var chart = ws.Drawings.AddChart("chart1", eChartType.ColumnClustered); + + // Fetch Category and Value axes + var catAxis = chart.Axis[0]; + var valAxis = chart.Axis[1]; + + // 1. Verify MajorTickMark Case-Insensitive Parsing + valAxis.MajorTickMark = eAxisTickMark.In; + Assert.AreEqual(eAxisTickMark.In, valAxis.MajorTickMark, "MajorTickMark should be parsed back as 'In' instead of defaulting to 'Cross'."); + + valAxis.MajorTickMark = eAxisTickMark.Out; + Assert.AreEqual(eAxisTickMark.Out, valAxis.MajorTickMark, "MajorTickMark should be parsed back as 'Out'."); + + valAxis.MajorTickMark = eAxisTickMark.None; + Assert.AreEqual(eAxisTickMark.None, valAxis.MajorTickMark, "MajorTickMark should be parsed back as 'None'."); + + // 2. Verify MinorTickMark Case-Insensitive Parsing + valAxis.MinorTickMark = eAxisTickMark.In; + Assert.AreEqual(eAxisTickMark.In, valAxis.MinorTickMark, "MinorTickMark should be parsed back as 'In'."); + + valAxis.MinorTickMark = eAxisTickMark.Out; + Assert.AreEqual(eAxisTickMark.Out, valAxis.MinorTickMark, "MinorTickMark should be parsed back as 'Out'."); + + valAxis.MinorTickMark = eAxisTickMark.None; + Assert.AreEqual(eAxisTickMark.None, valAxis.MinorTickMark, "MinorTickMark should be parsed back as 'None'."); + } + } + } } From 74a6bc47e034ce84c31f96c70f63394c515f5877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Thu, 4 Jun 2026 13:49:21 +0200 Subject: [PATCH 009/118] Fixed some trendline issues --- .../Chart/ColumnChartTests.cs | 2 +- .../Chart/LineChartToSvgTests.cs | 20 +++++++------- .../Chart/TrendlineTests.cs | 6 ++--- .../Renderer/Chart/ChartAxisRenderer.cs | 2 +- .../Renderer/Chart/ChartLegendRenderer.cs | 4 +-- .../BarColumnChartTypeDrawer.cs | 9 ++++++- .../Trendlines/ChartTrendlineRenderer.cs | 15 ++++++----- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 26 +++++++++++++++++-- 8 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/ColumnChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/ColumnChartTests.cs index 63b2cd8be4..6d6002687e 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/ColumnChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/ColumnChartTests.cs @@ -21,7 +21,7 @@ public void GenerateSvgForColumnCharts1() //var ix = 1; //var c = ws.Drawings[ix]; - //var svg = renderer.RenderDrawingToSvg(c); + //var svg = c.ToSvg(); //SaveTextFileToWorkbook($"svg\\ChartForSvg_ind{ix++}.svg", svg); var ix = 0; diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index 52eab80bd2..fd4499de0d 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -19,17 +19,17 @@ public void GenerateSvgForLineCharts_sheet1() { var ws = p.Workbook.Worksheets[0]; - //var ix = 6; - //var c = ws.Drawings[ix]; - //var svg = c.ToSvg(); - //SaveTextFileToWorkbook($"svg\\ChartForSvg_ind{ix++}.svg", svg); + var ix = 6; + var c = ws.Drawings[ix]; + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\ChartForSvg_ind{ix++}.svg", svg); - var ix = 0; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\ChartForSvg{ix++}.svg", svg); - } + //var ix = 0; + //foreach (ExcelChart c in ws.Drawings) + //{ + // var svg = c.ToSvg(); + // SaveTextFileToWorkbook($"svg\\ChartForSvg{ix++}.svg", svg); + //} } } diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs index e9df670ed8..842aee6627 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs @@ -13,9 +13,9 @@ public void GenerateSvgForTrendlines_Sheet1() using (var p = OpenTemplatePackage("Trendlines.xlsx")) { var ws = p.Workbook.Worksheets[0]; - //var ix = 2; + //var ix = 4; //var c = ws.Drawings[ix]; - //var svg = renderer.RenderDrawingToSvg(c); + //var svg = c.ToSvg(); //SaveTextFileToWorkbook($"svg\\Trendline_sheet1_ind{ix++}.svg", svg); var ix = 0; @@ -36,7 +36,7 @@ public void GenerateSvgForTrendlines_Sheet2() //var ix = 3; //var c = ws.Drawings[ix]; - //var svg = renderer.RenderDrawingToSvg(c); + //var svg = c.ToSvg(); //SaveTextFileToWorkbook($"svg\\Trendline_sheet1_ind{ix++}.svg", svg); var ix = 0; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 7f3eb4c721..8a7b34ef4a 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -203,7 +203,7 @@ private double GetTextWidest(ExcelChartAxisStandard ax) widest = m.Width; } } - return widest.PointToPixel(); + return widest; } public List Values diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index febf0e50b5..bfe4e19fc0 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -54,7 +54,7 @@ internal ChartLegendRenderer(ChartRenderer sc, bool isDataLabelLegend = false) : LeftMargin = RightMargin = 3; //4px TopMargin = BottomMargin = 3; //4px - _marginItemsWidth = mf.Size; //We use the size of the font as margin between items. + _marginItemsWidth = mf.Size / 2; //We use half the size of the font as margin between items. switch (l.Position) { case eLegendPosition.Top: @@ -603,7 +603,7 @@ private RectRenderItem GetBarSeriesIcon(ExcelChart ct, ExcelChartStandardSerie c double iconTop = 0, iconLeft = 0; pSls?.GetIconTopLeft(out iconTop, out iconLeft); - GetItemPosition(pSls, entryWidth, entryHeight, iconTop, iconTop + (iconHeight / 2), out double x, out double y); + GetItemPosition(pSls, entryWidth, entryHeight, iconLeft, iconTop + (iconHeight / 2), out double x, out double y); item.LineCap = LineCap.Round; item.Left = x; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index d91018e3b1..8c7b661dcf 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -53,7 +53,14 @@ internal BarColumnChartTypeDrawer(ChartRenderer svgChart, ExcelBarChart chartTyp ExcelChartAxisStandard.CalculateStacked100(_valValues); } - CreateTrendlines(chartType, _catValues, _valValues); + //if(chartType.IsTypeBar()) + //{ + // CreateTrendlines(chartType, _valValues, _catValues); + //} + //else + //{ + CreateTrendlines(chartType, _catValues, _valValues); + //} } internal override void DrawSeries() diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index fa9ad5c115..e92028beae 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -47,8 +47,8 @@ public ChartTrendlineRenderer(ChartRenderer svgChart, ExcelChartTrendline trendl { case eTrendLine.Linear: CalculateLinear(); - Coordinates.Add(new Coordinate(_xSerie[0], GetLinearValueAtPosition(_xSerie[0]))); - Coordinates.Add(new Coordinate(_xSerie[_xSerie.Count-1], GetLinearValueAtPosition(_xSerie[_xSerie.Count - 1]))); + Coordinates.Add(new Coordinate(0, GetLinearValueAtPosition(1))); + Coordinates.Add(new Coordinate(_xSerie.Count-1, GetLinearValueAtPosition(_xSerie.Count))); break; case eTrendLine.Exponential: CalculateExponential(); @@ -177,7 +177,7 @@ private void CreateDatalabel() DataLabel.TopMargin = DataLabel.BottomMargin = 2; //Set datalabel position. - if(DataLabel.Left - (DataLabel.Width + 5) > ChartRenderer.Bounds.Right) + if(DataLabel.Left + (DataLabel.Width + 5) > ChartRenderer.Bounds.Right) { DataLabel.Left = ChartRenderer.Bounds.Right - DataLabel.Width; } @@ -215,8 +215,6 @@ private void CalculateLinear() { var n = _xSerie.Count; //var sumX = (double)n * (n + 1) / 2; - var sumX = _xSerie.Sum(x => x); - var sumX2 = _xSerie.Sum(x => x * x); var sumY = _ySerie.Sum(y => y); //var sumX2 = (double)n * (n + 1) * (2 * n + 1) / 6; var sumXY = 0D; @@ -224,9 +222,12 @@ private void CalculateLinear() double slope, intercept; if (double.IsNaN(_trendline.Intercept)) { + var sumX = (double)n * (n + 1) / 2; + var sumX2 = (double)n * (n + 1) * (2 * n + 1) / 6; for (int i = 0; i < _ySerie.Length; i++) { - sumXY += _ySerie[i] * (i + 1); + double x = _xSerie[i]; + sumXY += _ySerie[i] * (i+1); } //Slope @@ -236,6 +237,8 @@ private void CalculateLinear() } else { + var sumX = _xSerie.Sum(x => x); + var sumX2 = _xSerie.Sum(x => x * x); intercept = _trendline.Intercept; for (int i = 0; i < _ySerie.Length; i++) { diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index e37f6fec9a..cc61266c15 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -84,6 +84,16 @@ private void SetAxisPositionsFromPlotarea() if (VerticalAxis != null) { PlaceVerticalAxis(VerticalAxis); + //Make sure the horizontal axis is moved up if the vertical axis has a negative minimum value, so that the 0 value is at the correct position. + if (VerticalAxis.Axis.TickLabelPosition == eTickLabelPosition.NextTo && HorizontalAxis.Axis.AxisType == eAxisType.Val && HorizontalAxis.Min < 0D) + { + Plotarea.Rectangle.Width += VerticalAxis.Rectangle.Width; + Plotarea.Group.Left = VerticalAxis.Rectangle.Left; + var newRight = Plotarea.Group.Left + HorizontalAxis.GetPositionInPlotarea(0D); + var rightDiff = newRight - VerticalAxis.Rectangle.Width; + VerticalAxis.Rectangle.Left = rightDiff; + VerticalAxis.Line.X1 = VerticalAxis.Line.X2 = newRight; + } VerticalAxis.AddTickmarksAndValues(DefItems); } @@ -92,7 +102,7 @@ private void SetAxisPositionsFromPlotarea() PlaceHorizontalAxis(HorizontalAxis); //Make sure the horizontal axis is moved up if the vertical axis has a negative minimum value, so that the 0 value is at the correct position. - if (VerticalAxis.Axis.AxisType == eAxisType.Val && VerticalAxis.Min < 0D) + if (HorizontalAxis.Axis.TickLabelPosition == eTickLabelPosition.NextTo && VerticalAxis.Axis.AxisType == eAxisType.Val && VerticalAxis.Min < 0D) { var newtop = VerticalAxis.GetPositionInPlotarea(0D) + Plotarea.Group.Top; var topDiff = HorizontalAxis.Rectangle.Top - newtop; @@ -162,8 +172,20 @@ private void PlaceVerticalAxis(ChartAxisRenderer verticalAxis) verticalAxis.Rectangle.Height = Plotarea.Rectangle.Height; verticalAxis.Line.Y1 = (float)verticalAxis.Rectangle.Top; verticalAxis.Line.Y2 = (float)verticalAxis.Rectangle.Bottom; - if (verticalAxis.Axis.AxisPosition == eAxisPosition.Left) + var axisPos = verticalAxis.Axis.AxisPosition; + if(verticalAxis.Axis.TickLabelPosition==eTickLabelPosition.High) { + axisPos = axisPos == eAxisPosition.Left ? eAxisPosition.Left : eAxisPosition.Right; + } + //if(verticalAxis.Axis.TickLabelPosition==eTickLabelPosition.NextTo) + //{ + + // verticalAxis.Rectangle.Left = Plotarea.Group.Left - verticalAxis.Rectangle.Width; + // verticalAxis.Line.X1 = verticalAxis.Line.X2 = (float)Plotarea.Group.Left; + //} + //else if (axisPos == eAxisPosition.Left) + if (axisPos == eAxisPosition.Left) + { verticalAxis.Rectangle.Left = Plotarea.Group.Left - verticalAxis.Rectangle.Width; verticalAxis.Line.X1 = verticalAxis.Line.X2 = (float)Plotarea.Group.Left; } From c5cd08efe3b68b35ac41bff705b48e67278797d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Thu, 4 Jun 2026 14:35:31 +0200 Subject: [PATCH 010/118] Fixes issue #2376 --- src/EPPlus/Core/CellStore/RangeDictionary.cs | 1 + src/EPPlusTest/DataValidation/DataValidationTests.cs | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/EPPlus/Core/CellStore/RangeDictionary.cs b/src/EPPlus/Core/CellStore/RangeDictionary.cs index a81ad06e80..c110024c17 100644 --- a/src/EPPlus/Core/CellStore/RangeDictionary.cs +++ b/src/EPPlus/Core/CellStore/RangeDictionary.cs @@ -133,6 +133,7 @@ internal bool Exists(int row, int col) } internal List GetValuesFromRange(int fromRow, int fromCol, int toRow, int toCol) { + if (_addresses.Keys.Count == 0) return new List(); var hs = new HashSet(); long rowSpan = ((fromRow - 1) << 20) | (fromRow - 1); var searchItem = new RangeItem(rowSpan, default); diff --git a/src/EPPlusTest/DataValidation/DataValidationTests.cs b/src/EPPlusTest/DataValidation/DataValidationTests.cs index b99ab5aee4..d0980466a6 100644 --- a/src/EPPlusTest/DataValidation/DataValidationTests.cs +++ b/src/EPPlusTest/DataValidation/DataValidationTests.cs @@ -1295,5 +1295,12 @@ public void DataValidationsPerformance() SaveAndCleanup(p); } } - } + [TestMethod] + public void VerifyClearDataValidationOnEmptyRange() + { + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + ws.Cells[1, 1, 4, 4].DataValidation.ClearDataValidation(); + } + } } \ No newline at end of file From e16b4c2306a8e86f2b60a3c6da730e4895f6e572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Fri, 5 Jun 2026 14:36:19 +0200 Subject: [PATCH 011/118] Removed, renamed and fixed a few issues --- .../Chart/LineChartToSvgTests.cs | 24 ++++- .../RenderItems/GradientFill.cs | 4 + src/EPPlus.DrawingRenderer/Svg/IRender.cs | 8 +- .../Renderer/Chart/ChartLegendRenderer.cs | 2 +- .../Renderer/Chart/ChartTitleRenderer.cs | 9 +- .../ChartTypeDrawers/LineChartTypeDrawer.cs | 1 + .../{ => Chart}/DrawingLegendSerie.cs | 2 +- .../DrawingLegendSeriesIcon.cs} | 2 +- .../Renderer/{ => Chart}/LineMarkerHelper.cs | 2 +- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 29 ++++-- .../Fill/DrawingRenderGradientFill.cs | 5 ++ src/EPPlus/Drawing/Renderer/SvgRange.cs | 89 ------------------- 12 files changed, 69 insertions(+), 108 deletions(-) rename src/EPPlus/Drawing/Renderer/{ => Chart}/DrawingLegendSerie.cs (96%) rename src/EPPlus/Drawing/Renderer/{SvgLegendSeriesIcon.cs => Chart/DrawingLegendSeriesIcon.cs} (85%) rename src/EPPlus/Drawing/Renderer/{ => Chart}/LineMarkerHelper.cs (99%) delete mode 100644 src/EPPlus/Drawing/Renderer/SvgRange.cs diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index fd4499de0d..10df58204e 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -155,9 +155,9 @@ public void GenerateSvgForCharts_SecondaryAxis() using (var p = OpenTemplatePackage("ChartForSvg_SecondaryAxis.xlsx")) { var ws = p.Workbook.Worksheets[0]; - //var ix = 3; + //var ix = 1; //var c = ws.Drawings[ix]; - //var svg = renderer.RenderDrawingToSvg(c); + //var svg = c.ToSvg(); //SaveTextFileToWorkbook($"svg\\ChartForSvg_sheet2_{ix++}.svg", svg); var ix = 1; foreach (ExcelChart c in ws.Drawings) @@ -186,6 +186,26 @@ public void GenerateSvgForCharts_SecondaryAxis_sheet2() } } } + [TestMethod] + public void GenerateSvgForCharts_SecondaryAxis_sheet3() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("ChartForSvg_SecondaryAxis.xlsx")) + { + var ws = p.Workbook.Worksheets[2]; + //var ix = 1; + //var c = ws.Drawings[ix]; + //var svg = c.ToSvg(); + //SaveTextFileToWorkbook($"svg\\ChartForSvg_sheet2_{ix++}.svg", svg); + var ix = 1; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\ChartForSvg_SecAxis{ix++}.svg", svg); + } + } + } + [TestMethod] public void GenerateSimplestChart() { diff --git a/src/EPPlus.DrawingRenderer/RenderItems/GradientFill.cs b/src/EPPlus.DrawingRenderer/RenderItems/GradientFill.cs index a0bd9e0264..214a5fc828 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/GradientFill.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/GradientFill.cs @@ -83,6 +83,10 @@ public RenderGradientFill() public OffsetRectangle FocusPoint { get; set; } = new OffsetRectangle(); public OffsetRectangle TileRectangle { get; set; } = new OffsetRectangle(); public RenderLinearGradientSettings LinearSettings { get; private set; } = new RenderLinearGradientSettings(); + /// + /// If the gradient should use the user space as coordinate system or the bounding box of the item. This is only used for gradient fills and is ignored for other fill types. If true, the gradient will use the user space as coordinate system, if false, the gradient will use the bounding box of the item as coordinate system. + /// + public bool UserSpaceOnUse { get; set; } public override string GetKey() { var sb = new StringBuilder(); diff --git a/src/EPPlus.DrawingRenderer/Svg/IRender.cs b/src/EPPlus.DrawingRenderer/Svg/IRender.cs index b426d81e16..88480d371e 100644 --- a/src/EPPlus.DrawingRenderer/Svg/IRender.cs +++ b/src/EPPlus.DrawingRenderer/Svg/IRender.cs @@ -111,7 +111,7 @@ private void WriteDefsForRenderItem(StringBuilder defSb, HashSet hs, ref var key = item.GradientFill.GetKey(); if (_defsCache.TryGetValue(key, out string? name) == false) { - name = WriteGradient($"Gradient{ix}", defSb, hs, item.GradientFill, item.FillColorSource, true); + name = WriteGradient($"Gradient{ix}", defSb, hs, item.GradientFill, item.FillColorSource); _defsCache[key] = name; } item.FillColor = $"Url(#{name})"; @@ -147,7 +147,7 @@ private void WriteDefsForRenderItem(StringBuilder defSb, HashSet hs, ref var key = item.BorderGradientFill.GetKey(); if (_defsCache.TryGetValue(key, out string? name) == false) { - name = WriteGradient($"StrokeGradient{ix}", defSb, hs, item.BorderGradientFill, item.BorderColorSource, true); + name = WriteGradient($"StrokeGradient{ix}", defSb, hs, item.BorderGradientFill, item.BorderColorSource); _defsCache[key] = name; } item.BorderColor = $"Url(#{name})"; @@ -249,11 +249,11 @@ private string WriteBlip(string namePrefix, StringBuilder defSb, HashSet defSb.Append($""); return name; } - private string WriteGradient(string namePrefix, StringBuilder defSb, HashSet hs, RenderGradientFill gradientFill, PathFillMode fillMode, bool userSpaceOnUse) + private string WriteGradient(string namePrefix, StringBuilder defSb, HashSet hs, RenderGradientFill gradientFill, PathFillMode fillMode) { //var gs = gradientFill.Settings; var name = $"{namePrefix}{fillMode}"; - var grUnits = userSpaceOnUse ? " gradientUnits=\"userSpaceOnUse\"" : ""; + var grUnits = gradientFill.UserSpaceOnUse ? " gradientUnits=\"userSpaceOnUse\"" : ""; if (gradientFill.ShadePath == ShadePath.Linear && hs.Contains(name) == false) { hs.Add(name); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index bfe4e19fc0..3919ead744 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -11,12 +11,12 @@ Date Author Change 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ using EPPlus.DrawingRenderer.RenderItems; -using EPPlus.Export.ImageRenderer.Svg; using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Integration; using EPPlusImageRenderer.RenderItems; using OfficeOpenXml.Drawing; using OfficeOpenXml.Drawing.Chart; +using OfficeOpenXml.Drawing.Renderer.Chart; using OfficeOpenXml.Drawing.Renderer.TextBox; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Style; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs index e31fed1184..bee3ce50ef 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs @@ -120,7 +120,14 @@ private void SetAxisTitleRect(ChartRenderer sc, ChartAxisRenderer axis) Rectangle.Left = sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Right || sc.Chart.Legend.Position == eLegendPosition.TopRight ? sc.Legend.Rectangle.Left - Rectangle.Width - margin : sc.Bounds.Right - Rectangle.Width - margin; break; case eAxisPosition.Bottom: - Rectangle.Top = sc.ChartArea.Rectangle.Height - margin - Rectangle.Height; + if(sc.HorizontalAxis!=null && sc.HorizontalAxis.Axis.HasTitle && sc.HorizontalAxis.Axis.AxisPosition==eAxisPosition.Bottom) + { + Rectangle.Top = sc.HorizontalAxis.Rectangle.Bottom + margin; + } + else + { + Rectangle.Top = sc.ChartArea.Rectangle.Height - margin - Rectangle.Height; + } Rectangle.Left = GetHorizontalLeft(sc); break; case eAxisPosition.Top: diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs index 6c6785f842..89925263fe 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs @@ -6,6 +6,7 @@ using EPPlusImageRenderer.Svg; using OfficeOpenXml.DigitalSignatures; using OfficeOpenXml.Drawing.Chart; +using OfficeOpenXml.Drawing.Renderer.Chart; using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.Utils.TypeConversion; using System; diff --git a/src/EPPlus/Drawing/Renderer/DrawingLegendSerie.cs b/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs similarity index 96% rename from src/EPPlus/Drawing/Renderer/DrawingLegendSerie.cs rename to src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs index 4809d8098b..652f7bf936 100644 --- a/src/EPPlus/Drawing/Renderer/DrawingLegendSerie.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs @@ -18,7 +18,7 @@ Date Author Change namespace EPPlusImageRenderer.Svg { - internal class DrawingLegendSerie : SvgLegendSeriesIcon + internal class DrawingLegendSerie : DrawingLegendSeriesIcon { internal DrawingTextbody Textbox { get; set; } diff --git a/src/EPPlus/Drawing/Renderer/SvgLegendSeriesIcon.cs b/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSeriesIcon.cs similarity index 85% rename from src/EPPlus/Drawing/Renderer/SvgLegendSeriesIcon.cs rename to src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSeriesIcon.cs index 39c0af1908..84ea79dfe7 100644 --- a/src/EPPlus/Drawing/Renderer/SvgLegendSeriesIcon.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSeriesIcon.cs @@ -2,7 +2,7 @@ namespace EPPlusImageRenderer.Svg { - internal class SvgLegendSeriesIcon + internal class DrawingLegendSeriesIcon { internal RenderItem SeriesIcon { get; set; } internal RenderItem MarkerIcon { get; set; } diff --git a/src/EPPlus/Drawing/Renderer/LineMarkerHelper.cs b/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs similarity index 99% rename from src/EPPlus/Drawing/Renderer/LineMarkerHelper.cs rename to src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs index 7a00fbe32c..53d2015c52 100644 --- a/src/EPPlus/Drawing/Renderer/LineMarkerHelper.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs @@ -8,7 +8,7 @@ using System; using System.Collections.Generic; -namespace EPPlus.Export.ImageRenderer.Svg +namespace OfficeOpenXml.Drawing.Renderer.Chart { internal class LineMarkerHelper { diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index cc61266c15..f20872b5ae 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -99,7 +99,7 @@ private void SetAxisPositionsFromPlotarea() if (HorizontalAxis != null && HorizontalAxis.Rectangle != null) { - PlaceHorizontalAxis(HorizontalAxis); + PlaceHorizontalAxis(HorizontalAxis, false); //Make sure the horizontal axis is moved up if the vertical axis has a negative minimum value, so that the 0 value is at the correct position. if (HorizontalAxis.Axis.TickLabelPosition == eTickLabelPosition.NextTo && VerticalAxis.Axis.AxisType == eAxisType.Val && VerticalAxis.Min < 0D) @@ -122,12 +122,12 @@ private void SetAxisPositionsFromPlotarea() if (SecondHorizontalAxis != null && SecondHorizontalAxis.Rectangle != null) { - PlaceHorizontalAxis(SecondHorizontalAxis); + PlaceHorizontalAxis(SecondHorizontalAxis, true); SecondHorizontalAxis.AddTickmarksAndValues(DefItems); } } - private void PlaceHorizontalAxis(ChartAxisRenderer horizontalAxis) + private void PlaceHorizontalAxis(ChartAxisRenderer horizontalAxis, bool isSecondary) { if (horizontalAxis.Axis.Deleted == false) { @@ -143,7 +143,7 @@ private void PlaceHorizontalAxis(ChartAxisRenderer horizontalAxis) } else { - horizontalAxis.Rectangle.Top = Plotarea.Rectangle.Top - horizontalAxis.Rectangle.Height; + horizontalAxis.Rectangle.Top = Plotarea.Group.Top - horizontalAxis.Rectangle.Height; horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = (float)Plotarea.Group.Top; } } @@ -151,14 +151,27 @@ private void PlaceHorizontalAxis(ChartAxisRenderer horizontalAxis) { horizontalAxis.Title.Rectangle.Height = Bounds.Height / 4; horizontalAxis.Title.Rectangle.Width = horizontalAxis.Rectangle?.Width ?? Plotarea.Rectangle.Width; - //horizontalAxis.Title.InitTextBox(); - if (horizontalAxis.Axis.AxisPosition == eAxisPosition.Bottom) + if (horizontalAxis.Axis.Deleted) { - horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Bottom; + if (horizontalAxis.Axis.AxisPosition == eAxisPosition.Bottom) + { + horizontalAxis.Title.TextBox.Top = Plotarea.Group.Top + Plotarea.Rectangle.Height; + } + else + { + horizontalAxis.Title.TextBox.Top = Plotarea.Group.Top - horizontalAxis.Rectangle.Height; + } } else { - horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Top - horizontalAxis.Title.TextBox.Height; + if (horizontalAxis.Axis.AxisPosition == eAxisPosition.Bottom) + { + horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Bottom; + } + else + { + horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Top - horizontalAxis.Title.TextBox.Height; + } } horizontalAxis.Title.TextBox.Left = Plotarea.Group.Left + (Plotarea.Rectangle.Width / 2) - (horizontalAxis.Title.TextBox.Width / 2); } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs index 67e96a4044..c1ef784bf0 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs @@ -50,6 +50,11 @@ public DrawingRenderGradientFill(ExcelTheme theme, ExcelDrawingGradientFill grad { LinearSettings.Angle = gradientFill.LinearSettings.Angle; LinearSettings.Scaled = gradientFill.LinearSettings.Scaled; + UserSpaceOnUse = false; + } + else + { + UserSpaceOnUse = true; } } } diff --git a/src/EPPlus/Drawing/Renderer/SvgRange.cs b/src/EPPlus/Drawing/Renderer/SvgRange.cs deleted file mode 100644 index a7cc43b622..0000000000 --- a/src/EPPlus/Drawing/Renderer/SvgRange.cs +++ /dev/null @@ -1,89 +0,0 @@ -using EPPlusImageRenderer.RenderItems; -using OfficeOpenXml; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace EPPlus.Export.ImageRenderer.Svg -{ - //internal class SvgRange - //{ - // List renderItems = new List(); - // //List textBoxes = new List(); - - // internal SvgRange(ExcelRange range, double totalWidth, double totalHeight) - // { - // SvgRenderRectItem rangeBB = new SvgRenderRectItem(); - - // //To pixel multiplier - // float mult = 96f / 72f; - - // rangeBB.Width = (float)totalWidth; - // rangeBB.Height = (float)totalHeight * mult; - - // rangeBB.BorderColor = "yellow"; - - // rangeBB.BorderWidth = 1; - - // renderItems.Add(rangeBB); - - // float currentWidth = 0f; - - // for (int i = 0; i < range.Columns; i++) - // { - // float currentHeight = 0f; - // var currCol = range.Worksheet.GetColumn(range._fromCol + i); - // var colWidth = currCol == null ? range.Worksheet.DefaultColWidth : currCol.Width; - // colWidth = ExcelColumn.ColumnWidthToPixels(colWidth, range.Worksheet.Workbook.MaxFontWidth); - - // for (int j = 0; j < range.Rows; j++) - // { - // var cell = range.Offset(j, i); - - // var cellContent = range.Offset(j, i).TextForWidth; - // float heightAlt = (float)cell.Worksheet.GetRowHeight(cell._fromRow + j); - // float heightAltPixels = heightAlt * mult; - // float height = (float)cell.Worksheet.Rows[cell._fromRow].Height * mult; - - // SvgRenderRectItem cellBB = new SvgRenderRectItem(); - // cellBB.Left = currentWidth; - // cellBB.Top = currentHeight; - - // cellBB.Width = (float)colWidth; - // cellBB.Height = (float)height; - - // cellBB.BorderWidth = 1; - - // cellBB.BorderColor = "black"; - // cellBB.FillColor = "gray"; - - // renderItems.Add(cellBB); - - // var cellTextBox = new RenderTextbox(null, currentWidth, currentHeight, colWidth, height); - // cellTextBox.AddCellTextRun(cell); - - // var deltaHeight = (float)cellTextBox.Bounds.Height; - // cellBB.Height = cellBB.Height < deltaHeight ? deltaHeight : cellBB.Height; - // textBoxes.Add(cellTextBox); - - // currentHeight += height; - // } - // currentWidth += (float)colWidth; - // } - - // } - - // internal void Render(StringBuilder sb) - // { - // foreach (var item in renderItems) - // { - // item.Render(sb); - // } - // foreach (var item in textBoxes) - // { - // item.RenderTextRuns(sb); - // } - // } - //} -} \ No newline at end of file From 105f3f1194e30ae43d120b11c1619f5d434db6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Fri, 5 Jun 2026 16:17:13 +0200 Subject: [PATCH 012/118] Added new ActualPosition property for axis --- .../Drawing/Chart/ExcelChartAxisStandard.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs index 4ce29e17e2..2f23144d13 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs @@ -158,6 +158,37 @@ internal set } } /// + /// Returns the actual position of the axis, taking into account the position and the label position. + /// For example, if the axis position is left and the label position is high, the actual position will be to the right of the plotarea. + /// + public eAxisPosition ActualAxisPosition + { + get + { + var ap = AxisPosition; + if(ap==eAxisPosition.Left && LabelPosition==eTickLabelPosition.High) + { + return eAxisPosition.Right; + } + else if(ap==eAxisPosition.Right && LabelPosition==eTickLabelPosition.Low) + { + return eAxisPosition.Left; + } + else if(ap==eAxisPosition.Top && LabelPosition==eTickLabelPosition.Low) + { + return eAxisPosition.Bottom; + } + else if(ap==eAxisPosition.Bottom && LabelPosition==eTickLabelPosition.High) + { + return eAxisPosition.Top; + } + else + { + return ap; + } + } + } + /// /// Chart axis title /// public new ExcelChartTitleStandard Title From 181c1ee05c80e338ef16d4e93a6d018829966822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 8 Jun 2026 10:32:43 +0200 Subject: [PATCH 013/118] Re-implemented pie-chart and explosion --- .../ChartTypeDrawers/PieChartTypeDrawer.cs | 16 +++++---- .../Renderer/Chart/PieSliceRenderItem.cs | 35 ++++++++++++------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs index 4ac761a78b..bca35923c5 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs @@ -161,7 +161,7 @@ private void UpdateSlice(ExcelPieChart chartType, ExcelPieChartSerie serie, int var dataPoint = serie.DataPoints[position]; Slices[position].ImportPathData( - ChartRenderer.Plotarea.Rectangle.Bounds, Rectangle.Bounds, + ChartRenderer.Plotarea.Rectangle.Bounds, ChartRenderer.Bounds, _sliceScaleFactor, dataPoint.Explosion, _pieExplosionPercent, position); Slices[position].ImportStlyeInfo(dataPoint, chartType); @@ -170,9 +170,12 @@ private void UpdateSlice(ExcelPieChart chartType, ExcelPieChartSerie serie, int internal override void DrawSeries() { - _groupItem = new GroupRenderItem(ChartRenderer.Bounds); - _groupItem.TranslationOffset.Left = ChartRenderer.Plotarea.Rectangle.Left; - _groupItem.TranslationOffset.Top = ChartRenderer.Plotarea.Rectangle.Top; + _groupItem = new GroupRenderItem(ChartRenderer.Plotarea.Group.Bounds); + + //_groupItem.Left = ChartRenderer.Plotarea.Group.Left; + //_groupItem.Top = ChartRenderer.Plotarea.Group.Top; + + //_groupItem.TransformOrigin = new Coordinate(ChartRenderer.Plotarea.LeftMargin, ChartRenderer.Plotarea.TopMargin); Rectangle.Bounds.Name = "ChartDrawer"; @@ -263,8 +266,9 @@ internal override void DrawSeries() public override void AppendRenderItems(List renderItems) { - renderItems.AddRange(ChartAreaRenderItems); - SeriesRenderItems.ForEach(x => ChartRenderer.Plotarea.Group.AddChildItem(x)); + ChartRenderer.Plotarea.Group.AddChildItem(_groupItem); + //renderItems.AddRange(ChartAreaRenderItems); + //SeriesRenderItems.ForEach(x => _groupItem.AddChildItem(x)); } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs b/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs index 4570bd81ed..d06e699cb0 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs @@ -63,6 +63,7 @@ internal Coordinate GetEndPointLocal() PathRenderItem _slicePath; PathRenderItem _debugBoundsPath; + RectRenderItem _debugCircleCenter; bool ExistWithinRange(double target, double min, double max) { @@ -249,7 +250,7 @@ internal void ImportPathData(BoundingBox plotAreaBounds, BoundingBox globalAreaB _slicePath.Commands.Add(lineToStart); _slicePath.Commands.Add(arcCommand); - if (position == -1) + if (position != -1) { //Visualize all points AddDebugLines(moveCenter, plotAreaBounds); @@ -266,21 +267,16 @@ internal void ImportPathData(BoundingBox plotAreaBounds, BoundingBox globalAreaB /// private void AddDebugLines(PathCommands moveCenter, BoundingBox bounds) { - //var lineToMidPoint = new PathCommands(PathCommandType.Line, _slicePath, _midPoint.Left, _midPoint.Top); - //var lineToEnd = new PathCommands(PathCommandType.Line, _slicePath, _endPoint.Left, _endPoint.Top); - //_slicePath.Commands.Add(moveCenter); - //_slicePath.Commands.Add(lineToMidPoint); - //_slicePath.Commands.Add(moveCenter); - //_slicePath.Commands.Add(lineToEnd); + //Render bounds for slice _debugBoundsPath = new PathRenderItem(bounds); _debugBoundsPath.BorderColor = "red"; _debugBoundsPath.FillColor = "transparent"; _debugBoundsPath.BorderWidth = 3; - var moveCenterDebug = new PathCommands(PathCommandType.Move, _circleCenter.Left, _circleCenter.Top); + var moveCenterDebug = new PathCommands(PathCommandType.Move, ExtremePoints.Left, ExtremePoints.Top); _debugBoundsPath.Commands.Add(moveCenterDebug); //Draw extremes/bounds - var lineToTopLeft = new PathCommands(PathCommandType.Line, ExtremePoints.Left, ExtremePoints.Top); + //var lineToTopLeft = new PathCommands(PathCommandType.Line, ExtremePoints.Left, ExtremePoints.Top); var lineToTopRight = new PathCommands(PathCommandType.Line, ExtremePoints.Right, ExtremePoints.Top); //var sliceCenter = GetSliceShapeCenterLocal(); @@ -290,13 +286,23 @@ private void AddDebugLines(PathCommands moveCenter, BoundingBox bounds) var lineToBottomLeft = new PathCommands(PathCommandType.Line, ExtremePoints.Left, ExtremePoints.Bottom); var end = new PathCommands(PathCommandType.End, ExtremePoints.Left, ExtremePoints.Top); - _debugBoundsPath.Commands.Add(lineToTopLeft); + //_debugBoundsPath.Commands.Add(lineToTopLeft); _debugBoundsPath.Commands.Add(lineToTopRight); ////_debugBoundsPath.Commands.Add(lineToSliceCenter); _debugBoundsPath.Commands.Add(lineToBottomRight); _debugBoundsPath.Commands.Add(lineToBottomLeft); - //_debugBoundsPath.Commands.Add(end); - + //_debugBoundsPath.Commands.Add(lineToTopLeft); + _debugBoundsPath.Commands.Add(end); + + //Render green dot at circle center + _debugCircleCenter = new RectRenderItem(bounds); + _debugCircleCenter.Left = _circleCenter.Left; + _debugCircleCenter.Top = _circleCenter.Top; + _debugCircleCenter.Bounds.Left -= 2.5d; + _debugCircleCenter.Bounds.Top -= 2.5d; + _debugCircleCenter.Bounds.Width = 5d; + _debugCircleCenter.Bounds.Height = 5d; + _debugCircleCenter.FillColor = "green"; } internal void ImportStlyeInfo(ExcelChartDataPoint dp, ExcelPieChart chartType) @@ -308,6 +314,10 @@ internal void ImportStlyeInfo(ExcelChartDataPoint dp, ExcelPieChart chartType) internal void AppendGroupItem(GroupRenderItem group) { + //Apply translation after all calculations are done + _innerGroup.Left += _innerGroup.TranslationOffset.Left; + _innerGroup.Top += _innerGroup.TranslationOffset.Top; + //The slice items post transform operations _innerItems.AddChildItem(_slicePath); //The bounds and translations of the slice @@ -317,6 +327,7 @@ internal void AppendGroupItem(GroupRenderItem group) { //adding the debug lines _innerGroup.AddChildItem(_debugBoundsPath); + _innerGroup.AddChildItem(_debugCircleCenter); } //The group containing all slices From 65d52da34945626f40366a9258eb4cdd3bb916dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Mon, 8 Jun 2026 10:53:29 +0200 Subject: [PATCH 014/118] Fixed trendline formulas to add superscript to textboxs --- .../RichTextFormatDrawing.cs | 7 +++ .../Drawing/Chart/ExcelChartAxisStandard.cs | 47 +++++++++++++++---- .../Drawing/Chart/enums/eAxisPosition.cs | 35 ++++++++++++++ .../Trendlines/ChartTrendlineRenderer.cs | 31 +++++++++++- .../Trendlines/SvgTrendlineDataLabel.cs | 10 ---- .../Issues/FormulaCalculationIssues.cs | 4 +- 6 files changed, 110 insertions(+), 24 deletions(-) delete mode 100644 src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/SvgTrendlineDataLabel.cs diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs index 07c5e253e9..d33c87c048 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs @@ -101,5 +101,12 @@ public RichTextFormatDrawing(string text, string fontFamily, float size, bool bo StrikeType = eDrawingStrikeType.No; UnderlineType = eDrawingUnderLineType.None; } + + public RichTextFormatDrawing(FontFormatBase defaultParagraphFont) + { + Family = defaultParagraphFont.Family; + SubFamily = defaultParagraphFont.SubFamily; + Size = defaultParagraphFont.Size; + } } } diff --git a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs index 2f23144d13..de5a081621 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs @@ -15,6 +15,7 @@ Date Author Change using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; using OfficeOpenXml.Style.XmlAccess; using OfficeOpenXml.Utils.EnumUtils; using OfficeOpenXml.Utils.TypeConversion; @@ -161,30 +162,58 @@ internal set /// Returns the actual position of the axis, taking into account the position and the label position. /// For example, if the axis position is left and the label position is high, the actual position will be to the right of the plotarea. /// - public eAxisPosition ActualAxisPosition + public eActualAxisPosition ActualAxisPosition { get { var ap = AxisPosition; - if(ap==eAxisPosition.Left && LabelPosition==eTickLabelPosition.High) + if(ap==eAxisPosition.Right && LabelPosition==eTickLabelPosition.Low) { - return eAxisPosition.Right; + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition == eTickLabelPosition.Low) + { + return eActualAxisPosition.Right; + } + else + { + return eActualAxisPosition.RightSecond; + } } - else if(ap==eAxisPosition.Right && LabelPosition==eTickLabelPosition.Low) + else if(ap==eAxisPosition.Right && LabelPosition==eTickLabelPosition.High) { - return eAxisPosition.Left; + if(_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition == eTickLabelPosition.High) + { + return eActualAxisPosition.Left; + } + else + { + return eActualAxisPosition.LeftSecond; + } } else if(ap==eAxisPosition.Top && LabelPosition==eTickLabelPosition.Low) { - return eAxisPosition.Bottom; + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition == eTickLabelPosition.Low) + { + return eActualAxisPosition.Bottom; + } + else + { + return eActualAxisPosition.BottomSecond; + } } else if(ap==eAxisPosition.Bottom && LabelPosition==eTickLabelPosition.High) - { - return eAxisPosition.Top; + { + if(_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition == eTickLabelPosition.High) + { + return eActualAxisPosition.Top; + } + else + { + return eActualAxisPosition.TopSecond; + } } else { - return ap; + return (eActualAxisPosition)ap; } } } diff --git a/src/EPPlus/Drawing/Chart/enums/eAxisPosition.cs b/src/EPPlus/Drawing/Chart/enums/eAxisPosition.cs index 0cbcd13295..823243bf3f 100644 --- a/src/EPPlus/Drawing/Chart/enums/eAxisPosition.cs +++ b/src/EPPlus/Drawing/Chart/enums/eAxisPosition.cs @@ -34,4 +34,39 @@ public enum eAxisPosition /// Top = 3 } + public enum eActualAxisPosition + { + /// + /// Left + /// + Left = 0, + /// + /// Bottom + /// + Bottom = 1, + /// + /// Right + /// + Right = 2, + /// + /// Top + /// + Top = 3, + /// + /// If there are two axis on the left side, this is the second axis left (most to the left) + /// + LeftSecond = 5, + /// + /// If there are two axis on the right side, this is the second axis right (most to the right) + /// + RightSecond = 7, + /// + /// If there are two axis on the top, this is the second top left (most to the top) + /// + TopSecond = 9, + /// + /// If there are two axis on the bottom, this is the second bottom left (most to the top) + /// + BottomSecond = 11 + } } \ No newline at end of file diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index e92028beae..1c03130bff 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -12,6 +12,7 @@ Date Author Change *************************************************************************************************/ using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; +using EPPlus.DrawingRenderer.RenderItems.Textbox; using EPPlusImageRenderer; using EPPlusImageRenderer.RenderItems; using EPPlusImageRenderer.Svg; @@ -172,7 +173,8 @@ private void CreateDatalabel() labelText += RSquare; } lbl.TextBody.Paragraphs[0].HorizontalAlignment = OfficeOpenXml.Drawing.eTextAlignment.Center; - DataLabel.ImportParagraph(lbl.TextBody.Paragraphs[0], 0, labelText); + DataLabel.ImportParagraph(lbl.TextBody.Paragraphs[0], 0, ""); + AddLblText(DataLabel, labelText); DataLabel.LeftMargin = DataLabel.RightMargin = 4; DataLabel.TopMargin = DataLabel.BottomMargin = 2; @@ -211,6 +213,31 @@ private void CreateDatalabel() DataLabel.Rectangle.SetDrawingPropertiesEffects(ChartRenderer.Theme, _trendline.Label.Effect); } + private void AddLblText(DrawingTextBox lbl, string labelText) + { + int pIx = 0; + var ix = labelText.IndexOf("|ss:"); + + if (ix < 0) + { + lbl.TextBody.Paragraphs[0].AddText(labelText); + } + while(ix>=0 && ix < labelText.Length) + { + lbl.TextBody.Paragraphs[0].AddText(labelText.Substring(pIx, ix - pIx)); + var endIx = labelText.IndexOf("|", ix + 4); + var rt = new RichTextFormatDrawing(lbl.TextBody.Paragraphs[0].DefaultParagraphFont) { SuperScript = true, Text = labelText.Substring(ix+4, endIx-ix-4)}; + lbl.TextBody.Paragraphs[0].AddRichText(rt); + pIx = endIx+1; + ix = labelText.IndexOf("|ss:", pIx); + } + + if(pIx < labelText.Length) + { + lbl.TextBody.Paragraphs[0].AddText(labelText.Substring(pIx, labelText.Length - pIx)); + } + } + private void CalculateLinear() { var n = _xSerie.Count; @@ -306,7 +333,7 @@ private void CalculateExponential() var r2 = Math.Pow(Pearson.PearsonImpl(_ySerie.Cast(), GetExponentialSerie(slope, intercept)), 2); Coefficients = [slope, intercept]; - Formula = $"y={intercept:G5}|ss:e{slope:G3}|"; + Formula = $"y={intercept:G5}e|ss:{slope:G3}|"; RSquare = $"R²={r2:N4}"; } private void CalculateLogarithmic() diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/SvgTrendlineDataLabel.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/SvgTrendlineDataLabel.cs deleted file mode 100644 index 2179533b59..0000000000 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/SvgTrendlineDataLabel.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -namespace EPPlus.Export.ImageRenderer.Svg.Chart.ChartTypeDrawers.Trendlines -{ - internal class SvgTrendlineDataLabel - { - } -} diff --git a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs index c29d104049..2544b23286 100644 --- a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs +++ b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs @@ -1626,7 +1626,6 @@ public void s1048() // Attach the logger before the calculation is performed. p.Workbook.FormulaParserManager.AttachLogger(logfile); - //ws.Cells["C56"].Calculate(new ExcelCalculationOption() { AllowCircularReferences = true }); p.Workbook.CalcMode = ExcelCalcMode.Manual; p.Workbook.FullCalcOnLoad = false; @@ -1650,5 +1649,4 @@ public void s1048() } } } -} - +} \ No newline at end of file From 74cef878080c918e3beaa80ee3e9123d9137afc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 8 Jun 2026 12:33:42 +0200 Subject: [PATCH 015/118] Added debug properties to pie slices --- .../Renderer/Chart/PieSliceRenderItem.cs | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs b/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs index d06e699cb0..587180a8be 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs @@ -58,10 +58,13 @@ internal Coordinate GetMidPointLocal() /// internal Coordinate GetEndPointLocal() { - return new Coordinate(_endPoint.Left, _endPoint.Top); + return new Coordinate(_endPoint.LocalPosition.X, _endPoint.LocalPosition.Y); } PathRenderItem _slicePath; + + + List DebugItems; PathRenderItem _debugBoundsPath; RectRenderItem _debugCircleCenter; @@ -267,6 +270,8 @@ internal void ImportPathData(BoundingBox plotAreaBounds, BoundingBox globalAreaB /// private void AddDebugLines(PathCommands moveCenter, BoundingBox bounds) { + DebugItems = new List(); + //Render bounds for slice _debugBoundsPath = new PathRenderItem(bounds); _debugBoundsPath.BorderColor = "red"; @@ -294,17 +299,36 @@ private void AddDebugLines(PathCommands moveCenter, BoundingBox bounds) //_debugBoundsPath.Commands.Add(lineToTopLeft); _debugBoundsPath.Commands.Add(end); + DebugItems.Add(_debugBoundsPath); + //Render green dot at circle center - _debugCircleCenter = new RectRenderItem(bounds); - _debugCircleCenter.Left = _circleCenter.Left; - _debugCircleCenter.Top = _circleCenter.Top; - _debugCircleCenter.Bounds.Left -= 2.5d; - _debugCircleCenter.Bounds.Top -= 2.5d; - _debugCircleCenter.Bounds.Width = 5d; - _debugCircleCenter.Bounds.Height = 5d; - _debugCircleCenter.FillColor = "green"; + _debugCircleCenter = GenerateDebugPoint(bounds, new Coordinate(_circleCenter.Left, _circleCenter.Top), "green"); + + DebugItems.Add(_debugCircleCenter); + + //render dots at points + var pointColor = "yellow"; + + var startDebug = GenerateDebugPoint(bounds, GetStartPointPositionLocal(), pointColor); + var midDebug = GenerateDebugPoint(bounds, GetMidPointLocal(), pointColor); + var endDebug = GenerateDebugPoint(bounds, GetEndPointLocal(), pointColor); + + DebugItems.Add(startDebug); + DebugItems.Add(midDebug); + DebugItems.Add(endDebug); } + private RectRenderItem GenerateDebugPoint(BoundingBox parent, Coordinate point, string fillColor) + { + double l = -2.5d; + double t = -2.5d; + double w = 5d; + double h = 5d; + + return new RectRenderItem(parent) { Left = l + point.X, Top = t + point.Y, Width = w, Height = h, FillColor = fillColor }; + } + + internal void ImportStlyeInfo(ExcelChartDataPoint dp, ExcelPieChart chartType) { _slicePath.SetDrawingPropertiesFill(ChartRenderer.Theme, dp.Fill, chartType.StyleManager.Style.DataPoint.FillReference.Color); @@ -323,11 +347,12 @@ internal void AppendGroupItem(GroupRenderItem group) //The bounds and translations of the slice _innerGroup.AddChildItem(_innerItems); - if (_debugBoundsPath != null) + if (DebugItems != null && DebugItems.Count > 0) { - //adding the debug lines - _innerGroup.AddChildItem(_debugBoundsPath); - _innerGroup.AddChildItem(_debugCircleCenter); + foreach(var debugItem in DebugItems) + { + _innerGroup.AddChildItem(debugItem); + } } //The group containing all slices From 2d73a982e200b7f2cf86792278591b6296ea4010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 8 Jun 2026 12:40:39 +0200 Subject: [PATCH 016/118] Deactivated debug props + cleanup --- .../Renderer/Chart/PieSliceRenderItem.cs | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs b/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs index 587180a8be..037532da46 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs @@ -6,7 +6,6 @@ using EPPlusImageRenderer.RenderItems; using EPPlusImageRenderer.Svg; using OfficeOpenXml.Drawing.Chart; -using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.Utils.Drawing; using System; using System.Collections.Generic; @@ -155,19 +154,6 @@ void CalculateWidthHeight(double prevSliceDegrees) ExtremePoints = new BoundingBox(minX, minY, maxX - minX, maxY - minY); ExtremePoints.Parent = _innerGroup.Bounds; - //if(Degrees > 0) - //{ - - //} - //if(Degrees) - //double xMax = Math.Max(_startPoint.Left, _endPoint.Left); - //xMax = Math.Max(xMax, _midPoint.Left); - - //double yMax = Math.Max(_startPoint.Top, _endPoint.Top); - //yMax = Math.Max(yMax, _midPoint.Top); - //double yMax; - //double xMin; - //double yMin; } private double _sliceScaleFactor = 1d; @@ -186,11 +172,6 @@ private void CalculateExplosionDir() CtrToOuterMidDir = new Vector2(pieDirection.X, pieDirection.Y); } - //internal Graphics.Math.Vector2 GetVectorCtrToEnd() - //{ - // //_circleCenter + _sc - //} - public PieSliceRenderItem(ChartRenderer renderer, BoundingBox parent, Point circleCenter, double radius, double percentOfPie, double prevSliceDegrees) : base(renderer) { Rectangle.Bounds.Parent = parent; @@ -253,7 +234,8 @@ internal void ImportPathData(BoundingBox plotAreaBounds, BoundingBox globalAreaB _slicePath.Commands.Add(lineToStart); _slicePath.Commands.Add(arcCommand); - if (position != -1) + //Change to != -1 to activate debug items + if (position == -1) { //Visualize all points AddDebugLines(moveCenter, plotAreaBounds); @@ -284,19 +266,13 @@ private void AddDebugLines(PathCommands moveCenter, BoundingBox bounds) //var lineToTopLeft = new PathCommands(PathCommandType.Line, ExtremePoints.Left, ExtremePoints.Top); var lineToTopRight = new PathCommands(PathCommandType.Line, ExtremePoints.Right, ExtremePoints.Top); - //var sliceCenter = GetSliceShapeCenterLocal(); - //var lineToSliceCenter = new PathCommands(PathCommandType.Line, _debugBoundsPath, sliceCenter.Left, sliceCenter.Top); - var lineToBottomRight = new PathCommands(PathCommandType.Line, ExtremePoints.Right, ExtremePoints.Bottom); var lineToBottomLeft = new PathCommands(PathCommandType.Line, ExtremePoints.Left, ExtremePoints.Bottom); var end = new PathCommands(PathCommandType.End, ExtremePoints.Left, ExtremePoints.Top); - //_debugBoundsPath.Commands.Add(lineToTopLeft); _debugBoundsPath.Commands.Add(lineToTopRight); - ////_debugBoundsPath.Commands.Add(lineToSliceCenter); _debugBoundsPath.Commands.Add(lineToBottomRight); _debugBoundsPath.Commands.Add(lineToBottomLeft); - //_debugBoundsPath.Commands.Add(lineToTopLeft); _debugBoundsPath.Commands.Add(end); DebugItems.Add(_debugBoundsPath); @@ -512,12 +488,6 @@ Coordinate GetFinalLocalTranslation(Vector2 LocalTranslationVector, Vector2 loca //The length is within the bounds. No binding neccesary. translationLeft = LocalTranslationVector.X; translationTop = LocalTranslationVector.Y; - - //translationLeft = Math.Min(translateX, maxTranslationXWorld); - //translationTop = Math.Min(translateY, maxTranslationY); - - //translationLeft = Math.Max(translationLeft, minTranslationXWorld); - //translationTop = Math.Max(translationTop, minTranslationYWorld); } return new Coordinate(translationLeft, translationTop); From 2a887cd4474cb1dca4c1ab9df8ae8dc574530c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 8 Jun 2026 12:52:58 +0200 Subject: [PATCH 017/118] Simplified constructor --- .../Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs index d33c87c048..cc4df26c5f 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs @@ -104,9 +104,7 @@ public RichTextFormatDrawing(string text, string fontFamily, float size, bool bo public RichTextFormatDrawing(FontFormatBase defaultParagraphFont) { - Family = defaultParagraphFont.Family; - SubFamily = defaultParagraphFont.SubFamily; - Size = defaultParagraphFont.Size; + SetFont(defaultParagraphFont); } } } From a3b7262281be21a02669aaf9d25be66a16ecccb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 8 Jun 2026 15:59:39 +0200 Subject: [PATCH 018/118] Fixed most trendline sub/superscript issues + rect --- .../Chart/TrendlineTests.cs | 22 +++++++++ .../Textbox/ParagraphRenderItem.cs | 45 +++++++++++++++++++ .../RenderItems/Textbox/RenderTextBody.cs | 22 +++++++++ .../RenderItems/Textbox/TextRunRenderItem.cs | 16 ++++++- .../Textbox/DrawingParagraphRenderItem.cs | 2 +- .../RenderItems/Textbox/DrawingTextBox.cs | 12 ++++- 6 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs index 842aee6627..219689525b 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs @@ -26,6 +26,28 @@ public void GenerateSvgForTrendlines_Sheet1() } } } + + [TestMethod] + public void TrendlineAlt() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("TrendlineAlt.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + //var ix = 4; + //var c = ws.Drawings[ix]; + //var svg = c.ToSvg(); + //SaveTextFileToWorkbook($"svg\\Trendline_sheet1_ind{ix++}.svg", svg); + + var ix = 0; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\TrendlineAlt_sheet1_{ix++}.svg", svg); + } + } + } + [TestMethod] public void GenerateSvgForTrendlines_Sheet2() { diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs index 2211eeb9ab..77188f41c7 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs @@ -1,5 +1,6 @@ using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; +using EPPlus.DrawingRenderer.RenderItems.Textbox; using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Integration; using EPPlus.Fonts.OpenType.Integration.DataHolders; @@ -8,6 +9,9 @@ using EPPlus.Graphics; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.RichText; +using System.Drawing; +using System.Text; +using static System.Net.Mime.MediaTypeNames; namespace EPPlus.Export.ImageRenderer.RenderItems.Shared { @@ -143,6 +147,11 @@ protected ParagraphRenderItem(BoundingBox parent, RenderTextBody textBody, IRich AddRichText(rtFormat); } + protected ParagraphRenderItem(BoundingBox parent, RenderTextBody textBody, IRichTextFormatDrawing rtFormat) : this(parent, textBody, false) + { + AddRichText(rtFormat); + } + protected double GetAlignmentHorizontal(TextAlignment txAlignment) { double x = 0; @@ -332,15 +341,51 @@ protected void SetHorizontalAlignment(double widthOfLargestLine) } } + void ImportStyles() + { + foreach (var run in Runs) + { + var rt = _layoutSystem.InputFragments[run.OriginalRtIdx].RichTextOptions; + if(rt is RichTextFormatDrawing) + { + //Import drawing data + run.ImportRichTextData((RichTextFormatDrawing)rt); + } + else if(rt is IRichTextFormatSimple) + { + //Import basic/cell data + run.ImportRichTextData((IRichTextFormatSimple)rt); + } + else + { + //Import only essential font data + run.ImportFontData((IFontFormatBase)rt); + } + } + } + public void AddRichText(IRichTextFormatSimple richText) { + //TODO: Fix superScript/subScript should apply baseLine changes appropriately + + AddRichTextBase(richText); + WrapTextFragmentsAndGenerateTextRuns(); + } + + public void AddRichText(IRichTextFormatDrawing richText) + { + //adjust size in accordance with baseline + richText.Size = richText.Baseline == 0 ? richText.Size : (float)(richText.Size * (1 - (Math.Abs(richText.Baseline) / 100))); + AddRichTextBase(richText); WrapTextFragmentsAndGenerateTextRuns(); + ImportStyles(); } public void AddText(string text) { ImportLinesAndTextRunsBase(text); + ImportStyles(); } protected abstract TextRunRenderItem CreateTextRun(BoundingBox parent, string displayText, int origRtIdx); diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs index 204b101fbe..64561749c0 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -125,6 +125,28 @@ public ParagraphRenderItem AddParagraph(string text = null) return paragraph; } + public void ApplyAutoSize() + { + if (AutoSize) + { + var currentHeight = 0d; + var currentWidth = 0d; + + foreach(var paragraph in Paragraphs) + { + currentHeight += paragraph.Bounds.Height; + + if (currentWidth < paragraph.Bounds.Width || currentWidth == MaxWidth) + { + currentWidth = paragraph.Bounds.Width; + } + } + + Bounds.Width = currentWidth; + Bounds.Height = currentHeight; + } + } + private void AdjustAndAddParagraph(ParagraphRenderItem paragraph) { paragraph.Bounds.Name = $"Container{Paragraphs.Count}"; diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/TextRunRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/TextRunRenderItem.cs index f9e0793632..0b04d4f66b 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/TextRunRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/TextRunRenderItem.cs @@ -1,13 +1,14 @@ using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; +using EPPlus.DrawingRenderer.RenderItems.Textbox; using EPPlus.Fonts.OpenType.Integration.DataHolders; using EPPlus.Fonts.OpenType.Utils; using EPPlus.Graphics; +using EPPlusImageRenderer.Utils; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.RichText; using System.Drawing; using System.Text.RegularExpressions; -using EPPlusImageRenderer.Utils; namespace EPPlus.Export.ImageRenderer.RenderItems.Shared { @@ -126,7 +127,7 @@ public abstract class TextRunRenderItem : RenderItem protected internal bool _isItalic = false; protected internal bool _isBold = false; protected internal eDrawingUnderLineType _underLineType = eDrawingUnderLineType.None; - protected internal eDrawingStrikeType _strikeType; + protected internal eDrawingStrikeType _strikeType = eDrawingStrikeType.No; protected internal Color _underlineColor; protected internal double _baseline; @@ -153,6 +154,17 @@ public void ImportRichTextData(IRichTextFormatSimple rt) { InitializeBase(rt); FillColor = "#" + rt.FontColor.To6CharHexStringImage(); + _underLineType = (int)rt.UnderlineType == -1 ? eDrawingUnderLineType.None : (eDrawingUnderLineType)rt.UnderlineType; + _underlineColor = rt.UnderlineColor; + } + + public void ImportRichTextData(IRichTextFormatDrawing rt) + { + InitializeBase(rt); + FillColor = "#" + rt.FontColor.To6CharHexStringImage(); + _baseline = rt.Baseline; + _strikeType = (int)rt.StrikeType == -1 ? eDrawingStrikeType.No : rt.StrikeType; + _underLineType = (int)rt.UnderlineType == -1 ? eDrawingUnderLineType.None : rt.UnderlineType; } internal protected void InitializeBase(IFontFormatBase font) diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs index 6bc07e58c6..7ebdb35ebf 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs @@ -55,7 +55,7 @@ public DrawingParagraphRenderItem(DrawingTextbody textBody, BoundingBox parent, ImportMarginAndIndent(p); ImportAlignment(textBody.AutoSize, textBody.MaxWidth, parent.Width); - + //---Initialize / calculate lines and runs--- //measurer must be set before AddLinesAndRichText DefaultParagraphFont = new FontFormatBase(p.DefaultRunProperties.GetMeasureFont()); diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs index 9bdb7605d2..9e9812e88b 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -26,7 +26,7 @@ private void Init(ExcelDrawing drawing, BoundingBox parent, double maxWidth, dou _rectangle = new RectRenderItem(parent); TextBody = new DrawingTextbody(drawing, Rectangle.Bounds, true); TextBody.MaxWidth = maxWidth; - TextBody.MaxHeight = maxHeight; + TextBody.MaxHeight = maxHeight; } internal DrawingTextBox(ExcelDrawing drawing, BoundingBox parent, double maxWidth, double maxHeight) : base(parent, maxWidth, maxHeight) @@ -265,10 +265,18 @@ public override void AppendRenderItems(List renderItems) } } groupItem.RenderItems.Add(titleItem); + + if(TextBody.AutoSize) + { + TextBody.ApplyAutoSize(); + rect.Width = TextBody.Width + RightMargin; + rect.Height = TextBody.Height + BottomMargin; + } + //As the rect item is inside the group, we set the left and right to the group and top and left on the rect to 0. groupItem.RenderItems.Add(rect); - TextBody.Bounds.Left = LeftMargin ; + TextBody.Bounds.Left = LeftMargin; TextBody.Bounds.Top = TopMargin; TextBody.AppendRenderItems(groupItem.RenderItems); } From b83e3310a8e8655720efbc462893d2fe1facf14d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 8 Jun 2026 16:22:28 +0200 Subject: [PATCH 019/118] Added margins to rect --- .../RenderItems/Textbox/ParagraphRenderItem.cs | 2 +- .../ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs | 2 +- .../Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs index 77188f41c7..7281accd8f 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs @@ -303,7 +303,7 @@ protected void WrapTextFragmentsAndGenerateTextRuns() lineIdx++; } } - Bounds.Width = widthOfLargestLine; + Bounds.Width = widthOfLargestLine + RightMargin; Bounds.Height = combinedHeight; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index 1c03130bff..465335ceb8 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -333,7 +333,7 @@ private void CalculateExponential() var r2 = Math.Pow(Pearson.PearsonImpl(_ySerie.Cast(), GetExponentialSerie(slope, intercept)), 2); Coefficients = [slope, intercept]; - Formula = $"y={intercept:G5}e|ss:{slope:G3}|"; + Formula = $"y={intercept:G5}e|ss:{slope:G3}x|"; RSquare = $"R²={r2:N4}"; } private void CalculateLogarithmic() diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs index 9e9812e88b..78922fdf69 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -269,8 +269,8 @@ public override void AppendRenderItems(List renderItems) if(TextBody.AutoSize) { TextBody.ApplyAutoSize(); - rect.Width = TextBody.Width + RightMargin; - rect.Height = TextBody.Height + BottomMargin; + rect.Width = TextBody.Width + RightMargin + LeftMargin; + rect.Height = TextBody.Height + BottomMargin + RightMargin; } //As the rect item is inside the group, we set the left and right to the group and top and left on the rect to 0. From 344d7d58928c6a2cbcf05dd583aa6ce6822c8252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 8 Jun 2026 17:03:32 +0200 Subject: [PATCH 020/118] Half fix for duplication --- .../RenderItems/Textbox/ParagraphRenderItem.cs | 2 +- .../ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs | 5 ++++- .../RenderItems/Textbox/DrawingParagraphRenderItem.cs | 6 +++--- .../Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs | 3 +-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs index 7281accd8f..a0c642cafe 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs @@ -327,7 +327,7 @@ protected double CalculatePrevWidthBasedOnAlignment(double lineDist) protected void SetHorizontalAlignment(double widthOfLargestLine) { - if (HorizontalAlignment == TextAlignment.Center && AutoSize && _centerAdjustment != null && TextIfEmptyIsNull) + if (HorizontalAlignment == TextAlignment.Center && _centerAdjustment != null /*&& AutoSize && TextIfEmptyIsNull*/) { //Bounds of the paragraph should be bounds of the text itself. //Therefore we must know the starting point to set accurate left and offset from left. diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index 465335ceb8..646eb4e8a7 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -13,6 +13,7 @@ Date Author Change using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; using EPPlus.DrawingRenderer.RenderItems.Textbox; +using EPPlus.Export.ImageRenderer.RenderItems.Shared; using EPPlusImageRenderer; using EPPlusImageRenderer.RenderItems; using EPPlusImageRenderer.Svg; @@ -217,7 +218,9 @@ private void AddLblText(DrawingTextBox lbl, string labelText) { int pIx = 0; var ix = labelText.IndexOf("|ss:"); - + + //lbl.TextBody.Paragraphs[0].HorizontalAlignment = TextAlignment.Center; + if (ix < 0) { lbl.TextBody.Paragraphs[0].AddText(labelText); diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs index 7ebdb35ebf..bc75b2f3cd 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs @@ -199,8 +199,8 @@ private void ImportMarginAndIndent(ExcelDrawingParagraph p) private void ImportAlignment(bool isAutoSize, double maxWidth, double parentWidth) { - if (isAutoSize == false) - { + //if (isAutoSize == false) + //{ Bounds.Left = 0; Bounds.Width = ParentMaxWidth; @@ -216,7 +216,7 @@ private void ImportAlignment(bool isAutoSize, double maxWidth, double parentWidt _centerAdjustment = GetAlignmentHorizontal(HorizontalAlignment); } Bounds.Width = parentWidth - RightMargin - LeftMargin; - } + //} } private void ImportLineSpacing(eDrawingTextLineSpacing lsType, double lineSpacingValue) diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs index 78922fdf69..f8188c8486 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -99,8 +99,7 @@ internal void AddText(string text = null) TextBody.AddParagraph(text); } - - public new DrawingTextbody TextBody {get;set;} + public new DrawingTextbody TextBody { get { return (DrawingTextbody)base.TextBody; } set { base.TextBody = value; } } public double Left { get From 229d086783df99344718d0a0ab73781b56da32bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 9 Jun 2026 08:53:05 +0200 Subject: [PATCH 021/118] Fixed datalabel positions. Messily. --- .../Chart/DataLabels/ChartSerieDataLabelRenderer.cs | 6 ++++-- .../Renderer/Chart/DataLabels/SvgDataLabelPoint.cs | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs index fd3dd52aa4..da7a894eb7 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs @@ -26,7 +26,7 @@ public ChartSerieDataLabelRenderer(ChartRenderer chart, ExcelChartSerieDataLabel { _serieIndex = index; _dlblSerie = dlblSerie; - plotAreaBounds = chart.Plotarea.Rectangle.Bounds; + plotAreaBounds = chart.Plotarea.Group.Bounds; if (dlblSerie.TextBody.Paragraphs.Count != 0) { @@ -136,8 +136,10 @@ internal void SetParentPoint(BoundingBox parent, int index) public override void AppendRenderItems(List renderItems) { var plotAreaGroup = new GroupRenderItem(plotAreaBounds); + plotAreaGroup.Left = plotAreaBounds.Position.X; + plotAreaGroup.Top = plotAreaBounds.Position.Y; - if(_dlblSerie.Fill.IsEmpty == false) + if (_dlblSerie.Fill.IsEmpty == false) { plotAreaGroup.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); plotAreaGroup.GroupTransform += $" fill=\"{plotAreaGroup.FillColor}\""; diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index db3db6cfea..3428fb2542 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -245,6 +245,7 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V Rectangle.Bounds.Parent = parentPoint; _parentPoint = parentPoint; _parentShapeBounds = parentShape; + var dataLabelCenter = new Vector2(Rectangle.Bounds.Left, Rectangle.Bounds.Top); Vector2 startPointDirection = Vector2.Zero; @@ -458,24 +459,28 @@ private void AppendDebugBounds(List renderItems) public override void AppendRenderItems(List renderItems) { var parentPointGroup = new GroupRenderItem(_parentPoint); + parentPointGroup.Left = _parentPoint.Left; + parentPointGroup.Top = _parentPoint.Top; renderItems.Add(parentPointGroup); var titleItemOrigin = new TitleRenderItem("DataLabel originpoint"); renderItems.Add(titleItemOrigin); var group = new GroupRenderItem(Rectangle.Bounds); + group.Left = Rectangle.Bounds.Left; + group.Top = Rectangle.Bounds.Top; parentPointGroup.RenderItems.Add(group); var titleItem = new TitleRenderItem("DataLabel size adjustment"); parentPointGroup.RenderItems.Add(titleItem); - _txtBox.AppendRenderItems(parentPointGroup.RenderItems); + _txtBox.AppendRenderItems(group.RenderItems); if(_renderConnectionPointLines) { if (_connectionPointLines != null) { - _connectionPointLines.AppendRenderItems(parentPointGroup.RenderItems); + _connectionPointLines.AppendRenderItems(group.RenderItems); } } From 77b7382565aa0a8e8c9d2c5168b0de30d85b100b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 9 Jun 2026 09:33:04 +0200 Subject: [PATCH 022/118] Fixed datalabels more. Started fixing centering trendline --- .../DataLabels/ChartSerieDataLabelRenderer.cs | 3 +- .../Chart/DataLabels/SvgDataLabelPoint.cs | 9 +-- .../RenderItems/Textbox/DrawingTextBody.cs | 62 +++++++++---------- .../RenderItems/Textbox/DrawingTextBox.cs | 8 +-- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs index da7a894eb7..7078647db1 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs @@ -136,13 +136,14 @@ internal void SetParentPoint(BoundingBox parent, int index) public override void AppendRenderItems(List renderItems) { var plotAreaGroup = new GroupRenderItem(plotAreaBounds); + plotAreaGroup.Left = plotAreaBounds.Position.X; plotAreaGroup.Top = plotAreaBounds.Position.Y; if (_dlblSerie.Fill.IsEmpty == false) { plotAreaGroup.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); - plotAreaGroup.GroupTransform += $" fill=\"{plotAreaGroup.FillColor}\""; + plotAreaGroup.GroupTransform += $" fill=\"{plotAreaGroup.FillColor}\" name=\"Plot area group\""; } renderItems.Add(plotAreaGroup); diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 3428fb2542..1b2ddb2df6 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -461,19 +461,20 @@ public override void AppendRenderItems(List renderItems) var parentPointGroup = new GroupRenderItem(_parentPoint); parentPointGroup.Left = _parentPoint.Left; parentPointGroup.Top = _parentPoint.Top; - renderItems.Add(parentPointGroup); var titleItemOrigin = new TitleRenderItem("DataLabel originpoint"); renderItems.Add(titleItemOrigin); + renderItems.Add(parentPointGroup); + + var titleItem = new TitleRenderItem("DataLabel size adjustment"); + parentPointGroup.RenderItems.Add(titleItem); + var group = new GroupRenderItem(Rectangle.Bounds); group.Left = Rectangle.Bounds.Left; group.Top = Rectangle.Bounds.Top; parentPointGroup.RenderItems.Add(group); - var titleItem = new TitleRenderItem("DataLabel size adjustment"); - parentPointGroup.RenderItems.Add(titleItem); - _txtBox.AppendRenderItems(group.RenderItems); if(_renderConnectionPointLines) diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs index 151c7c2d44..29ca4672f5 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs @@ -80,33 +80,33 @@ public void ImportParagraph(ExcelDrawingParagraph item, double startingY, string Paragraphs.Add(paragraph); } - internal void SetHorizontalAlignmentPosition() - { - //if (AutoSize) - //{ - foreach (var p in Paragraphs) - { - switch (p.HorizontalAlignment) - { - case TextAlignment.Left: - p.Bounds.Left = 0; - break; - case TextAlignment.Center: - p.Bounds.Left = (Bounds.Width / 2) - (p.Bounds.Width / 2); - break; - case TextAlignment.Right: - p.Bounds.Left = Bounds.Right - p.Bounds.Width; - break; - case TextAlignment.Distributed: - case TextAlignment.Justified: - case TextAlignment.JustifiedLow: - case TextAlignment.ThaiDistributed: - p.Bounds.Left = 0; //TODO: Set left for now as we do not support distributed spacing yet - break; - } - } - //} - } + //internal void SetHorizontalAlignmentPosition() + //{ + // //if (AutoSize) + // //{ + // foreach (var p in Paragraphs) + // { + // switch (p.HorizontalAlignment) + // { + // case TextAlignment.Left: + // p.Bounds.Left = 0; + // break; + // case TextAlignment.Center: + // p.Bounds.Left = (Bounds.Width / 2) - (p.Bounds.Width / 2); + // break; + // case TextAlignment.Right: + // p.Bounds.Left = Bounds.Right - p.Bounds.Width; + // break; + // case TextAlignment.Distributed: + // case TextAlignment.Justified: + // case TextAlignment.JustifiedLow: + // case TextAlignment.ThaiDistributed: + // p.Bounds.Left = 0; //TODO: Set left for now as we do not support distributed spacing yet + // break; + // } + // } + // //} + //} internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHorizontalAlignment horizontalDefault = ExcelHorizontalAlignment.Left) { @@ -138,10 +138,10 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz largestWidth = Math.Max(largestWidth, addedPara.Bounds.Width); } - foreach (var paragraph in body.Paragraphs) - { - SetHorizontalAlignmentPosition(); - } + //foreach (var paragraph in body.Paragraphs) + //{ + // SetHorizontalAlignmentPosition(); + //} if (Paragraphs != null && Paragraphs.Count() > 0) { diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs index f8188c8486..5750582a03 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -265,18 +265,18 @@ public override void AppendRenderItems(List renderItems) } groupItem.RenderItems.Add(titleItem); - if(TextBody.AutoSize) + if (TextBody.AutoSize) { TextBody.ApplyAutoSize(); rect.Width = TextBody.Width + RightMargin + LeftMargin; rect.Height = TextBody.Height + BottomMargin + RightMargin; } + //TextBody.SetHorizontalAlignmentPosition(); + TextBody.Bounds.Left = LeftMargin; + TextBody.Bounds.Top = TopMargin; //As the rect item is inside the group, we set the left and right to the group and top and left on the rect to 0. groupItem.RenderItems.Add(rect); - - TextBody.Bounds.Left = LeftMargin; - TextBody.Bounds.Top = TopMargin; TextBody.AppendRenderItems(groupItem.RenderItems); } From 73c96255e61def33c1deed4176c23902f8e8715d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 9 Jun 2026 10:47:42 +0200 Subject: [PATCH 023/118] Fixed DrawingTextBox duplicate properties --- .../Textbox/ParagraphRenderItem.cs | 4 +- .../RenderItems/Textbox/RenderTextbox.cs | 28 +-- .../Renderer/Chart/ChartAxisRenderer.cs | 2 +- .../Renderer/Chart/ChartLegendRenderer.cs | 8 +- .../Renderer/Chart/ChartTitleRenderer.cs | 2 +- .../Trendlines/ChartTrendlineRenderer.cs | 7 +- .../Chart/DataLabels/SvgDataLabelPoint.cs | 6 +- .../Renderer/Chart/DrawingLegendSerie.cs | 2 +- .../Textbox/DrawingParagraphRenderItem.cs | 8 +- .../RenderItems/Textbox/DrawingTextBody.cs | 10 +- .../RenderItems/Textbox/DrawingTextBox.cs | 205 +++++++++--------- src/EPPlus/Drawing/Renderer/ShapeRenderer.cs | 6 +- 12 files changed, 142 insertions(+), 146 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs index a0c642cafe..2c7a51047e 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs @@ -327,11 +327,11 @@ protected double CalculatePrevWidthBasedOnAlignment(double lineDist) protected void SetHorizontalAlignment(double widthOfLargestLine) { - if (HorizontalAlignment == TextAlignment.Center && _centerAdjustment != null /*&& AutoSize && TextIfEmptyIsNull*/) + if (HorizontalAlignment == TextAlignment.Center) { //Bounds of the paragraph should be bounds of the text itself. //Therefore we must know the starting point to set accurate left and offset from left. - Bounds.Left = _centerAdjustment.Value - (widthOfLargestLine / 2); + Bounds.Left = GetAlignmentHorizontal(HorizontalAlignment) - (widthOfLargestLine / 2); } else { diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs index 2cbe96fe21..788be57ef9 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs @@ -30,7 +30,7 @@ public RenderTextbox(BoundingBox parent, double maxWidth, double maxHeight) public RenderTextbox(BoundingBox parent, BoundingBox maxBounds) { } - RectRenderItem _rectangle =null; + protected RectRenderItem _rectangle =null; public RectRenderItem Rectangle { get @@ -40,7 +40,8 @@ public RectRenderItem Rectangle return _rectangle; } } - public virtual RenderTextBody TextBody {get;set;} + + public virtual RenderTextBody TextBody { get; set; } public double Left { get @@ -80,27 +81,28 @@ public double Height return TopMargin + (TextBody?.Bounds.Height ?? 0d) + BottomMargin; } } - internal double LeftMargin + public double LeftMargin { get; set; } - internal double TopMargin + public double TopMargin { get; set; } - internal double RightMargin + public double RightMargin { get; set; } - internal double BottomMargin + public double BottomMargin { get; set; } - internal BoundingBox Parent { get; private set; } - internal double Rotation + + internal protected BoundingBox Parent { get; protected set; } + public double Rotation { get { @@ -115,7 +117,7 @@ internal double Rotation /// Gets the actual width of the rotated textbox. /// /// - internal double GetActualWidth() + public double GetActualWidth() { return Width * Math.Abs(Math.Cos(MathHelper.Radians(Rotation))) + Height * Math.Abs(Math.Sin(MathHelper.Radians(Rotation))); } @@ -123,7 +125,7 @@ internal double GetActualWidth() /// Gets the actual right position of the rotated textbox. /// /// - internal double GetActualRight() + public double GetActualRight() { return Left+GetActualWidth(); } @@ -131,7 +133,7 @@ internal double GetActualRight() /// Gets the actual height of the rotated textbox. /// /// - internal double GetActualHeight() + public double GetActualHeight() { return Width * Math.Abs(Math.Sin(MathHelper.Radians(Rotation))) + Height * Math.Abs(Math.Cos(MathHelper.Radians(Rotation))); } @@ -139,7 +141,7 @@ internal double GetActualHeight() /// Gets the actual right position of the rotated textbox. /// /// - internal double GetActualBottom() + public double GetActualBottom() { return Top + GetActualHeight(); } @@ -151,7 +153,7 @@ public override void AppendRenderItems(List renderItems) /// /// How the text is anchored. /// - internal eTextAnchor TextAnchor + public eTextAnchor TextAnchor { get; set; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 8a7b34ef4a..652e89ec0b 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -437,7 +437,7 @@ private List GetAxisValueTextBoxes() p.HorizontalAlignment = eTextAlignment.Center; } - tb.TextBody.ImportParagraph(p, 0, v); + tb.ImportParagraph(p, 0, v); //tb.TextBody.Paragraphs[0].AddText(v, Axis.Font); tb.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, Axis.Fill, axisStyle.FillReference.Color); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index 3919ead744..2789199c67 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -414,7 +414,7 @@ private void SetTrendlineLegend(ExcelChart ct, int serieIndex, int entryIndex, D tbWidth = Rectangle.Bounds.Width - tbLeft; var tbHeight = entryHeight; - sls.Textbox = new DrawingTextbody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); var entry = Chart.Legend.Entries.FirstOrDefault(x => x.Index == entryIndex); var headerText = tl.GetName(serieIndex); @@ -441,7 +441,7 @@ private void SetPieLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe var tbWidth = Rectangle.Bounds.Width - tbLeft; var tbHeight = entryHeight; - sls.Textbox = new DrawingTextbody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); //Cat values are the header text //They create a rect marker for each slice @@ -495,7 +495,7 @@ private void SetLineLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eL var tbWidth = Rectangle.Bounds.Width - tbLeft; var tbHeight = entryHeight; - sls.Textbox = new DrawingTextbody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); var entry = Chart.Legend.Entries.FirstOrDefault(x => x.Index == index); var headerText = s.GetHeaderText(index); @@ -541,7 +541,7 @@ private void SetBarLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe tbWidth = Rectangle.Bounds.Width - tbLeft; var tbHeight = tm.Height; - sls.Textbox = new DrawingTextbody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); //sls.Textbox.Bounds.Left = si.Bottom + MarginIconText; var entry = Chart.Legend.Entries.FirstOrDefault(x => x.Index == index); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs index bee3ce50ef..fd138c21fb 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs @@ -198,7 +198,7 @@ internal void InitTextBox(double maxWidth, double maxHeight) } if (_title.TextBody.Paragraphs.Count > 0) { - TextBox.ImportTextBody(_title.TextBody, true, ExcelHorizontalAlignment.Center); + TextBox.ImportTextBodyAndParagraphs(_title.TextBody, true, ExcelHorizontalAlignment.Center); } else { diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index 646eb4e8a7..5af479aa3f 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -159,7 +159,8 @@ private void CreateDatalabel() } DataLabel.TextBody.AutoSize = true; } - DataLabel.ImportTextBody(lbl.TextBody, true, OfficeOpenXml.Style.ExcelHorizontalAlignment.Center); + lbl.TextBody.Paragraphs[0].HorizontalAlignment = OfficeOpenXml.Drawing.eTextAlignment.Center; + DataLabel.ImportTextBodyAndParagraphs(lbl.TextBody, true, OfficeOpenXml.Style.ExcelHorizontalAlignment.Center); var labelText = ""; if (_trendline.DisplayEquation) { @@ -173,8 +174,8 @@ private void CreateDatalabel() } labelText += RSquare; } - lbl.TextBody.Paragraphs[0].HorizontalAlignment = OfficeOpenXml.Drawing.eTextAlignment.Center; - DataLabel.ImportParagraph(lbl.TextBody.Paragraphs[0], 0, ""); + //lbl.TextBody.Paragraphs[0].HorizontalAlignment = OfficeOpenXml.Drawing.eTextAlignment.Center; + //DataLabel.ImportParagraph(lbl.TextBody.Paragraphs[0], 0, ""); AddLblText(DataLabel, labelText); DataLabel.LeftMargin = DataLabel.RightMargin = 4; DataLabel.TopMargin = DataLabel.BottomMargin = 2; diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 1b2ddb2df6..fe439a7dac 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -104,19 +104,19 @@ internal void ImportDataLabel(ExcelChartStandardSerie serie, ExcelChartDataLabel var txtBox = new DrawingTextBox(Chart, Rectangle.Bounds, maxBounds.Width, maxBounds.Height); - txtBox.ImportTextBody(dataLabel.TextBody, false); + txtBox.ImportTextBodyAndParagraphs(dataLabel.TextBody, false); txtBox.TextBody.Bounds.Top = 0; txtBox.TextBody.AutoSize = true; if (txtBox.TextBody.Paragraphs.Count == 0) { - txtBox.TextBody.ImportParagraph(defaultParagraph, 0, finalString); + txtBox.ImportParagraph(defaultParagraph, 0, finalString); //txtBox.TextBody.AddParagraph(0, finalString); } else if (txtBox.TextBody.Paragraphs.Count == 1) { - txtBox.TextBody.ImportParagraph(dataLabel.TextBody.Paragraphs[0], 0, finalString); + txtBox.ImportParagraph(dataLabel.TextBody.Paragraphs[0], 0, finalString); //Remove dummy paragraph added by ImportTextBody txtBox.TextBody.Paragraphs.RemoveAt(0); } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs b/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs index 652f7bf936..f1aea585d8 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs @@ -20,7 +20,7 @@ namespace EPPlusImageRenderer.Svg { internal class DrawingLegendSerie : DrawingLegendSeriesIcon { - internal DrawingTextbody Textbox { get; set; } + internal DrawingTextBody Textbox { get; set; } internal void GetIconTopLeft(out double top, out double left) { diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs index bc75b2f3cd..318a65d4c3 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs @@ -25,7 +25,7 @@ internal class DrawingParagraphRenderItem : ParagraphRenderItem /// /// /// - public DrawingParagraphRenderItem(DrawingTextbody textBody, BoundingBox parent) : base(parent, textBody) + public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent) : base(parent, textBody) { ParagraphLineSpacing = GetParagraphLineSpacingInPoints(100, (TextShaper)OpenTypeFonts.GetShaperForFont(DefaultParagraphFont), DefaultParagraphFont.Size); } @@ -36,7 +36,7 @@ public DrawingParagraphRenderItem(DrawingTextbody textBody, BoundingBox parent) /// /// /// - public DrawingParagraphRenderItem(DrawingTextbody textBody, BoundingBox parent, string text) : this(textBody, parent) + public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent, string text) : this(textBody, parent) { ImportLinesAndTextRunsDefault(text); } @@ -48,7 +48,7 @@ public DrawingParagraphRenderItem(DrawingTextbody textBody, BoundingBox parent, /// /// /// - public DrawingParagraphRenderItem(DrawingTextbody textBody, BoundingBox parent, ExcelDrawingParagraph p, string textIfEmpty = null) : base(parent, textBody, false) + public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent, ExcelDrawingParagraph p, string textIfEmpty = null) : base(parent, textBody, false) { IsFirstParagraph = p == p._paragraphs[0]; ImportStyleInfo(textBody, p); @@ -132,7 +132,7 @@ void GenerateRichText(ExcelDrawingTextRunCollection runs/*, List } } - private void ImportStyleInfo(DrawingTextbody textBody, ExcelDrawingParagraph p) + private void ImportStyleInfo(DrawingTextBody textBody, ExcelDrawingParagraph p) { //If this paragraph has defaults of its own enter here if (p.DefaultRunProperties.Fill != null && p.DefaultRunProperties.Fill.IsEmpty == false) diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs index 29ca4672f5..ecc7597480 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs @@ -20,20 +20,20 @@ namespace OfficeOpenXml.Drawing.Renderer.TextBox { - public class DrawingTextbody : RenderTextBody + public class DrawingTextBody : RenderTextBody { internal ExcelDrawing _drawing; internal ExcelTheme Theme { get; } - public DrawingTextbody(ExcelDrawing drawing, BoundingBox parent, bool autoSize, bool clampedToParent = false) : base(parent, autoSize) + public DrawingTextBody(ExcelDrawing drawing, BoundingBox parent, bool autoSize, bool clampedToParent = false) : base(parent, autoSize) { _drawing = drawing; Theme = drawing._drawings.Worksheet.Workbook.ThemeManager.GetOrCreateTheme(); MaxWidth = parent.Width; MaxHeight = parent.Height; } - public DrawingTextbody(ExcelDrawing drawing, BoundingBox parent, double left, double top, double maxWidth, double maxHeight, bool clampedToParent = false, bool autoSize=false) : base(parent, autoSize) + public DrawingTextBody(ExcelDrawing drawing, BoundingBox parent, double left, double top, double maxWidth, double maxHeight, bool clampedToParent = false, bool autoSize=false) : base(parent, autoSize) { _drawing = drawing; Theme = drawing._drawings.Worksheet.Workbook.ThemeManager.GetOrCreateTheme(); @@ -176,12 +176,12 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz // renderItems.Add(new SvgEndGroupItem(DrawingRenderer, Bounds)); //} - internal DrawingParagraphRenderItem CreateParagraph(DrawingTextbody textBody, BoundingBox parent) + internal DrawingParagraphRenderItem CreateParagraph(DrawingTextBody textBody, BoundingBox parent) { return new DrawingParagraphRenderItem(textBody, parent); } - internal DrawingParagraphRenderItem CreateParagraph(DrawingTextbody textBody, ExcelDrawingParagraph paragraph, BoundingBox parent, string textIfEmpty = null) + internal DrawingParagraphRenderItem CreateParagraph(DrawingTextBody textBody, ExcelDrawingParagraph paragraph, BoundingBox parent, string textIfEmpty = null) { return new DrawingParagraphRenderItem(textBody, parent, paragraph, textIfEmpty); } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs index 5750582a03..ebb197fe70 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -24,7 +24,7 @@ private void Init(ExcelDrawing drawing, BoundingBox parent, double maxWidth, dou Parent = parent; _drawing= drawing; _rectangle = new RectRenderItem(parent); - TextBody = new DrawingTextbody(drawing, Rectangle.Bounds, true); + TextBody = new DrawingTextBody(drawing, Rectangle.Bounds, true); TextBody.MaxWidth = maxWidth; TextBody.MaxHeight = maxHeight; } @@ -39,16 +39,16 @@ internal DrawingTextBox(ExcelDrawing drawing, BoundingBox parent, double maxWidt // parent, maxBounds.Left, maxBounds.Top, maxBounds.Width, maxBounds.Height, maxBounds.Width, maxBounds.Height) //{ //} - RectRenderItem _rectangle =null; - public RectRenderItem Rectangle - { - get - { - _rectangle.Bounds.Width = Width; - _rectangle.Bounds.Height = Height; - return _rectangle; - } - } + //RectRenderItem _rectangle =null; + //public RectRenderItem Rectangle + //{ + // get + // { + // _rectangle.Bounds.Width = Width; + // _rectangle.Bounds.Height = Height; + // return _rectangle; + // } + //} //internal override void AppendRenderItems(List renderItems) //{ // var rect = Rectangle; @@ -99,119 +99,112 @@ internal void AddText(string text = null) TextBody.AddParagraph(text); } - public new DrawingTextbody TextBody { get { return (DrawingTextbody)base.TextBody; } set { base.TextBody = value; } } - public double Left - { - get - { - return Rectangle.Bounds.Left; //TextBody.Bounds.Left - LeftMargin; + DrawingTextBody _textBody; - } - set - { - //TextBody.Bounds.Left = value + LeftMargin; - Rectangle.Bounds.Left = value; - } - } - public double Top - { - get - { - return Rectangle.Bounds.Top; //TextBody.Bounds.Top - TopMargin; - } - set - { - //TextBody.Bounds.Top = value + TopMargin; - Rectangle.Bounds.Top = value; - } - } - public double Width - { - get - { - return LeftMargin + (TextBody?.Width ?? 0D) + RightMargin; - } - } - public double Height - { - get - { - return TopMargin + (TextBody?.Height ?? 0d) + BottomMargin; - } - } - internal double LeftMargin + public DrawingTextBody GetTextBody() { - get; set; + return (DrawingTextBody)TextBody; } - internal double TopMargin + public void SetDrawingTextBody(DrawingTextBody tb) { - get; set; + TextBody = tb; } - internal double RightMargin - { - get; set; - } + public override RenderTextBody TextBody { get { return _textBody; } set { _textBody = (DrawingTextBody)value; } } + //public double Left + //{ + // get + // { + // return Rectangle.Bounds.Left; //TextBody.Bounds.Left - LeftMargin; - internal double BottomMargin - { - get; set; - } - internal BoundingBox Parent { get; private set; } - internal double Rotation - { - get - { - return Rectangle.Bounds.Rotation; - } - set - { - Rectangle.Bounds.Rotation = value; - } - } + // } + // set + // { + // //TextBody.Bounds.Left = value + LeftMargin; + // Rectangle.Bounds.Left = value; + // } + //} + //public double Top + //{ + // get + // { + // return Rectangle.Bounds.Top; //TextBody.Bounds.Top - TopMargin; + // } + // set + // { + // //TextBody.Bounds.Top = value + TopMargin; + // Rectangle.Bounds.Top = value; + // } + //} + //public double Width + //{ + // get + // { + // return LeftMargin + (TextBody?.Width ?? 0D) + RightMargin; + // } + //} + //public double Height + //{ + // get + // { + // return TopMargin + (TextBody?.Height ?? 0d) + BottomMargin; + // } + //} + //internal BoundingBox Parent { get; private set; } + //internal double Rotation + //{ + // get + // { + // return Rectangle.Bounds.Rotation; + // } + // set + // { + // Rectangle.Bounds.Rotation = value; + // } + //} /// /// Gets the actual width of the rotated textbox. /// /// - internal double GetActualWidth() - { - return Width * Math.Abs(Math.Cos(MathHelper.Radians(Rotation))) + Height * Math.Abs(Math.Sin(MathHelper.Radians(Rotation))); - } - /// - /// Gets the actual right position of the rotated textbox. - /// - /// - internal double GetActualRight() - { - return Left+GetActualWidth(); - } - /// - /// Gets the actual height of the rotated textbox. - /// - /// - internal double GetActualHeight() - { - return Width * Math.Abs(Math.Sin(MathHelper.Radians(Rotation))) + Height * Math.Abs(Math.Cos(MathHelper.Radians(Rotation))); - } + //internal double GetActualWidth() + //{ + // return Width * Math.Abs(Math.Cos(MathHelper.Radians(Rotation))) + Height * Math.Abs(Math.Sin(MathHelper.Radians(Rotation))); + //} + ///// + ///// Gets the actual right position of the rotated textbox. + ///// + ///// + //internal double GetActualRight() + //{ + // return Left+GetActualWidth(); + //} + ///// + ///// Gets the actual height of the rotated textbox. + ///// + ///// + //internal double GetActualHeight() + //{ + // return Width * Math.Abs(Math.Sin(MathHelper.Radians(Rotation))) + Height * Math.Abs(Math.Cos(MathHelper.Radians(Rotation))); + //} /// /// Gets the actual right position of the rotated textbox. /// /// - internal double GetActualBottom() - { - return Top + GetActualHeight(); - } + //internal double GetActualBottom() + //{ + // return Top + GetActualHeight(); + //} /// /// How the text is anchored. /// - internal eTextAnchor TextAnchor - { - get; - set; - } + //internal eTextAnchor TextAnchor + //{ + // get; + // set; + //} - internal void ImportTextBody(ExcelTextBody body, bool useDefaults = true, ExcelHorizontalAlignment horizontalDefault = ExcelHorizontalAlignment.Left) + internal void ImportTextBodyAndParagraphs(ExcelTextBody body, bool useDefaults = true, ExcelHorizontalAlignment horizontalDefault = ExcelHorizontalAlignment.Left) { double l, r, t, b; if (useDefaults) @@ -227,7 +220,7 @@ internal void ImportTextBody(ExcelTextBody body, bool useDefaults = true, ExcelH RightMargin = r; BottomMargin = b; - TextBody.ImportTextBodyAndParagraphs(body, horizontalDefault); + _textBody.ImportTextBodyAndParagraphs(body, horizontalDefault); } public override void AppendRenderItems(List renderItems) @@ -282,7 +275,7 @@ public override void AppendRenderItems(List renderItems) internal void ImportParagraph(ExcelDrawingParagraph item, double startingY, string text = null) { - TextBody.ImportParagraph(item, startingY, text); + _textBody.ImportParagraph(item, startingY, text); } //internal void AddText(double startingY, string text = null) diff --git a/src/EPPlus/Drawing/Renderer/ShapeRenderer.cs b/src/EPPlus/Drawing/Renderer/ShapeRenderer.cs index 78fbbac90e..03323b4a2c 100644 --- a/src/EPPlus/Drawing/Renderer/ShapeRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ShapeRenderer.cs @@ -42,7 +42,7 @@ internal class ShapeRenderer : DrawingRenderer /// /// Textbox from memory /// - public DrawingTextbody TextBody{ get; internal set; } + public DrawingTextBody TextBody{ get; internal set; } //public ShapeRenderer(eShapeStyle style, double top, double left, double width, double height, eTextAutofit autofit) : base() //{ @@ -232,7 +232,7 @@ public string ViewBox return $"{(Bounds.Left).PointToPixelString()},{Bounds.Top.PointToPixelString()},{Bounds.Right.PointToPixelString()},{Bounds.Bottom.PointToPixelString()}"; } } - DrawingTextbody CreateTextBodyItem(ExcelTextBody bodyOrig) + DrawingTextBody CreateTextBodyItem(ExcelTextBody bodyOrig) { if (InsetTextBox == null) { @@ -263,7 +263,7 @@ DrawingTextbody CreateTextBodyItem(ExcelTextBody bodyOrig) var grp = new GroupRenderItem(MarginTextBox.Bounds); RenderItems.Add(grp); - var txtBodyItem = new DrawingTextbody(Drawing, MarginTextBox.Bounds, MarginTextBox.Left, MarginTextBox.Top, MarginTextBox.Width, MarginTextBox.Height); + var txtBodyItem = new DrawingTextBody(Drawing, MarginTextBox.Bounds, MarginTextBox.Left, MarginTextBox.Top, MarginTextBox.Width, MarginTextBox.Height); txtBodyItem.ImportTextBodyAndParagraphs(bodyOrig); txtBodyItem.AppendRenderItems(grp.RenderItems); From 976e1ae155e44efe9029bb6e4fbd3b33c2513cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 9 Jun 2026 12:01:30 +0200 Subject: [PATCH 024/118] Fixed Most of trendline centering --- .../Textbox/ParagraphRenderItem.cs | 15 ++++------ .../RenderItems/Textbox/RenderTextBody.cs | 4 +-- .../Trendlines/ChartTrendlineRenderer.cs | 6 ++-- .../Textbox/DrawingParagraphRenderItem.cs | 30 +++++++++---------- .../RenderItems/Textbox/DrawingTextBox.cs | 22 +++++++------- 5 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs index 2c7a51047e..0213751cb8 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs @@ -238,15 +238,7 @@ protected void ImportLinesAndTextRunsBase(string textIfEmpty) } AddDefaultTextFragment(textIfEmpty); - - Bounds.Left = GetAlignmentHorizontal(HorizontalAlignment); - if (HorizontalAlignment == TextAlignment.Center) - { - _centerAdjustment = GetAlignmentHorizontal(HorizontalAlignment); - } - WrapTextFragmentsAndGenerateTextRuns(); - } protected void WrapTextFragmentsAndGenerateTextRuns() @@ -271,7 +263,11 @@ protected void WrapTextFragmentsAndGenerateTextRuns() widthOfLargestLine = Lines.LargestWidthWithoutSpace; combinedHeight = Lines.GetHeightOfCollection(_lsMultiplier, lineSpacingResult); - SetHorizontalAlignment(widthOfLargestLine); + if(AutoSize) + { + Bounds.Width = widthOfLargestLine + RightMargin; + } + //SetHorizontalAlignment(widthOfLargestLine); int lineIdx = 0; foreach (var line in Lines) @@ -303,7 +299,6 @@ protected void WrapTextFragmentsAndGenerateTextRuns() lineIdx++; } } - Bounds.Width = widthOfLargestLine + RightMargin; Bounds.Height = combinedHeight; } diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs index 64561749c0..2ab53c70bb 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -199,10 +199,10 @@ protected double GetAlignmentVertical() //Center means center of a Shape's ENTIRE bounding box height. //Not center of the Inset GetRectangle case TextAnchoringType.Center: - alignmentY = (MaxHeight - Bounds.Height) / 2 + Bounds.Top; + alignmentY = (Bounds.Height) / 2 + Bounds.Top; break; case TextAnchoringType.Bottom: - alignmentY = MaxHeight - Bounds.Height; + alignmentY = Bounds.Height; break; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index 5af479aa3f..a34fa7989f 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -490,7 +490,7 @@ public void CalculatePolynomial() } Formula = "y=" + GetPolynormFormula(); var r2 = CalculateRSquared(x => PredictLinear(x), _ySerie, _trendline.Intercept); - RSquare = $"R²={r2:N4}"; + RSquare = $"R² = {r2:N4}"; } private void CalculatePower() @@ -511,10 +511,10 @@ private void CalculatePower() var intercept = Math.Pow(Math.E, (sumLnY - slope * sumLnX) / n); Coefficients = [slope, intercept]; - Formula = $"y={intercept:G5}x|ss:{slope:G3}|"; + Formula = $"y = {intercept:G5} x |ss:{slope:G3}|"; var ylogSerie = _ySerie.Select(y => Math.Log(y)).ToArray(); var r2 = CalculateRSquaredPearson(x => intercept * Math.Pow(x, slope), _ySerie); - RSquare = $"R²={r2:N4}"; + RSquare = $"R² = {r2:N4} "; } private void CalculateMoveingAverage() diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs index 318a65d4c3..b830d43151 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs @@ -54,7 +54,7 @@ public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent, ImportStyleInfo(textBody, p); ImportMarginAndIndent(p); - ImportAlignment(textBody.AutoSize, textBody.MaxWidth, parent.Width); + //ImportAlignment(textBody.AutoSize, textBody.MaxWidth, parent.Width); //---Initialize / calculate lines and runs--- //measurer must be set before AddLinesAndRichText @@ -201,21 +201,21 @@ private void ImportAlignment(bool isAutoSize, double maxWidth, double parentWidt { //if (isAutoSize == false) //{ - Bounds.Left = 0; - Bounds.Width = ParentMaxWidth; + //Bounds.Left = 0; + //Bounds.Width = ParentMaxWidth; - //Left is equal to left Paragraph margin - //Textbody or Textbox are assumed to handle shape/chart margins - //Paragraph handles only indentations/margins that is applied ON TOP of those margins - //Paragraph left is the exact position where the text itself starts on the left - Bounds.Left = GetAlignmentHorizontal(TextAlignment.Left); - if (HorizontalAlignment == TextAlignment.Center) - { - //Center is a bit strange the bounds really are the same as left or right aligned - //It doesn't truly matter as only left min and right max play a role - _centerAdjustment = GetAlignmentHorizontal(HorizontalAlignment); - } - Bounds.Width = parentWidth - RightMargin - LeftMargin; + ////Left is equal to left Paragraph margin + ////Textbody or Textbox are assumed to handle shape/chart margins + ////Paragraph handles only indentations/margins that is applied ON TOP of those margins + ////Paragraph left is the exact position where the text itself starts on the left + //Bounds.Left = GetAlignmentHorizontal(TextAlignment.Left); + //if (HorizontalAlignment == TextAlignment.Center) + //{ + // //Center is a bit strange the bounds really are the same as left or right aligned + // //It doesn't truly matter as only left min and right max play a role + // _centerAdjustment = GetAlignmentHorizontal(HorizontalAlignment); + //} + //Bounds.Width = parentWidth - RightMargin - LeftMargin; //} } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs index ebb197fe70..450e15d58d 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -235,6 +235,14 @@ public override void AppendRenderItems(List renderItems) rect.Top = 0; rect.Left = 0; + if (TextBody.AutoSize) + { + TextBody.ApplyAutoSize(); + rect.Width = TextBody.Width + RightMargin + LeftMargin; + rect.Height = TextBody.Height + BottomMargin + TopMargin; + rect.Left = -LeftMargin; + rect.Top = -TopMargin; + } var titleItem = new TitleRenderItem("TextBodySvg Rect"); //The rect shound encapse the text element, so we need to set the left depending on the text anchor. @@ -257,16 +265,10 @@ public override void AppendRenderItems(List renderItems) } } groupItem.RenderItems.Add(titleItem); - - if (TextBody.AutoSize) - { - TextBody.ApplyAutoSize(); - rect.Width = TextBody.Width + RightMargin + LeftMargin; - rect.Height = TextBody.Height + BottomMargin + RightMargin; - } - //TextBody.SetHorizontalAlignmentPosition(); - TextBody.Bounds.Left = LeftMargin; - TextBody.Bounds.Top = TopMargin; + + ////TextBody.SetHorizontalAlignmentPosition(); + //TextBody.Bounds.Left = LeftMargin; + //TextBody.Bounds.Top = TopMargin; //As the rect item is inside the group, we set the left and right to the group and top and left on the rect to 0. groupItem.RenderItems.Add(rect); From 5a394ce9d981e36ffe2b11a9c54edf9a78933aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 9 Jun 2026 12:43:10 +0200 Subject: [PATCH 025/118] Added spacing in trendline strings --- .../Trendlines/ChartTrendlineRenderer.cs | 20 +++++++++---------- .../RenderItems/Textbox/DrawingTextBox.cs | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index a34fa7989f..79ab7a93bb 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -337,8 +337,8 @@ private void CalculateExponential() var r2 = Math.Pow(Pearson.PearsonImpl(_ySerie.Cast(), GetExponentialSerie(slope, intercept)), 2); Coefficients = [slope, intercept]; - Formula = $"y={intercept:G5}e|ss:{slope:G3}x|"; - RSquare = $"R²={r2:N4}"; + Formula = $"y = {intercept:G5}e|ss:{slope:G3}x| "; + RSquare = $"R² = {r2:N4} "; } private void CalculateLogarithmic() { @@ -364,8 +364,8 @@ private void CalculateLogarithmic() Coefficients = [slope, intercept]; var r2 = CalculateRSquared(x => slope * Math.Log(x) + intercept, _ySerie, _trendline.Intercept); - Formula = $"y={slope:G5}ln(x)+{intercept:G5}"; - RSquare = $"R²={r2:N4}"; + Formula = $"y = {slope:G5}ln(x) + {intercept:G5}"; + RSquare = $"R² = {r2:N4}"; } public void CalculatePolynomial() { @@ -488,7 +488,7 @@ public void CalculatePolynomial() Coefficients[i] = matrix[i, coeffCount]; } } - Formula = "y=" + GetPolynormFormula(); + Formula = "y = " + GetPolynormFormula(); var r2 = CalculateRSquared(x => PredictLinear(x), _ySerie, _trendline.Intercept); RSquare = $"R² = {r2:N4}"; } @@ -543,25 +543,25 @@ private string GetPolynormFormula() { if (Coefficients[i-1]>=0) { - sb.Insert(0, "+"); + sb.Insert(0, " + "); } else { - sb.Insert(0, "-"); + sb.Insert(0, " - "); } if(i < 2) { - sb.Insert(0, $"{Math.Abs(Coefficients[i]):G5}x"); + sb.Insert(0, $"{Math.Abs(Coefficients[i]):G5}x "); } else { - sb.Insert(0, $"{Math.Abs(Coefficients[i]):G5}x|ss:{i}|"); + sb.Insert(0, $"{Math.Abs(Coefficients[i]):G5}x|ss:{i}| "); } } if (Coefficients[Coefficients.Length - 1] < 0) { - sb.Insert(0, "-"); + sb.Insert(0, " - "); } return sb.ToString(); diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs index 450e15d58d..de8c20bdf3 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -238,8 +238,8 @@ public override void AppendRenderItems(List renderItems) if (TextBody.AutoSize) { TextBody.ApplyAutoSize(); - rect.Width = TextBody.Width + RightMargin + LeftMargin; - rect.Height = TextBody.Height + BottomMargin + TopMargin; + rect.Width = TextBody.Width + RightMargin; + rect.Height = TextBody.Height + BottomMargin; rect.Left = -LeftMargin; rect.Top = -TopMargin; } From ab4198592ddbc36fcf6d423f88b26f861d89a70c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 9 Jun 2026 12:59:35 +0200 Subject: [PATCH 026/118] Fixed stacking axis --- .../Drawing/Chart/ExcelChartAxisStandard.cs | 69 +++++++++++------- .../Drawing/Chart/ExcelDrawingTextSettings.cs | 3 +- .../Renderer/Chart/ChartAxisRenderer.cs | 70 ++++++++++++------- .../Renderer/Chart/ChartPlotareaRenderer.cs | 56 +++++++++++---- .../Renderer/Chart/ChartTitleRenderer.cs | 17 ++--- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 49 ++++++++----- 6 files changed, 173 insertions(+), 91 deletions(-) diff --git a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs index de5a081621..02be725181 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs @@ -167,48 +167,69 @@ public eActualAxisPosition ActualAxisPosition get { var ap = AxisPosition; - if(ap==eAxisPosition.Right && LabelPosition==eTickLabelPosition.Low) + if (ap == eAxisPosition.Left && LabelPosition == eTickLabelPosition.High) { - if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition == eTickLabelPosition.Low) + return eActualAxisPosition.Right; + } + else if (ap == eAxisPosition.Bottom) + { + if (LabelPosition == eTickLabelPosition.High) { - return eActualAxisPosition.Right; + return eActualAxisPosition.Top; } else { - return eActualAxisPosition.RightSecond; + return eActualAxisPosition.Bottom; } } - else if(ap==eAxisPosition.Right && LabelPosition==eTickLabelPosition.High) + else if (ap == eAxisPosition.Right) { - if(_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition == eTickLabelPosition.High) + if (LabelPosition == eTickLabelPosition.Low) { - return eActualAxisPosition.Left; + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition == eTickLabelPosition.High) + { + return eActualAxisPosition.Left; + } + else + { + return eActualAxisPosition.LeftSecond; + } } - else + else { - return eActualAxisPosition.LeftSecond; + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition == eTickLabelPosition.Low) + { + return eActualAxisPosition.Right; + } + else + { + return eActualAxisPosition.RightSecond; + } } } - else if(ap==eAxisPosition.Top && LabelPosition==eTickLabelPosition.Low) + else if(ap==eAxisPosition.Top) { - if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition == eTickLabelPosition.Low) + if (LabelPosition == eTickLabelPosition.Low) { - return eActualAxisPosition.Bottom; + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition == eTickLabelPosition.High) + { + return eActualAxisPosition.Bottom; + } + else + { + return eActualAxisPosition.BottomSecond; + } } else { - return eActualAxisPosition.BottomSecond; - } - } - else if(ap==eAxisPosition.Bottom && LabelPosition==eTickLabelPosition.High) - { - if(_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition == eTickLabelPosition.High) - { - return eActualAxisPosition.Top; - } - else - { - return eActualAxisPosition.TopSecond; + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition == eTickLabelPosition.Low) + { + return eActualAxisPosition.Top; + } + else + { + return eActualAxisPosition.TopSecond; + } } } else diff --git a/src/EPPlus/Drawing/Chart/ExcelDrawingTextSettings.cs b/src/EPPlus/Drawing/Chart/ExcelDrawingTextSettings.cs index 9545be7711..18ef738b72 100644 --- a/src/EPPlus/Drawing/Chart/ExcelDrawingTextSettings.cs +++ b/src/EPPlus/Drawing/Chart/ExcelDrawingTextSettings.cs @@ -10,9 +10,8 @@ Date Author Change ************************************************************************************************* 01/27/2020 EPPlus Software AB Initial release EPPlus 5 *************************************************************************************************/ -using OfficeOpenXml.FormulaParsing.Excel.Functions.Finance; -using System.Xml; using OfficeOpenXml.Drawing.Style.Effect; +using System.Xml; namespace OfficeOpenXml.Drawing.Chart { /// diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 8a7b34ef4a..14e2d0be47 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -36,6 +36,7 @@ Date Author Change using System; using System.Collections.Generic; using System.Linq; +using System.Security.AccessControl; namespace EPPlusImageRenderer.Svg { @@ -84,34 +85,55 @@ internal ChartAxisRenderer(ChartRenderer sc, ExcelChartAxisStandard ax) : base(s } else { - if (ax.AxisPosition == eAxisPosition.Left || ax.AxisPosition == eAxisPosition.Right) + var aav = ax.ActualAxisPosition; + if (ax.IsVertical) { - if (ax.AxisPosition == eAxisPosition.Left) + if (aav == eActualAxisPosition.Left || + aav == eActualAxisPosition.LeftSecond) { Rectangle.Width = GetTextWidest(ax) + LeftMargin; - var ll = 8D; - if (sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Left) - { - ll = sc.Legend.Rectangle.Right + sc.Legend.RightMargin; - } - Rectangle.Left = Title == null ? ll : Title.Rectangle.Left + Title.TextBox.GetActualWidth(); + //var ll = 8D; + //if (sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Left) + //{ + // ll = sc.Legend.Rectangle.Right + sc.Legend.RightMargin; + //} + //Rectangle.Left = Title == null ? ll : Title.Rectangle.Left + Title.TextBox.GetActualWidth(); } else { Rectangle.Width = GetTextWidest(ax) + RightMargin; - var lp = sc.ChartArea.Rectangle.Width - Rectangle.Width - 8D; - if (sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Right) - { - lp = sc.Legend.Rectangle.Left + -Rectangle.Width; - } - Rectangle.Left = Title == null ? lp : Title.Rectangle.Left - Rectangle.Width - LeftMargin; + //var lp = sc.ChartArea.Rectangle.Width - Rectangle.Width - 8D; + //if (aav == eActualAxisPosition.Right) + //{ + // if (sc.SecondVerticalAxis?.Axis?.ActualAxisPosition == eActualAxisPosition.RightSecond) + // { + // lp -= sc.SecondVerticalAxis.Rectangle.Width; + // } + // else + // { + // } + //} + //else + //{ + + //} + //if (sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Right) + //{ + // lp = sc.Legend.Rectangle.Left + -Rectangle.Width; + //} + //Rectangle.Left = Title == null ? lp : Title.Rectangle.Left - Rectangle.Width - LeftMargin; } } else { Rectangle.Height = GetTextHeight(ax); - //TODO:Fix - Rectangle.Top = Title == null || ax.AxisPosition == eAxisPosition.Top ? sc.ChartArea.Rectangle.Height - 8 - Rectangle.Height : Title.Rectangle.Top - Rectangle.Height - 8; + //var rb = 0D; + //if (aav == eActualAxisPosition.Bottom && sc.SecondHorizontalAxis?.Axis.ActualAxisPosition==eActualAxisPosition.BottomSecond) + //{ + // rb = sc.SecondHorizontalAxis.Rectangle.Height; + //} + + //Rectangle.Top = Title == null || ax.AxisPosition == eAxisPosition.Top ? sc.ChartArea.Rectangle.Height - 8 - Rectangle.Height : Title.Rectangle.Top - Rectangle.Height - 8; } } @@ -667,26 +689,26 @@ private List AddTickmarks(double units, eTimeUnit? dateUnit, dou case eAxisPosition.Left: y1 = (float)(Rectangle.Top + Rectangle.Height - ((d - min) / diff * Rectangle.Height)); y2 = y1; - x1 = (float)Rectangle.Right - tickMarkWidthOutside; - x2 = (float)Rectangle.Right + tickMarkWidthInside; + x1 = (float)ChartRenderer.Plotarea.Rectangle.Left - tickMarkWidthOutside; + x2 = (float)ChartRenderer.Plotarea.Rectangle.Left + tickMarkWidthInside; break; case eAxisPosition.Right: y1 = (float)(Rectangle.Top + Rectangle.Height - ((d - min) / diff * Rectangle.Height)); y2 = y1; - x1 = (float)Rectangle.Left - tickMarkWidthInside; - x2 = (float)Rectangle.Left + tickMarkWidthOutside; + x1 = (float)ChartRenderer.Plotarea.Rectangle.Right - tickMarkWidthInside; + x2 = (float)ChartRenderer.Plotarea.Rectangle.Right + tickMarkWidthOutside; break; case eAxisPosition.Top: x1 = (float)(Rectangle.Left + ((d - min) / diff * Rectangle.Width)); x2 = x1; - y1 = (float)Rectangle.Bottom - tickMarkWidthOutside; - y2 = (float)Rectangle.Bottom + tickMarkWidthInside; + y1 = (float)ChartRenderer.Plotarea.Rectangle.Top - tickMarkWidthOutside; + y2 = (float)ChartRenderer.Plotarea.Rectangle.Top + tickMarkWidthInside; break; case eAxisPosition.Bottom: x1 = (float)(Rectangle.Left + ((d - min) / diff * Rectangle.Width)); x2 = x1; - y1 = (float)Rectangle.Top - tickMarkWidthInside; - y2 = (float)Rectangle.Top + tickMarkWidthOutside; + y1 = (float)ChartRenderer.Plotarea.Rectangle.Top - tickMarkWidthInside; + y2 = (float)ChartRenderer.Plotarea.Rectangle.Top + tickMarkWidthOutside; break; default: throw new InvalidOperationException("Invalid axis position"); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs index ce905cfc0b..e6282569b8 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs @@ -66,11 +66,12 @@ internal void SetPlotAreaRectangle() private double GetPlotAreaHeight(RectRenderItem rect) { - var bottomAxis = GetAxisByPosition(eAxisPosition.Bottom); + var bottomAxis = GetAxisActualByPosition(eActualAxisPosition.Bottom); double vaHeight = 0; if (bottomAxis!=null) { - vaHeight = (bottomAxis.Rectangle?.Height ?? 0D) + (bottomAxis.Title?.TextBox?.GetActualHeight() ?? 0D); + var bottomSecondAxis = GetAxisActualByPosition(eActualAxisPosition.Bottom); + vaHeight = (bottomAxis.Rectangle?.Height ?? 0D) + (bottomAxis.Title?.TextBox?.GetActualHeight() ?? 0D) + (bottomSecondAxis.Rectangle?.Height ?? 0D); } if (Chart.Legend?.Position == eLegendPosition.Bottom) { @@ -81,7 +82,8 @@ private double GetPlotAreaHeight(RectRenderItem rect) private double GetPlotAreaWidth(RectRenderItem rect) { - var rightAxis = GetAxisByPosition(eAxisPosition.Right); + var rightAxis = GetAxisActualByPosition(eActualAxisPosition.Right); + var rightSecondAxis = GetAxisActualByPosition(eActualAxisPosition.RightSecond); var lp = ChartRenderer.Chart.Legend?.Position; var right = ((lp == eLegendPosition.Right || lp == eLegendPosition.TopRight) && ChartRenderer.Legend != null ? ChartRenderer.Legend.Rectangle.Bounds.GlobalLeft - RightMargin : @@ -95,7 +97,7 @@ private double GetPlotAreaWidth(RectRenderItem rect) } else { - rightAxisWidth = (rightAxis.Title?.TextBox.GetActualWidth() ?? 0D) + (rightAxis.Rectangle?.Width ?? 0D); + rightAxisWidth = (rightAxis.Title?.TextBox.GetActualWidth() ?? 0D) + (rightAxis.Rectangle?.Width ?? 0D) + (rightSecondAxis.Rectangle?.Width ?? 0D); } var width = right - rightAxisWidth - rect.GlobalLeft; @@ -127,10 +129,16 @@ private double GetPlotAreaLeft() left += ChartRenderer.Legend.Rectangle.Bounds.Width + ChartRenderer.Legend.RightMargin; } - var leftAxis = GetAxisByPosition(eAxisPosition.Left); - if (leftAxis != null) + var leftAxis = GetAxisActualByPosition(eActualAxisPosition.Left); + var leftSecondAxis = GetAxisActualByPosition(eActualAxisPosition.LeftSecond); + if (leftAxis == null) { - if(leftAxis.Title!=null) + leftAxis = GetAxisByPosition(eAxisPosition.Left); + left += leftAxis?.Title?.Rectangle.Width ?? 0D; + } + else + { + if (leftAxis.Title!=null) { left += leftAxis.Title.TextBox.GetActualWidth(); } @@ -144,24 +152,42 @@ private double GetPlotAreaLeft() private double GetPlotAreaTop() { double haHeight = 0; - var topAxis = GetAxisByPosition(eAxisPosition.Top); + var topAxis = GetAxisActualByPosition(eActualAxisPosition.Top); + var topSecondAxis = GetAxisActualByPosition(eActualAxisPosition.TopSecond); if (topAxis == null) { - //var bottomAxis = GetAxisByPosition(sc, eAxisPosition.Bottom); - //if (bottomAxis != null && sc.Chart.XAxis.LabelPosition == eTickLabelPosition.High) - //{ - // top += bottomAxis.Rectangle.Height; - //} - //return top; + //If the axis is not on the top, we should check if there is an axis that has the position on the top. If there is, we should reserve space for the title of the axis. This can happen when LabelPosition is set to Low and the axis is on the bottom, but the position of the axis is set to top. + topAxis = GetAxisByPosition(eAxisPosition.Top); + haHeight = topAxis.Title?.Rectangle.Height ?? 0D; } else { - haHeight = (topAxis.Rectangle?.Height ?? 0D) + (topAxis.Title?.TextBox?.GetActualHeight() ?? 0D); + haHeight = (topAxis.Rectangle?.Height ?? 0D) + (topSecondAxis.Rectangle?.Height ?? 0D) + (topAxis.Title?.TextBox?.GetActualHeight() ?? 0D); } return (Chart.Legend?.Position == eLegendPosition.Top ? ChartRenderer.Legend.Rectangle.Bounds.Bottom : ChartRenderer.Title?.Rectangle?.GlobalBottom ?? 0d) + haHeight + TopMargin; } + private ChartAxisRenderer GetAxisActualByPosition(eActualAxisPosition pos) + { + if (ChartRenderer.HorizontalAxis != null && ChartRenderer.HorizontalAxis.Axis.ActualAxisPosition == pos) + { + return ChartRenderer.HorizontalAxis; + } + else if (ChartRenderer.VerticalAxis != null && ChartRenderer.VerticalAxis.Axis.ActualAxisPosition == pos) + { + return ChartRenderer.VerticalAxis; + } + else if (ChartRenderer.SecondHorizontalAxis != null && ChartRenderer.SecondHorizontalAxis.Axis.ActualAxisPosition == pos) + { + return ChartRenderer.SecondHorizontalAxis; + } + else if (ChartRenderer.SecondVerticalAxis != null && ChartRenderer.SecondVerticalAxis.Axis.ActualAxisPosition == pos) + { + return ChartRenderer.SecondVerticalAxis; + } + return null; + } private ChartAxisRenderer GetAxisByPosition(eAxisPosition pos) { if (ChartRenderer.HorizontalAxis != null && ChartRenderer.HorizontalAxis.Axis.AxisPosition == pos) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs index bee3ce50ef..a5a2ef7d33 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs @@ -120,20 +120,21 @@ private void SetAxisTitleRect(ChartRenderer sc, ChartAxisRenderer axis) Rectangle.Left = sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Right || sc.Chart.Legend.Position == eLegendPosition.TopRight ? sc.Legend.Rectangle.Left - Rectangle.Width - margin : sc.Bounds.Right - Rectangle.Width - margin; break; case eAxisPosition.Bottom: - if(sc.HorizontalAxis!=null && sc.HorizontalAxis.Axis.HasTitle && sc.HorizontalAxis.Axis.AxisPosition==eAxisPosition.Bottom) - { - Rectangle.Top = sc.HorizontalAxis.Rectangle.Bottom + margin; - } - else - { - Rectangle.Top = sc.ChartArea.Rectangle.Height - margin - Rectangle.Height; - } + Rectangle.Top = sc.ChartArea.Rectangle.Height - margin - Rectangle.Height; Rectangle.Left = GetHorizontalLeft(sc); break; case eAxisPosition.Top: Rectangle.Top = sc.Title != null && sc.Title._title.Layout.HasLayout==false ? sc.Title.Rectangle.Bottom+margin : margin; Rectangle.Left = GetHorizontalLeft(sc); break; + //case eActualAxisPosition.BottomSecond: + // Rectangle.Top = sc.HorizontalAxis.Rectangle.Bottom; + // Rectangle.Left = GetHorizontalLeft(sc); + // break; + //case eActualAxisPosition.RightSecond: + // Rectangle.Top = sc.GetPlotAreaTop(); + // Rectangle.Left = sc.VerticalAxis.Rectangle.Right; + // break; } } diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index f20872b5ae..fcc5e978c3 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -135,17 +135,28 @@ private void PlaceHorizontalAxis(ChartAxisRenderer horizontalAxis, bool isSecond horizontalAxis.Rectangle.Left = Plotarea.Group.Left; horizontalAxis.Line.X1 = (float)horizontalAxis.Rectangle.Left; horizontalAxis.Line.X2 = (float)horizontalAxis.Rectangle.Right; - - if (horizontalAxis.Axis.AxisPosition == eAxisPosition.Bottom) + + var axisPos = horizontalAxis.Axis.ActualAxisPosition; + if (axisPos == eActualAxisPosition.Bottom) { horizontalAxis.Rectangle.Top = Plotarea.Group.Top + Plotarea.Rectangle.Height; horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = (float)Plotarea.Group.Top + Plotarea.Rectangle.Height; } - else + else if(axisPos == eActualAxisPosition.BottomSecond) + { + horizontalAxis.Rectangle.Top = Plotarea.Group.Top + Plotarea.Rectangle.Height + HorizontalAxis.Rectangle.Height; + horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = (float)Plotarea.Group.Top + Plotarea.Rectangle.Height; + } + else if(axisPos == eActualAxisPosition.Top) { horizontalAxis.Rectangle.Top = Plotarea.Group.Top - horizontalAxis.Rectangle.Height; horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = (float)Plotarea.Group.Top; } + else + { + horizontalAxis.Rectangle.Top = Plotarea.Group.Top - horizontalAxis.Rectangle.Height - HorizontalAxis.Rectangle.Height; + horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = (float)Plotarea.Group.Top; + } } if (horizontalAxis.Title != null) { @@ -185,26 +196,26 @@ private void PlaceVerticalAxis(ChartAxisRenderer verticalAxis) verticalAxis.Rectangle.Height = Plotarea.Rectangle.Height; verticalAxis.Line.Y1 = (float)verticalAxis.Rectangle.Top; verticalAxis.Line.Y2 = (float)verticalAxis.Rectangle.Bottom; - var axisPos = verticalAxis.Axis.AxisPosition; - if(verticalAxis.Axis.TickLabelPosition==eTickLabelPosition.High) - { - axisPos = axisPos == eAxisPosition.Left ? eAxisPosition.Left : eAxisPosition.Right; - } - //if(verticalAxis.Axis.TickLabelPosition==eTickLabelPosition.NextTo) - //{ - - // verticalAxis.Rectangle.Left = Plotarea.Group.Left - verticalAxis.Rectangle.Width; - // verticalAxis.Line.X1 = verticalAxis.Line.X2 = (float)Plotarea.Group.Left; - //} - //else if (axisPos == eAxisPosition.Left) - if (axisPos == eAxisPosition.Left) + var axisPos = verticalAxis.Axis.ActualAxisPosition; + + if (axisPos == eActualAxisPosition.Left) { verticalAxis.Rectangle.Left = Plotarea.Group.Left - verticalAxis.Rectangle.Width; verticalAxis.Line.X1 = verticalAxis.Line.X2 = (float)Plotarea.Group.Left; } + else if(axisPos == eActualAxisPosition.LeftSecond) + { + verticalAxis.Rectangle.Left = Plotarea.Group.Left - verticalAxis.Rectangle.Width - VerticalAxis.Rectangle.Width; + verticalAxis.Line.X1 = verticalAxis.Line.X2 = (float)Plotarea.Group.Left; + } + else if (axisPos == eActualAxisPosition.Right) + { + verticalAxis.Rectangle.Left = Plotarea.Group.Left + Plotarea.Rectangle.Width; + verticalAxis.Line.X1 = verticalAxis.Line.X2 = (float)Plotarea.Group.Left + Plotarea.Rectangle.Width; + } else { - verticalAxis.Rectangle.Left = Plotarea.Group.Left+Plotarea.Rectangle.Width; + verticalAxis.Rectangle.Left = Plotarea.Group.Left + Plotarea.Rectangle.Width + VerticalAxis.Rectangle.Width; verticalAxis.Line.X1 = verticalAxis.Line.X2 = (float)Plotarea.Group.Left + Plotarea.Rectangle.Width; } } @@ -226,7 +237,8 @@ private void PlaceVerticalAxis(ChartAxisRenderer verticalAxis) } else { - verticalAxis.Title.TextBox.Left = verticalAxis.Rectangle.Left - verticalAxis.Title.TextBox.GetActualWidth() - 1.5; + //verticalAxis.Title.TextBox.Left = verticalAxis.Rectangle.Left - verticalAxis.Title.TextBox.GetActualWidth() - 1.5; + verticalAxis.Title.TextBox.Left = ChartArea.LeftMargin; } } else @@ -251,6 +263,7 @@ private void SetChartArea() item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style.ChartArea.FillReference.Color); item.Rectangle.SetDrawingPropertiesBorder(Theme, Chart.Border, Chart.StyleManager.Style.ChartArea.BorderReference.Color, Chart.Border.Width > 0); item.AppendRenderItems(RenderItems); + item.SetMargins(Chart.TextBody); ChartArea = item; } private ChartAxisRenderer GetAxis(bool vertical, int offset = 0) From 741941748558abcb0c17a3e88ea6d7ed37c94dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 9 Jun 2026 15:17:26 +0200 Subject: [PATCH 027/118] Tweaked tests. Added recalculation to textbody --- .../SvgStandAloneTests.cs | 170 ++++++++++-------- .../SvgItem/SvgTextBodyRenderItem.cs | 1 + .../SvgItem/SvgTextRunRenderItem.cs | 11 +- .../Textbox/ParagraphRenderItem.cs | 9 +- .../RenderItems/Textbox/RenderTextBody.cs | 19 +- .../Svg/SvgTextRunRenderer.cs | 13 +- .../Integration/LayoutSystemTests.cs | 19 +- .../Textbox/DrawingParagraphRenderItem.cs | 2 +- 8 files changed, 163 insertions(+), 81 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index f4dbcfd416..f10e764a70 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -1,76 +1,61 @@ using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; -using EPPlus.DrawingRenderer.Svg; -using EPPlus.Export.ImageRenderer.RenderItems.Shared; -using EPPlus.Graphics; -using OfficeOpenXml.Drawing.Renderer.TextBox; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using EPPlus.DrawingRenderer.RenderItems.SvgItem; -using EPPlus.Fonts.OpenType.Integration.RichText; using EPPlus.Fonts.OpenType.Integration.DataHolders; +using EPPlus.Graphics; using System.Drawing; +using System.Text; namespace EPPlus.Export.ImageRenderer.Tests.DrawingShapeRenderer { [TestClass] public class SvgStandAloneTests : TestBase { - [TestMethod] - public void SvgRectTest() + + private GroupRenderItem GenerateShapeRenderer() { - BoundingBox bounds = new BoundingBox(0,0,500,500); + BoundingBox bounds = new BoundingBox(0, 0, 500, 500); StringBuilder sb = new StringBuilder(); var svgShapeRenderer = new SvgShapeRenderer(bounds, sb); - var baseGroup = new GroupRenderItem(bounds); - var rectItem = new RectRenderItem(baseGroup.Bounds); + var baseGroup = new GroupRenderItem(bounds); - rectItem.Width = 250; - rectItem.Height = 250; - rectItem.FillColor = "darkblue"; + var background = new RectRenderItem(baseGroup.Bounds); - //var textBody = new RenderTextBody(baseGroup.Bounds, true); + background.Width = bounds.Width; + background.Height = bounds.Height; + background.FillColor = "aliceBlue"; - //textBody.Text = "Hello"; - //var para = new SvgParagraphRenderItem(textBody, textBody.Bounds); - - //var para2 = new DrawingParagraphRenderItem(textBody, textBody.Bounds); - //textBody.Paragraphs.Add + baseGroup.AddChildItem(background); + return baseGroup; + } - baseGroup.AddChildItem(rectItem); - //baseGroup.AddChildItem(textBody); + private void GenerateSvgFile(string fileName, BoundingBox bounds, params RenderItem[] items) + { - List items = new List() { baseGroup }; + StringBuilder sb = new StringBuilder(); + var svgShapeRenderer = new SvgShapeRenderer(bounds, sb); - svgShapeRenderer.Render(items); + List renderItems = items.ToList(); + svgShapeRenderer.Render(renderItems); var svg = sb.ToString(); - SaveTextFileToWorkbook("svg\\rectStandalone.svg", svg); + SaveTextFileToWorkbook($"svg\\{fileName}.svg", svg); } [TestMethod] - public void SvgTextRun() + public void SvgRectTest() { - BoundingBox bounds = new BoundingBox(0, 0, 500, 500); - StringBuilder sb = new StringBuilder(); - var svgShapeRenderer = new SvgShapeRenderer(bounds, sb); - - - var baseGroup = new GroupRenderItem(bounds); - - var background = new RectRenderItem(baseGroup.Bounds); - - background.Width = bounds.Width; - background.Height = bounds.Height; - background.FillColor = "aliceBlue"; + var baseGroup = GenerateShapeRenderer(); + GenerateSvgFile("rectStandAlone", baseGroup.Bounds, baseGroup); + } - baseGroup.AddChildItem(background); + [TestMethod] + public void SvgTextRun() + { + var baseGroup = GenerateShapeRenderer(); var rt = new RichTextFormatSimple(); rt.Text = "My text"; @@ -79,42 +64,45 @@ public void SvgTextRun() rt.Family = "Archivo Narrow"; rt.SubFamily = OfficeOpenXml.Interfaces.Fonts.FontSubFamily.Regular; rt.Size = 12f; - - //var paragraph = new SvgParagraphRenderItem() - - var textRun = new SvgTextRunRenderItem(baseGroup.Bounds, rt, rt.Text); - baseGroup.AddChildItem(textRun); - - - List items = new List() { baseGroup }; - svgShapeRenderer.Render(items); + var textRun = new SvgTextRunRenderItem(baseGroup.Bounds, rt, rt.Text, true); - var svg = sb.ToString(); + //Add size of text since svg renders text upwards from the start point. + textRun.YPosition = rt.Size; + baseGroup.AddChildItem(textRun); - SaveTextFileToWorkbook("svg\\textRunStandAlone.svg", svg); + GenerateSvgFile("textRunStandAlone", baseGroup.Bounds, baseGroup); } - [TestMethod] - public void SvgTextBodyTest() + private void GenerateTextBodyFile(string fileName, GroupRenderItem baseGroup, SvgTextBodyRenderItem textBody) { - BoundingBox bounds = new BoundingBox(0, 0, 500, 500); StringBuilder sb = new StringBuilder(); - var svgShapeRenderer = new SvgShapeRenderer(bounds, sb); - - - var baseGroup = new GroupRenderItem(bounds); + var svgShapeRenderer = new SvgShapeRenderer(baseGroup.Bounds, sb); var background = new RectRenderItem(baseGroup.Bounds); - background.Width = bounds.Width; - background.Height = bounds.Height; + background.Width = baseGroup.Bounds.Width; + background.Height = baseGroup.Bounds.Height; background.FillColor = "aliceBlue"; - baseGroup.Bounds.Width = bounds.Width; - baseGroup.Bounds.Height = bounds.Height; + baseGroup.AddChildItem(textBody); + baseGroup.AddChildItem(background); + + List items = new List() { baseGroup }; + textBody.AppendRenderItems(items); + svgShapeRenderer.Render(items); + + var svg = sb.ToString(); + + + SaveTextFileToWorkbook($"svg\\{fileName}.svg", svg); + } + + + private SvgTextBodyRenderItem GenerateTextBody(GroupRenderItem baseGroup) + { var textBody = new SvgTextBodyRenderItem(baseGroup.Bounds, true); var paragraph = textBody.AddParagraph("Hello"); @@ -124,18 +112,56 @@ public void SvgTextBodyTest() rtItem.FontColor = Color.DarkGreen; var para2 = textBody.AddParagraph(rtItem); - baseGroup.AddChildItem(textBody); - baseGroup.AddChildItem(background); + return textBody; + } - List items = new List() { baseGroup }; - textBody.AppendRenderItems(items); + [TestMethod] + public void SvgTextBodyTest() + { + BoundingBox bounds = new BoundingBox(0, 0, 500, 500); + var baseGroup = new GroupRenderItem(bounds); - svgShapeRenderer.Render(items); + baseGroup.Bounds.Width = bounds.Width; + baseGroup.Bounds.Height = bounds.Height; - var svg = sb.ToString(); + var textBody = GenerateTextBody(baseGroup); + + GenerateTextBodyFile("standAloneTextBody", baseGroup, textBody); + } + [TestMethod] + public void SvgTextBodyTestCenterAlignmentGenerated() + { + BoundingBox bounds = new BoundingBox(0, 0, 500, 500); + var baseGroup = new GroupRenderItem(bounds); + + baseGroup.Bounds.Width = bounds.Width; + baseGroup.Bounds.Height = bounds.Height; + + var textBody = GenerateTextBody(baseGroup); + textBody.Paragraphs[0].AddText(" a\r\n new day beckons"); + textBody.Paragraphs[0].HorizontalAlignment = RenderItems.Shared.TextAlignment.Center; + //Text was added to the paragraph above the last paragraph + //We must re-calculate where the next paragraph should be placed + textBody.RecalculateParagraphs(); + textBody.ApplyAutoSize(); + + //new day beckons is the largest line in the centered paragraph[0] + //Assert that the first line has been centered appropriately + Assert.AreEqual(9.890869140625d, textBody.Paragraphs[0].Runs[0].Bounds.Left); + Assert.AreEqual(33.142333984375d, textBody.Paragraphs[0].Runs[1].Bounds.Left); + Assert.AreEqual(59.509033203125d, textBody.Paragraphs[0].Runs[2].Bounds.Left); + Assert.AreEqual(0d, textBody.Paragraphs[0].Runs[3].Bounds.Left); + + //Assert that the second paragraph has been moved correctly + Assert.AreEqual(26.85546875d, textBody.Paragraphs[1].Bounds.Top); + GenerateTextBodyFile("standAloneTextBodyAlignment", baseGroup, textBody); + } + + [TestMethod] + public void SvgTextBoxTest() + { - SaveTextFileToWorkbook("svg\\textBodyStandAlone.svg", svg); } } } diff --git a/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextBodyRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextBodyRenderItem.cs index 70ef9e714e..7d5308eab5 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextBodyRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextBodyRenderItem.cs @@ -1,6 +1,7 @@ using EPPlus.Export.ImageRenderer.RenderItems.Shared; using EPPlus.Fonts.OpenType.Integration.DataHolders; using EPPlus.Graphics; +using System.Drawing; namespace EPPlus.DrawingRenderer.RenderItems.SvgItem diff --git a/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextRunRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextRunRenderItem.cs index e681ac1eef..3971adac4c 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextRunRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/SvgItem/SvgTextRunRenderItem.cs @@ -19,12 +19,21 @@ public SvgTextRunRenderItem(BoundingBox parent, string text, int origRtIdx) : ba { } - public SvgTextRunRenderItem(BoundingBox parent, IFontFormatBase font, string displayText) : base(parent, font, displayText) + public SvgTextRunRenderItem(BoundingBox parent, IFontFormatBase font, string displayText, bool renderTextNode = false) : base(parent, font, displayText) { + RenderTextNode = renderTextNode; } public SvgTextRunRenderItem(BoundingBox parent, string text, IFontFormatBase font, string displayText) : base(parent, text, font, displayText) { } + + + /// + /// If set to true will render its own parent Text Node + /// Will not work properly within paragraphs + /// + internal bool RenderTextNode { get; private set; } = false; + } } diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs index 0213751cb8..9fe3e2a1f8 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs @@ -9,9 +9,6 @@ using EPPlus.Graphics; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Interfaces.RichText; -using System.Drawing; -using System.Text; -using static System.Net.Mime.MediaTypeNames; namespace EPPlus.Export.ImageRenderer.RenderItems.Shared { @@ -86,7 +83,11 @@ public abstract class ParagraphRenderItem : RenderItem protected RichTextCollectionBase _textFragments = new RichTextCollectionBase(); public double ParagraphLineSpacing { get; protected set; } - public TextAlignment HorizontalAlignment { get; protected set; } + + protected TextAlignment _alignment; + + //After setting alignment we must re-calculate the rows + public TextAlignment HorizontalAlignment { get { return _alignment; } set { _alignment = value; WrapTextFragmentsAndGenerateTextRuns(); } } public List Runs { get; set; } = new List(); public TextLineCollection Lines { get; protected set; } public bool DisplayBounds { get; set; } = false; diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs index 2ab53c70bb..92674bf306 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -104,11 +104,11 @@ public void AppendRenderItems(List renderItems) //TranslationOffset.Left = Bounds.Left; //TranslationOffset.Top = Bounds.Top; - renderItems.Add(this); foreach (var item in Paragraphs) { AddChildItem(item); } + renderItems.Add(this); } public ParagraphRenderItem AddParagraph(IRichTextFormatSimple rtFormat) @@ -147,6 +147,23 @@ public void ApplyAutoSize() } } + /// + /// If text is added to the first paragraph without using textbody e.g. Paragraphs[0].AddText() + /// Subsequent paragraphs must be updated + /// + public void RecalculateParagraphs() + { + double lastParagraphBottom = 0; + + foreach(var paragraph in Paragraphs) + { + paragraph.Bounds.Top = lastParagraphBottom; + lastParagraphBottom = paragraph.Bounds.Bottom; + } + + Bounds.Height = lastParagraphBottom; + } + private void AdjustAndAddParagraph(ParagraphRenderItem paragraph) { paragraph.Bounds.Name = $"Container{Paragraphs.Count}"; diff --git a/src/EPPlus.DrawingRenderer/Svg/SvgTextRunRenderer.cs b/src/EPPlus.DrawingRenderer/Svg/SvgTextRunRenderer.cs index 9078ceddc2..d1c9783ae8 100644 --- a/src/EPPlus.DrawingRenderer/Svg/SvgTextRunRenderer.cs +++ b/src/EPPlus.DrawingRenderer/Svg/SvgTextRunRenderer.cs @@ -1,4 +1,5 @@ using EPPlus.DrawingRenderer.RenderItems; +using EPPlus.DrawingRenderer.RenderItems.SvgItem; using EPPlus.Export.ImageRenderer.RenderItems; using EPPlus.Export.ImageRenderer.RenderItems.Shared; using EPPlus.Fonts.OpenType.Utils; @@ -18,6 +19,7 @@ public SvgTextRunRenderer(StringBuilder outputStream) : base(outputStream) { } + string GetFontStyleAttributes(TextRunRenderItem textRun) { string fontStyleAttributes = " "; @@ -166,11 +168,20 @@ public override void Render(TextRunRenderItem textRun) var textRunString = sb.ToString(); //Wrap in another tspan to apply underline color if neccesary - if(string.IsNullOrEmpty(UnderlineColorString) == false) + if (string.IsNullOrEmpty(UnderlineColorString) == false) { textRunString = string.Format(UnderlineColorString, textRunString); } + if (textRun is SvgTextRunRenderItem) + { + var tr = (SvgTextRunRenderItem)textRun; + if (tr.RenderTextNode) + { + textRunString = $"{textRunString}"; + } + } + OutputStream.Append(textRunString); UnderlineColorString = string.Empty; } diff --git a/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs index 68e01cc160..e5a4dc525b 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs @@ -382,8 +382,25 @@ public void TestGetSectionNumber2() } [TestMethod] - public void TestSimpleRichText2() + public void TestLayoutSystemMultipleParagraphs() { + var paragraphEndSymbol = '\u2029'; + + List lstOfRichText = new() { $"Here comes lorem ipsum{paragraphEndSymbol} " + + $"Sed ut perspiciatis,{paragraphEndSymbol}", $"u{paragraphEndSymbol}n{paragraphEndSymbol}de{paragraphEndSymbol} omnis" }; + var font = new FontFormatBase() + { + Family = "Aptos Narrow", + Size = 11, + SubFamily = FontSubFamily.Bold + }; + var fragments = new List() + { + new TextFragment() {Text = lstOfRichText[0], Font = font } + }; + + var layout = new LayoutSystem(fragments); + Assert.AreEqual(3, layout.GetParagraphSeparatorCount()); //var rtCollection = new RichTextCollectionBase(); //var someTextRt = rtCollection.Add("SomeText", true); //var richRt = rtCollection.Add("rich"); diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs index b830d43151..61e1d725ae 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs @@ -192,7 +192,7 @@ private void ImportMarginAndIndent(ExcelDrawingParagraph p) LeftMargin = LeftMargin.PixelToPoint(); RightMargin = RightMargin.PixelToPoint(); - HorizontalAlignment = (TextAlignment)p.HorizontalAlignment; + _alignment = (TextAlignment)p.HorizontalAlignment; LeftMargin = LeftMargin.PixelToPoint(); RightMargin = RightMargin.PixelToPoint(); } From 7d7113a9ec43246f532a88f7145e3339b06e77ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 9 Jun 2026 15:43:04 +0200 Subject: [PATCH 028/118] Fixed positioning of stacked axis --- .../RenderItems/Textbox/RenderTextbox.cs | 24 ++++++ .../Renderer/Chart/ChartAxisRenderer.cs | 42 +++++----- .../Renderer/Chart/ChartDrawingObject.cs | 2 +- .../Renderer/Chart/ChartPlotareaRenderer.cs | 2 +- .../Renderer/Chart/ChartTitleRenderer.cs | 2 +- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 84 ++++++++++++++----- 6 files changed, 115 insertions(+), 41 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs index 788be57ef9..2ac2c8b550 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs @@ -39,6 +39,10 @@ public RectRenderItem Rectangle _rectangle.Bounds.Height = Height; return _rectangle; } + set + { + _rectangle = value; + } } public virtual RenderTextBody TextBody { get; set; } @@ -74,6 +78,16 @@ public double Width return LeftMargin + (TextBody?.Bounds?.Width ?? 0D) + RightMargin; } } + public double WidthRotated + { + get + { + var radians = MathHelper.Radians(Rotation); + var sin = Math.Abs(Math.Sin(radians)); + var cos = Math.Abs(Math.Cos(radians)); + return Width * sin + Height * cos; + } + } public double Height { get @@ -81,6 +95,16 @@ public double Height return TopMargin + (TextBody?.Bounds.Height ?? 0d) + BottomMargin; } } + public double HeightWithRotation + { + get + { + var radians = MathHelper.Radians(Rotation); + var sin = Math.Abs(Math.Sin(radians)); + var cos = Math.Abs(Math.Cos(radians)); + return Width * cos + Height * sin; + } + } public double LeftMargin { get; set; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index bfe77dd36f..8eefdf9cf8 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -408,7 +408,7 @@ private List GetAxisValueTextBoxes() if (LabelOrientation == eTextOrientation.Diagonal) { x = ticMarkX - (height / 2) * cos; - if (Axis.AxisPosition == eAxisPosition.Bottom) + if (Axis.ActualAxisPosition == eActualAxisPosition.Bottom || Axis.ActualAxisPosition == eActualAxisPosition.BottomSecond) { y = ticMarkY + 4 + TopMargin - (height / 2 * cos); } @@ -420,7 +420,7 @@ private List GetAxisValueTextBoxes() else { x = ticMarkX - (height / 2); - if (Axis.AxisPosition == eAxisPosition.Bottom) + if (Axis.ActualAxisPosition == eActualAxisPosition.Bottom || Axis.ActualAxisPosition == eActualAxisPosition.BottomSecond) { y = ticMarkY + BottomMargin + 4; } @@ -435,7 +435,7 @@ private List GetAxisValueTextBoxes() if (LabelOrientation == eTextOrientation.Diagonal) { tb.Rotation = -45; - if(Axis.AxisPosition==eAxisPosition.Bottom) + if(Axis.ActualAxisPosition==eActualAxisPosition.Bottom || Axis.ActualAxisPosition == eActualAxisPosition.BottomSecond) { tb.TextAnchor = eTextAnchor.End; } @@ -444,7 +444,7 @@ private List GetAxisValueTextBoxes() else if (LabelOrientation == eTextOrientation.Vertical) { tb.Rotation = -90; - if (Axis.AxisPosition == eAxisPosition.Bottom) + if (Axis.ActualAxisPosition == eActualAxisPosition.Bottom || Axis.ActualAxisPosition == eActualAxisPosition.BottomSecond) { tb.TextAnchor = eTextAnchor.End; } @@ -574,7 +574,7 @@ private double GetAxisItemLeft(int i, OfficeOpenXml.Interfaces.Drawing.Text.Text private double GetAxisItemTop(int i, OfficeOpenXml.Interfaces.Drawing.Text.TextMeasurement m) { - if (Axis.AxisPosition == eAxisPosition.Top) + if (Axis.ActualAxisPosition == eActualAxisPosition.Top || Axis.ActualAxisPosition == eActualAxisPosition.TopSecond) { switch (LabelOrientation) { @@ -585,7 +585,7 @@ private double GetAxisItemTop(int i, OfficeOpenXml.Interfaces.Drawing.Text.TextM return Rectangle.Bottom - m.Height - TopMargin; } } - else if (Axis.AxisPosition == eAxisPosition.Bottom) + else if (Axis.ActualAxisPosition == eActualAxisPosition.Bottom || Axis.ActualAxisPosition == eActualAxisPosition.BottomSecond) { switch(LabelOrientation) { @@ -684,31 +684,35 @@ private List AddTickmarks(double units, eTimeUnit? dateUnit, dou if (double.IsNaN(parentUnit) || (d % parentUnit != 0)) { double x1, y1, x2, y2; - switch (Axis.AxisPosition) + switch (Axis.ActualAxisPosition) { - case eAxisPosition.Left: + case eActualAxisPosition.Left: + case eActualAxisPosition.LeftSecond: y1 = (float)(Rectangle.Top + Rectangle.Height - ((d - min) / diff * Rectangle.Height)); y2 = y1; - x1 = (float)ChartRenderer.Plotarea.Rectangle.Left - tickMarkWidthOutside; - x2 = (float)ChartRenderer.Plotarea.Rectangle.Left + tickMarkWidthInside; + x1 = (float)Rectangle.Right - tickMarkWidthOutside; + x2 = (float)Rectangle.Right + tickMarkWidthInside; break; - case eAxisPosition.Right: + case eActualAxisPosition.Right: + case eActualAxisPosition.RightSecond: y1 = (float)(Rectangle.Top + Rectangle.Height - ((d - min) / diff * Rectangle.Height)); y2 = y1; - x1 = (float)ChartRenderer.Plotarea.Rectangle.Right - tickMarkWidthInside; - x2 = (float)ChartRenderer.Plotarea.Rectangle.Right + tickMarkWidthOutside; + x1 = (float)Rectangle.Left - tickMarkWidthInside; + x2 = (float)Rectangle.Left + tickMarkWidthOutside; break; - case eAxisPosition.Top: + case eActualAxisPosition.Top: + case eActualAxisPosition.TopSecond: x1 = (float)(Rectangle.Left + ((d - min) / diff * Rectangle.Width)); x2 = x1; - y1 = (float)ChartRenderer.Plotarea.Rectangle.Top - tickMarkWidthOutside; - y2 = (float)ChartRenderer.Plotarea.Rectangle.Top + tickMarkWidthInside; + y1 = (float)Rectangle.Bottom - tickMarkWidthOutside; + y2 = (float)Rectangle.Bottom + tickMarkWidthInside; break; - case eAxisPosition.Bottom: + case eActualAxisPosition.Bottom: + case eActualAxisPosition.BottomSecond: x1 = (float)(Rectangle.Left + ((d - min) / diff * Rectangle.Width)); x2 = x1; - y1 = (float)ChartRenderer.Plotarea.Rectangle.Top - tickMarkWidthInside; - y2 = (float)ChartRenderer.Plotarea.Rectangle.Top + tickMarkWidthOutside; + y1 = (float)Rectangle.Top - tickMarkWidthInside; + y2 = (float)Rectangle.Top + tickMarkWidthOutside; break; default: throw new InvalidOperationException("Invalid axis position"); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs index 2e73aeeb69..6b38e74c7a 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs @@ -56,7 +56,7 @@ internal void SetMargins(ExcelTextBody tb) internal double RightMargin { get; set; } internal double TopMargin { get; set; } internal double BottomMargin { get; set; } - internal RectRenderItem Rectangle { get; set; } + internal virtual RectRenderItem Rectangle { get; set; } protected static RectRenderItem GetRectFromManualLayout(ChartRenderer sc, ExcelLayout layout, BoundingBox parent=null) { var bounds = parent ?? sc.Bounds; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs index e6282569b8..546b2c4ad2 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs @@ -134,7 +134,7 @@ private double GetPlotAreaLeft() if (leftAxis == null) { leftAxis = GetAxisByPosition(eAxisPosition.Left); - left += leftAxis?.Title?.Rectangle.Width ?? 0D; + left += leftAxis?.Title?.TextBox.Width ?? 0D; } else { diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs index 6dd1513e2f..cbd4977a5c 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs @@ -191,7 +191,6 @@ private static string GetDefaultChartTitleText(ChartRenderer sc, ExcelChartTitle internal void InitTextBox(double maxWidth, double maxHeight) { TextBox = new DrawingTextBox(_svgChart.Drawing, _svgChart.ChartArea.Rectangle.Bounds, maxWidth, maxHeight); - Rectangle = TextBox.Rectangle; if (_title.Rotation != 0) { TextBox.Rotation = _title.Rotation; @@ -218,6 +217,7 @@ public DrawingTextBox TextBox { get; private set; } + internal override RectRenderItem Rectangle { get => TextBox.Rectangle; set => base.Rectangle = value; } public override void AppendRenderItems(List renderItems) { var p = _title.DefaultTextBody.Paragraphs.FirstOrDefault(); diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index fcc5e978c3..9ca2534a2a 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -145,7 +145,7 @@ private void PlaceHorizontalAxis(ChartAxisRenderer horizontalAxis, bool isSecond else if(axisPos == eActualAxisPosition.BottomSecond) { horizontalAxis.Rectangle.Top = Plotarea.Group.Top + Plotarea.Rectangle.Height + HorizontalAxis.Rectangle.Height; - horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = (float)Plotarea.Group.Top + Plotarea.Rectangle.Height; + horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = horizontalAxis.Rectangle.Top; } else if(axisPos == eActualAxisPosition.Top) { @@ -155,42 +155,87 @@ private void PlaceHorizontalAxis(ChartAxisRenderer horizontalAxis, bool isSecond else { horizontalAxis.Rectangle.Top = Plotarea.Group.Top - horizontalAxis.Rectangle.Height - HorizontalAxis.Rectangle.Height; - horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = (float)Plotarea.Group.Top; + horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = horizontalAxis.Rectangle.Bottom; } } if (horizontalAxis.Title != null) { - horizontalAxis.Title.Rectangle.Height = Bounds.Height / 4; - horizontalAxis.Title.Rectangle.Width = horizontalAxis.Rectangle?.Width ?? Plotarea.Rectangle.Width; - if (horizontalAxis.Axis.Deleted) + PlaceHorizontalAxisTitle(horizontalAxis); + } + } + + private void PlaceHorizontalAxisTitle(ChartAxisRenderer horizontalAxis) + { + horizontalAxis.Title.Rectangle.Height = Bounds.Height / 4; + horizontalAxis.Title.Rectangle.Width = horizontalAxis.Rectangle?.Width ?? Plotarea.Rectangle.Width; + if (horizontalAxis.Axis.Deleted) + { + if (horizontalAxis.Axis.AxisPosition == eAxisPosition.Bottom) { - if (horizontalAxis.Axis.AxisPosition == eAxisPosition.Bottom) + horizontalAxis.Title.TextBox.Top = Plotarea.Group.Top + Plotarea.Rectangle.Height; + } + else + { + horizontalAxis.Title.TextBox.Top = Plotarea.Group.Top - horizontalAxis.Rectangle.Height; + } + } + else + { + if (horizontalAxis.Axis.AxisPosition == eAxisPosition.Bottom) + { + if (SecondHorizontalAxis.Axis.ActualAxisPosition == eActualAxisPosition.BottomSecond) + { + horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Bottom + SecondHorizontalAxis.Rectangle.Height; + } + else if (horizontalAxis.Axis.ActualAxisPosition == eActualAxisPosition.Bottom) { - horizontalAxis.Title.TextBox.Top = Plotarea.Group.Top + Plotarea.Rectangle.Height; + horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Bottom; } else { - horizontalAxis.Title.TextBox.Top = Plotarea.Group.Top - horizontalAxis.Rectangle.Height; + if (Legend != null && Chart.Legend.Position == eLegendPosition.Top) + { + horizontalAxis.Title.TextBox.Top = Legend.Rectangle.Top - horizontalAxis.Title.Rectangle.Height; + } + else + { + horizontalAxis.Title.TextBox.Top = ChartArea.Rectangle.Bottom - horizontalAxis.Title.Rectangle.Height - ChartArea.BottomMargin; + } } } else { - if (horizontalAxis.Axis.AxisPosition == eAxisPosition.Bottom) + if (SecondHorizontalAxis.Axis.ActualAxisPosition == eActualAxisPosition.TopSecond) { - horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Bottom; + horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Top - SecondHorizontalAxis.Rectangle.Height - horizontalAxis.Title.TextBox.Height; } - else + else if (horizontalAxis.Axis.ActualAxisPosition == eActualAxisPosition.Top) { horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Top - horizontalAxis.Title.TextBox.Height; } + else + { + if (Legend != null && Chart.Legend.Position == eLegendPosition.Top) + { + horizontalAxis.Title.TextBox.Top = Legend.Rectangle.Bottom; + } + else if (Title != null) + { + horizontalAxis.Title.TextBox.Top = Title.Rectangle.Bottom; + } + else + { + horizontalAxis.Title.TextBox.Top = ChartArea.TopMargin; + } + } } - horizontalAxis.Title.TextBox.Left = Plotarea.Group.Left + (Plotarea.Rectangle.Width / 2) - (horizontalAxis.Title.TextBox.Width / 2); } + horizontalAxis.Title.TextBox.Left = Plotarea.Group.Left + (Plotarea.Rectangle.Width / 2) - (horizontalAxis.Title.TextBox.Width / 2); } private void PlaceVerticalAxis(ChartAxisRenderer verticalAxis) { - if(verticalAxis.Axis.Deleted==false && verticalAxis.Rectangle != null) + if (verticalAxis.Axis.Deleted == false && verticalAxis.Rectangle != null) { verticalAxis.Rectangle.Top = Plotarea.Group.Top; verticalAxis.Rectangle.Height = Plotarea.Rectangle.Height; @@ -199,11 +244,11 @@ private void PlaceVerticalAxis(ChartAxisRenderer verticalAxis) var axisPos = verticalAxis.Axis.ActualAxisPosition; if (axisPos == eActualAxisPosition.Left) - { + { verticalAxis.Rectangle.Left = Plotarea.Group.Left - verticalAxis.Rectangle.Width; verticalAxis.Line.X1 = verticalAxis.Line.X2 = (float)Plotarea.Group.Left; } - else if(axisPos == eActualAxisPosition.LeftSecond) + else if (axisPos == eActualAxisPosition.LeftSecond) { verticalAxis.Rectangle.Left = Plotarea.Group.Left - verticalAxis.Rectangle.Width - VerticalAxis.Rectangle.Width; verticalAxis.Line.X1 = verticalAxis.Line.X2 = (float)Plotarea.Group.Left; @@ -220,11 +265,13 @@ private void PlaceVerticalAxis(ChartAxisRenderer verticalAxis) } } + PlaceVerticalAxisTitle(verticalAxis); + } + + private void PlaceVerticalAxisTitle(ChartAxisRenderer verticalAxis) + { if (verticalAxis.Title != null) { - //verticalAxis.Title.Rectangle.Height = Plotarea.Rectangle.Height; - //verticalAxis.Title.Rectangle.Width = sc.Bounds.Width / 4; - //verticalAxis.Title.InitTextBox(); var sinRot = Math.Abs(Math.Sin(MathHelper.Radians(verticalAxis.Title.TextBox.Rotation))); var cosRot = Math.Abs(Math.Cos(MathHelper.Radians(verticalAxis.Title.TextBox.Rotation))); verticalAxis.Title.TextBox.Top = Plotarea.Group.Top + (Plotarea.Rectangle.Height / 2) + ((verticalAxis.Title.TextBox.Height * cosRot + verticalAxis.Title.TextBox.Width * sinRot) / 2); @@ -237,7 +284,6 @@ private void PlaceVerticalAxis(ChartAxisRenderer verticalAxis) } else { - //verticalAxis.Title.TextBox.Left = verticalAxis.Rectangle.Left - verticalAxis.Title.TextBox.GetActualWidth() - 1.5; verticalAxis.Title.TextBox.Left = ChartArea.LeftMargin; } } From bd0f235cfd46497fbe398345217a88cd2aa1c246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 9 Jun 2026 16:29:42 +0200 Subject: [PATCH 029/118] WIP:Axis positioning and sizeing --- .../Chart/LineChartToSvgTests.cs | 18 +++++++++--------- .../Renderer/Chart/ChartPlotareaRenderer.cs | 6 +++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index 10df58204e..092936ce7c 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -193,16 +193,16 @@ public void GenerateSvgForCharts_SecondaryAxis_sheet3() using (var p = OpenTemplatePackage("ChartForSvg_SecondaryAxis.xlsx")) { var ws = p.Workbook.Worksheets[2]; - //var ix = 1; - //var c = ws.Drawings[ix]; - //var svg = c.ToSvg(); - //SaveTextFileToWorkbook($"svg\\ChartForSvg_sheet2_{ix++}.svg", svg); var ix = 1; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\ChartForSvg_SecAxis{ix++}.svg", svg); - } + var c = ws.Drawings[ix]; + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\ChartForSvg_sheet3_{ix++}.svg", svg); + //var ix = 1; + //foreach (ExcelChart c in ws.Drawings) + //{ + // var svg = c.ToSvg(); + // SaveTextFileToWorkbook($"svg\\ChartForSvg_SecAxis{ix++}.svg", svg); + //} } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs index 546b2c4ad2..d4d24f01af 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs @@ -146,6 +146,10 @@ private double GetPlotAreaLeft() { left += leftAxis.Rectangle.Width + 1.5; } + if(leftSecondAxis!=null) + { + left += leftSecondAxis.Rectangle.Width; + } } return left; } @@ -158,7 +162,7 @@ private double GetPlotAreaTop() { //If the axis is not on the top, we should check if there is an axis that has the position on the top. If there is, we should reserve space for the title of the axis. This can happen when LabelPosition is set to Low and the axis is on the bottom, but the position of the axis is set to top. topAxis = GetAxisByPosition(eAxisPosition.Top); - haHeight = topAxis.Title?.Rectangle.Height ?? 0D; + haHeight = topAxis?.Title?.Rectangle.Height ?? 0D; } else { From 45b069bde05c388ba04f82d198807775ffde5bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 9 Jun 2026 16:49:03 +0200 Subject: [PATCH 030/118] Fixed line-ending issue --- .../SvgStandAloneTests.cs | 53 +++++++++++++++++-- .../Integration/TextLayoutEngine.RichText.cs | 10 ++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index f10e764a70..3485957741 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -141,21 +141,66 @@ public void SvgTextBodyTestCenterAlignmentGenerated() var textBody = GenerateTextBody(baseGroup); textBody.Paragraphs[0].AddText(" a\r\n new day beckons"); textBody.Paragraphs[0].HorizontalAlignment = RenderItems.Shared.TextAlignment.Center; + + textBody.Paragraphs[1].HorizontalAlignment = RenderItems.Shared.TextAlignment.Center; + textBody.Paragraphs[1].AddText("\r\n What fun, what fun!"); + //Text was added to the paragraph above the last paragraph //We must re-calculate where the next paragraph should be placed textBody.RecalculateParagraphs(); textBody.ApplyAutoSize(); + double delta = 0.001; + //new day beckons is the largest line in the centered paragraph[0] //Assert that the first line has been centered appropriately - Assert.AreEqual(9.890869140625d, textBody.Paragraphs[0].Runs[0].Bounds.Left); - Assert.AreEqual(33.142333984375d, textBody.Paragraphs[0].Runs[1].Bounds.Left); - Assert.AreEqual(59.509033203125d, textBody.Paragraphs[0].Runs[2].Bounds.Left); + Assert.AreEqual(9.890869140625d, textBody.Paragraphs[0].Runs[0].Bounds.Left, delta); + Assert.AreEqual(33.142333984375d, textBody.Paragraphs[0].Runs[1].Bounds.Left, delta); + Assert.AreEqual(59.509033203125d, textBody.Paragraphs[0].Runs[2].Bounds.Left, delta); Assert.AreEqual(0d, textBody.Paragraphs[0].Runs[3].Bounds.Left); + Assert.AreEqual(4.3760001659393311d, textBody.Paragraphs[1].Runs[0].Bounds.Left, delta); + Assert.AreEqual(0d, textBody.Paragraphs[1].Runs[1].Bounds.Left); + //Assert that the second paragraph has been moved correctly Assert.AreEqual(26.85546875d, textBody.Paragraphs[1].Bounds.Top); - GenerateTextBodyFile("standAloneTextBodyAlignment", baseGroup, textBody); + GenerateTextBodyFile("textBodyAlignCenter", baseGroup, textBody); + } + + [TestMethod] + public void SvgTextBodyTestRightAlignmentGenerated() + { + BoundingBox bounds = new BoundingBox(0, 0, 500, 500); + var baseGroup = new GroupRenderItem(bounds); + + baseGroup.Bounds.Width = bounds.Width; + baseGroup.Bounds.Height = bounds.Height; + + var textBody = GenerateTextBody(baseGroup); + textBody.Paragraphs[0].AddText(" a\r\n new day beckons"); + textBody.Paragraphs[0].HorizontalAlignment = RenderItems.Shared.TextAlignment.Right; + + + textBody.Paragraphs[1].HorizontalAlignment = RenderItems.Shared.TextAlignment.Right; + textBody.Paragraphs[1].AddText("\r\n What fun, what fun!"); + + //Text was added to the paragraph above the last paragraph + //We must re-calculate where the next paragraph should be placed + textBody.RecalculateParagraphs(); + textBody.ApplyAutoSize(); + + double delta = 0.001; + + //Assert that the first line has been aligned correctly + Assert.AreEqual(19.78173828125d, textBody.Paragraphs[0].Runs[0].Bounds.Left, delta); + Assert.AreEqual(43.033203125d, textBody.Paragraphs[0].Runs[1].Bounds.Left, delta); + Assert.AreEqual(69.39990234375d, textBody.Paragraphs[0].Runs[2].Bounds.Left, delta); + Assert.AreEqual(0d, textBody.Paragraphs[0].Runs[3].Bounds.Left); + + Assert.AreEqual(8.7520003318786621d, textBody.Paragraphs[1].Runs[0].Bounds.Left, delta); + Assert.AreEqual(0d, textBody.Paragraphs[1].Runs[1].Bounds.Left); + + GenerateTextBodyFile("textBodyAlignRight", baseGroup, textBody); } [TestMethod] diff --git a/src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.RichText.cs b/src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.RichText.cs index 5a825ac6ad..944e558124 100644 --- a/src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.RichText.cs +++ b/src/EPPlus.Fonts.OpenType/Integration/TextLayoutEngine.RichText.cs @@ -218,9 +218,6 @@ private void ProcessStyleRun( state.CharIdxRt = 0; state.CharIdxWithinOriginal = run.FullTextStart; - state.LineFrag = new LineFragment(state.CurrentFragmentIdx, lineBuilder.Length, state.CharIdxRt, state.CharIdxWithinOriginal); - state.LineFrag.SpaceWidth = run.SpaceWidth; - int i = 0; var len = run.Length; while (i < (len)) @@ -239,6 +236,13 @@ private void ProcessStyleRun( continue; } + //Must be done here rather than above the while loop since when a linebreak is the first char of a new linefragment it will produce empty internalLineFragments otherwise + if(i == 0) + { + state.LineFrag = new LineFragment(state.CurrentFragmentIdx, lineBuilder.Length, state.CharIdxRt, state.CharIdxWithinOriginal); + state.LineFrag.SpaceWidth = run.SpaceWidth; + } + state.CharIdxRt = i; var cWidth = run.GetCharWidthByIndex(i); From a84dcf63872a3185fcd6199c729ec1676d52c307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 9 Jun 2026 17:15:58 +0200 Subject: [PATCH 031/118] Verified vertical alignment also functional --- .../SvgStandAloneTests.cs | 92 ++++++++++++++----- .../RenderItems/Textbox/RenderTextBody.cs | 6 +- 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index 3485957741..9422b040fc 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -31,6 +31,22 @@ private GroupRenderItem GenerateShapeRenderer() return baseGroup; } + private GroupRenderItem GenerateGroupRenderItem() + { + BoundingBox bounds = new BoundingBox(0, 0, 500, 500); + + var baseGroup = new GroupRenderItem(bounds); + + var background = new RectRenderItem(baseGroup.Bounds); + + background.Width = bounds.Width; + background.Height = bounds.Height; + background.FillColor = "aliceBlue"; + + baseGroup.AddChildItem(background); + return baseGroup; + } + private void GenerateSvgFile(string fileName, BoundingBox bounds, params RenderItem[] items) { @@ -90,7 +106,6 @@ private void GenerateTextBodyFile(string fileName, GroupRenderItem baseGroup, Sv baseGroup.AddChildItem(background); List items = new List() { baseGroup }; - textBody.AppendRenderItems(items); svgShapeRenderer.Render(items); @@ -112,31 +127,26 @@ private SvgTextBodyRenderItem GenerateTextBody(GroupRenderItem baseGroup) rtItem.FontColor = Color.DarkGreen; var para2 = textBody.AddParagraph(rtItem); + textBody.AddChildItem(paragraph); + textBody.AddChildItem(para2); + + baseGroup.AddChildItem(textBody); + return textBody; } [TestMethod] public void SvgTextBodyTest() { - BoundingBox bounds = new BoundingBox(0, 0, 500, 500); - var baseGroup = new GroupRenderItem(bounds); - - baseGroup.Bounds.Width = bounds.Width; - baseGroup.Bounds.Height = bounds.Height; - + var baseGroup = GenerateGroupRenderItem(); var textBody = GenerateTextBody(baseGroup); - - GenerateTextBodyFile("standAloneTextBody", baseGroup, textBody); + GenerateSvgFile("standAloneTextBody", baseGroup.Bounds, baseGroup); } [TestMethod] public void SvgTextBodyTestCenterAlignmentGenerated() { - BoundingBox bounds = new BoundingBox(0, 0, 500, 500); - var baseGroup = new GroupRenderItem(bounds); - - baseGroup.Bounds.Width = bounds.Width; - baseGroup.Bounds.Height = bounds.Height; + var baseGroup = GenerateGroupRenderItem(); var textBody = GenerateTextBody(baseGroup); textBody.Paragraphs[0].AddText(" a\r\n new day beckons"); @@ -164,17 +174,13 @@ public void SvgTextBodyTestCenterAlignmentGenerated() //Assert that the second paragraph has been moved correctly Assert.AreEqual(26.85546875d, textBody.Paragraphs[1].Bounds.Top); - GenerateTextBodyFile("textBodyAlignCenter", baseGroup, textBody); + GenerateSvgFile("textBodyAlignCenter", baseGroup.Bounds, baseGroup); } [TestMethod] public void SvgTextBodyTestRightAlignmentGenerated() { - BoundingBox bounds = new BoundingBox(0, 0, 500, 500); - var baseGroup = new GroupRenderItem(bounds); - - baseGroup.Bounds.Width = bounds.Width; - baseGroup.Bounds.Height = bounds.Height; + var baseGroup = GenerateGroupRenderItem(); var textBody = GenerateTextBody(baseGroup); textBody.Paragraphs[0].AddText(" a\r\n new day beckons"); @@ -200,13 +206,55 @@ public void SvgTextBodyTestRightAlignmentGenerated() Assert.AreEqual(8.7520003318786621d, textBody.Paragraphs[1].Runs[0].Bounds.Left, delta); Assert.AreEqual(0d, textBody.Paragraphs[1].Runs[1].Bounds.Left); - GenerateTextBodyFile("textBodyAlignRight", baseGroup, textBody); + GenerateSvgFile("textBodyAlignRight", baseGroup.Bounds, baseGroup); + } + + [TestMethod] + public void SvgTextBodyVerticalAlignmentGenerated() + { + var baseGroup = GenerateGroupRenderItem(); + + var textBody = GenerateTextBody(baseGroup); + textBody.Paragraphs[0].AddText(" a\r\n new day beckons"); + textBody.Paragraphs[1].AddText("\r\n What fun, what fun!"); + + //Text was added to the paragraph above the last paragraph + //We must re-calculate where the next paragraph should be placed + textBody.RecalculateParagraphs(); + textBody.ApplyAutoSize(); + + textBody.AutoSize = false; + textBody.MaxHeight = 500; + + textBody.Bounds.Top = 0; + textBody.VerticalAlignment = TextAnchoringType.Center; + textBody.Bounds.Top = textBody.GetAlignmentVertical(); + + GenerateSvgFile("textBodyAlignVCenter", baseGroup.Bounds, baseGroup); } [TestMethod] - public void SvgTextBoxTest() + public void SvgTextBodyVerticalAlignmentBottomGenerated() { + var baseGroup = GenerateGroupRenderItem(); + + var textBody = GenerateTextBody(baseGroup); + textBody.Paragraphs[0].AddText(" a\r\n new day beckons"); + textBody.Paragraphs[1].AddText("\r\n What fun, what fun!"); + + //Text was added to the paragraph above the last paragraph + //We must re-calculate where the next paragraph should be placed + textBody.RecalculateParagraphs(); + textBody.ApplyAutoSize(); + + textBody.AutoSize = false; + textBody.MaxHeight = 500; + + textBody.Bounds.Top = 0; + textBody.VerticalAlignment = TextAnchoringType.Bottom; + textBody.Bounds.Top = textBody.GetAlignmentVertical(); + GenerateSvgFile("textBodyAlignVBottom", baseGroup.Bounds, baseGroup); } } } diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs index 92674bf306..e93723715b 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -204,7 +204,7 @@ private double GetTopToAddNextParagraphAt() /// Get the start of text space vertically /// /// - protected double GetAlignmentVertical() + public double GetAlignmentVertical() { double alignmentY = 0; @@ -216,10 +216,10 @@ protected double GetAlignmentVertical() //Center means center of a Shape's ENTIRE bounding box height. //Not center of the Inset GetRectangle case TextAnchoringType.Center: - alignmentY = (Bounds.Height) / 2 + Bounds.Top; + alignmentY = (MaxHeight) / 2 - Bounds.Height; break; case TextAnchoringType.Bottom: - alignmentY = Bounds.Height; + alignmentY = MaxHeight - Bounds.Height; break; } From 388c06e02c66113663f04e5e82a82942853e553c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 10 Jun 2026 11:27:04 +0200 Subject: [PATCH 032/118] Changed TextBox. GroupItem realtime. + margin grp --- .../SvgStandAloneTests.cs | 106 +++++++++ .../RenderItems/Textbox/RenderTextBody.cs | 5 +- .../RenderItems/Textbox/RenderTextbox.cs | 106 +++++++-- .../RenderItems/Textbox/DrawingTextBox.cs | 208 +----------------- 4 files changed, 198 insertions(+), 227 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index 9422b040fc..ba014aa166 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -1,10 +1,14 @@ using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; using EPPlus.DrawingRenderer.RenderItems.SvgItem; +using EPPlus.Export.ImageRenderer.RenderItems.SvgItem; using EPPlus.Fonts.OpenType.Integration.DataHolders; using EPPlus.Graphics; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Engineering; +using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using System.Drawing; using System.Text; +using static OfficeOpenXml.Drawing.OleObject.Structures.OleObjectDataStructures; namespace EPPlus.Export.ImageRenderer.Tests.DrawingShapeRenderer { @@ -230,6 +234,10 @@ public void SvgTextBodyVerticalAlignmentGenerated() textBody.VerticalAlignment = TextAnchoringType.Center; textBody.Bounds.Top = textBody.GetAlignmentVertical(); + double delta = 0.001; + + Assert.AreEqual(180.04052829742432d, textBody.Bounds.Top, delta); + GenerateSvgFile("textBodyAlignVCenter", baseGroup.Bounds, baseGroup); } @@ -254,7 +262,105 @@ public void SvgTextBodyVerticalAlignmentBottomGenerated() textBody.VerticalAlignment = TextAnchoringType.Bottom; textBody.Bounds.Top = textBody.GetAlignmentVertical(); + double delta = 0.001; + Assert.AreEqual(430.04052829742432d, textBody.Bounds.Top, delta); + GenerateSvgFile("textBodyAlignVBottom", baseGroup.Bounds, baseGroup); } + + + + [TestMethod] + public void BasicTextBox() + { + var group = GenerateGroupRenderItem(); + + var textbox = new RenderTextbox(group.Bounds, 500d, 500d); + textbox.TextBody = new SvgTextBodyRenderItem(group.Bounds, true); + var paragraph = textbox.TextBody.AddParagraph("Hello"); + + paragraph.AddText(" There"); + + var rtItem = new RichTextFormatSimple("Second paragraph", "Archivo Narrow", 16f, true); + rtItem.FontColor = Color.DarkGreen; + var para2 = textbox.TextBody.AddParagraph(rtItem); + + textbox.Rectangle.FillColor = "#F9F6C4"; + + textbox.AppendRenderItems(group.RenderItems); + + double delta = 0.001; + + Assert.AreEqual(107.95200681686401d, textbox.Width, delta); + Assert.AreEqual(34.979735851287842d, textbox.Height, delta); + + StringBuilder sb = new StringBuilder(); + var svgShapeRenderer = new SvgShapeRenderer(group.Bounds, sb); + + List renderItems = new List() { group }; + svgShapeRenderer.Render(renderItems); + + var svg = sb.ToString(); + //textbox.Rectangle.BorderColor = "green"; + //textbox.Rectangle.BorderWidth = 5; + + var fileName = "BasicTextbox"; + + SaveTextFileToWorkbook($"svg\\{fileName}.svg", svg); + + //var textBody = new (baseGroup.Bounds, true); + } + + + + [TestMethod] + public void TextBoxWithMargins() + { + var group = GenerateGroupRenderItem(); + + var textbox = new RenderTextbox(group.Bounds, 500d, 500d); + textbox.TextBody = new SvgTextBodyRenderItem(group.Bounds, true); + var paragraph = textbox.TextBody.AddParagraph("Hello"); + + paragraph.AddText(" There"); + + var rtItem = new RichTextFormatSimple("Second paragraph", "Archivo Narrow", 16f, true); + rtItem.FontColor = Color.DarkGreen; + var para2 = textbox.TextBody.AddParagraph(rtItem); + + textbox.Rectangle.FillColor = "#F9F6C4"; + + double delta = 0.001; + + textbox.LeftMargin = 10d; + textbox.TopMargin = 10d; + + textbox.AppendRenderItems(group.RenderItems); + + //Assert local position unchanged + Assert.AreEqual(0d, textbox.TextBody.Left); + Assert.AreEqual(0d, textbox.TextBody.Top); + + //Assert global position changed + Assert.AreEqual(10d, textbox.TextBody.Bounds.Position.X); + Assert.AreEqual(10d, textbox.TextBody.Bounds.Position.Y); + + //Assert width and height changed by margins + Assert.AreEqual(117.95200681686401d, textbox.Width, delta); + Assert.AreEqual(44.979735851287842d, textbox.Height, delta); + + StringBuilder sb = new StringBuilder(); + var svgShapeRenderer = new SvgShapeRenderer(group.Bounds, sb); + + List renderItems = new List() { group }; + svgShapeRenderer.Render(renderItems); + + var svg = sb.ToString(); + var fileName = "MarginTextBox"; + + SaveTextFileToWorkbook($"svg\\{fileName}.svg", svg); + + //var textBody = new (baseGroup.Bounds, true); + } } } diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs index e93723715b..83b5ccab0e 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -104,11 +104,14 @@ public void AppendRenderItems(List renderItems) //TranslationOffset.Left = Bounds.Left; //TranslationOffset.Top = Bounds.Top; + renderItems.Add(this); + + var titleItem = new TitleRenderItem("TextBody group"); + AddChildItem(titleItem); foreach (var item in Paragraphs) { AddChildItem(item); } - renderItems.Add(this); } public ParagraphRenderItem AddParagraph(IRichTextFormatSimple rtFormat) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs index 2ac2c8b550..469a39740b 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs @@ -15,10 +15,12 @@ public RenderTextbox(BoundingBox parent, double left, double top, double width, public void Init(BoundingBox parent, double maxWidth, double maxHeight) { Parent = parent; - _rectangle = new RectRenderItem(Parent); - //TextBody = new TextBody(Rectangle.Bounds, true); + _group = new GroupRenderItem(Parent); + _rectangle = new RectRenderItem(_group.Bounds); + _marginGroup = new GroupRenderItem(_group.Bounds); + //TextBody = new RenderTextBody(Rectangle.Bounds, true); //TextBody.MaxWidth = maxWidth; - //TextBody.MaxHeight = maxHeight; + //TextBody.MaxHeight = maxHeight; } public RenderTextbox(BoundingBox parent, double maxWidth, double maxHeight) @@ -26,10 +28,12 @@ public RenderTextbox(BoundingBox parent, double maxWidth, double maxHeight) Init(parent, maxWidth, maxHeight); } - //Simplified input - public RenderTextbox(BoundingBox parent, BoundingBox maxBounds) - { - } + //The origin point of the entire textbox itself (its outermost left and top point) + protected GroupRenderItem _group; + //The origin point of the textbody after applied margins + protected GroupRenderItem _marginGroup; + + protected RectRenderItem _rectangle =null; public RectRenderItem Rectangle { @@ -45,30 +49,38 @@ public RectRenderItem Rectangle } } - public virtual RenderTextBody TextBody { get; set; } + RenderTextBody _textBody; + + public virtual RenderTextBody TextBody + { + get { return _textBody; } + set + { _textBody = value; + //Margins should affect textbody global position in real-time + _textBody.Bounds.Parent = _marginGroup.Bounds; + } + } public double Left { get { - return Rectangle.Bounds.Left; //TextBody.Bounds.Left - LeftMargin; + return _group.Bounds.Left; } set { - //TextBody.Bounds.Left = value + LeftMargin; - Rectangle.Bounds.Left = value; + _group.Bounds.Left = value; } } public double Top { get { - return Rectangle.Bounds.Top; //TextBody.Bounds.Top - TopMargin; + return _group.Bounds.Top; } set { - //TextBody.Bounds.Top = value + TopMargin; - Rectangle.Bounds.Top = value; + _group.Bounds.Top = value; } } public double Width @@ -107,12 +119,13 @@ public double HeightWithRotation } public double LeftMargin { - get; set; + get { return _marginGroup.Left; } set { _marginGroup.Left = value; } } public double TopMargin { - get; set; + get { return _marginGroup.Top; } + set { _marginGroup.Top = value; } } public double RightMargin @@ -130,11 +143,11 @@ public double Rotation { get { - return Rectangle.Bounds.Rotation; + return _group.Rotation; } set { - Rectangle.Bounds.Rotation = value; + _group.Rotation = value; } } /// @@ -171,8 +184,61 @@ public double GetActualBottom() } public override void AppendRenderItems(List renderItems) { - renderItems.Add(Rectangle); - TextBody.AppendRenderItems(renderItems); + var rect = Rectangle; + + //As the rect item is inside the group, we set the left and right to the group and top and left on the rect to 0. + _group.Bounds.Left = Left; + _group.Bounds.Top = Top; + _group.Bounds.Width = Width; + _group.Bounds.Height = Height; + + _group.TextAnchor = TextAnchor.ToEnumString(); + renderItems.Add(_group); + rect.Top = 0; + rect.Left = 0; + + if (TextBody.AutoSize) + { + TextBody.ApplyAutoSize(); + } + + rect.Width = Width; + rect.Height = Height; + + var titleItem = new TitleRenderItem("TextBox group"); + _group.RenderItems.Add(titleItem); + //The rect shound encapse the text element, so we need to set the left depending on the text anchor. + if (TextAnchor == eTextAnchor.Middle) + { + _group.Bounds.Left += -(rect.Bounds.Width / 2); + } + else if (TextAnchor == eTextAnchor.End) + { + if (Math.Abs(Rotation) == 45) + { + const double COS45 = 0.70710678118654757; //Constant for Math.Sin(Math.PI / 4) --45 degrees + _group.Bounds.Left += -(rect.Bounds.Width * COS45); + _group.Bounds.Top += (rect.Bounds.Width * COS45); + } + else + { + _group.Bounds.Left += rect.Bounds.Height / 2; + _group.Bounds.Top += (rect.Bounds.Width); + } + } + _group.RenderItems.Add(rect); + + //The textbox should be in local-space. + //If I.e. a user changes textbody left and right, changing margin on the parent should not change the Local coordinates + //Therefore a group inbetween should hold the margins + _marginGroup.Left = LeftMargin; + _marginGroup.Top = TopMargin; + + var marginTitleItem = new TitleRenderItem("TextBox Margin Group"); + _marginGroup.AddChildItem(marginTitleItem); + + _group.AddChildItem(_marginGroup); + TextBody.AppendRenderItems(_marginGroup.RenderItems); } /// /// How the text is anchored. diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs index de8c20bdf3..ebbafe5446 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -22,9 +22,8 @@ internal DrawingTextBox(ExcelDrawing drawing, BoundingBox parent, double left, d private void Init(ExcelDrawing drawing, BoundingBox parent, double maxWidth, double maxHeight) { Parent = parent; - _drawing= drawing; - _rectangle = new RectRenderItem(parent); - TextBody = new DrawingTextBody(drawing, Rectangle.Bounds, true); + _drawing= drawing; + TextBody = new DrawingTextBody(drawing, _marginGroup.Bounds, true); TextBody.MaxWidth = maxWidth; TextBody.MaxHeight = maxHeight; } @@ -34,66 +33,6 @@ internal DrawingTextBox(ExcelDrawing drawing, BoundingBox parent, double maxWidt Init(drawing, parent, maxWidth, maxHeight); } - ////Simplified input - //internal DrawingTextBox(BoundingBox parent, BoundingBox maxBounds) : this( - // parent, maxBounds.Left, maxBounds.Top, maxBounds.Width, maxBounds.Height, maxBounds.Width, maxBounds.Height) - //{ - //} - //RectRenderItem _rectangle =null; - //public RectRenderItem Rectangle - //{ - // get - // { - // _rectangle.Bounds.Width = Width; - // _rectangle.Bounds.Height = Height; - // return _rectangle; - // } - //} - //internal override void AppendRenderItems(List renderItems) - //{ - // var rect = Rectangle; - - // SvgGroupItem groupItem; - // if (Rotation == 0) - // { - // groupItem = new SvgGroupItem(DrawingRenderer, new BoundingBox(Left, Top, Width, Height)); - // } - // else - // { - // groupItem = new SvgGroupItem(DrawingRenderer, new BoundingBox(Left, Top, Width, Height), Rotation); - // } - // groupItem.TextAnchor = TextAnchor.ToEnumString(); - // renderItems.Add(groupItem); - - // var textboxGroupItem = new SvgGroupItem(DrawingRenderer); - // renderItems.Add(textboxGroupItem); - - // var titleItem = new SvgTitleItem(DrawingRenderer, "TextBodySvg Rect"); - // //The rect shound encapse the text element, so we need to set the left depending on the text anchor. - // if (TextAnchor == eTextAnchor.Middle) - // { - // rect.Bounds.Left = -(rect.Bounds.Width / 2); - // } - // else if (TextAnchor == eTextAnchor.End) - // { - // rect.Bounds.Left = -rect.Bounds.Width; - // } - // else - // { - // rect.Bounds.Left = 0; - // } - // rect.Bounds.Top = 0; - // renderItems.Add(titleItem); - // renderItems.Add(rect); - - // renderItems.Add(new SvgEndGroupItem(DrawingRenderer, rect.Bounds)); - - // TextBody.Bounds.Left = LeftMargin; - // TextBody.Bounds.Top = TopMargin; - // TextBody.AppendRenderItems(renderItems); - // renderItems.Add(new SvgEndGroupItem(DrawingRenderer, rect.Bounds)); - //} - internal void AddText(string text = null) { TextBody.AddParagraph(text); @@ -112,97 +51,6 @@ public void SetDrawingTextBody(DrawingTextBody tb) } public override RenderTextBody TextBody { get { return _textBody; } set { _textBody = (DrawingTextBody)value; } } - //public double Left - //{ - // get - // { - // return Rectangle.Bounds.Left; //TextBody.Bounds.Left - LeftMargin; - - // } - // set - // { - // //TextBody.Bounds.Left = value + LeftMargin; - // Rectangle.Bounds.Left = value; - // } - //} - //public double Top - //{ - // get - // { - // return Rectangle.Bounds.Top; //TextBody.Bounds.Top - TopMargin; - // } - // set - // { - // //TextBody.Bounds.Top = value + TopMargin; - // Rectangle.Bounds.Top = value; - // } - //} - //public double Width - //{ - // get - // { - // return LeftMargin + (TextBody?.Width ?? 0D) + RightMargin; - // } - //} - //public double Height - //{ - // get - // { - // return TopMargin + (TextBody?.Height ?? 0d) + BottomMargin; - // } - //} - //internal BoundingBox Parent { get; private set; } - //internal double Rotation - //{ - // get - // { - // return Rectangle.Bounds.Rotation; - // } - // set - // { - // Rectangle.Bounds.Rotation = value; - // } - //} - /// - /// Gets the actual width of the rotated textbox. - /// - /// - //internal double GetActualWidth() - //{ - // return Width * Math.Abs(Math.Cos(MathHelper.Radians(Rotation))) + Height * Math.Abs(Math.Sin(MathHelper.Radians(Rotation))); - //} - ///// - ///// Gets the actual right position of the rotated textbox. - ///// - ///// - //internal double GetActualRight() - //{ - // return Left+GetActualWidth(); - //} - ///// - ///// Gets the actual height of the rotated textbox. - ///// - ///// - //internal double GetActualHeight() - //{ - // return Width * Math.Abs(Math.Sin(MathHelper.Radians(Rotation))) + Height * Math.Abs(Math.Cos(MathHelper.Radians(Rotation))); - //} - /// - /// Gets the actual right position of the rotated textbox. - /// - /// - //internal double GetActualBottom() - //{ - // return Top + GetActualHeight(); - //} - /// - /// How the text is anchored. - /// - //internal eTextAnchor TextAnchor - //{ - // get; - // set; - //} internal void ImportTextBodyAndParagraphs(ExcelTextBody body, bool useDefaults = true, ExcelHorizontalAlignment horizontalDefault = ExcelHorizontalAlignment.Left) { @@ -223,58 +71,6 @@ internal void ImportTextBodyAndParagraphs(ExcelTextBody body, bool useDefaults = _textBody.ImportTextBodyAndParagraphs(body, horizontalDefault); } - public override void AppendRenderItems(List renderItems) - { - var rect = Rectangle; - - GroupRenderItem groupItem = groupItem = new GroupRenderItem((BoundingBox)rect.Bounds.Parent, Rotation); - groupItem.Bounds = new BoundingBox(Left, Top, Width, Height); - groupItem.TextAnchor = TextAnchor.ToEnumString(); - renderItems.Add(groupItem); - - rect.Top = 0; - rect.Left = 0; - - if (TextBody.AutoSize) - { - TextBody.ApplyAutoSize(); - rect.Width = TextBody.Width + RightMargin; - rect.Height = TextBody.Height + BottomMargin; - rect.Left = -LeftMargin; - rect.Top = -TopMargin; - } - - var titleItem = new TitleRenderItem("TextBodySvg Rect"); - //The rect shound encapse the text element, so we need to set the left depending on the text anchor. - if(TextAnchor==eTextAnchor.Middle) - { - groupItem.Bounds.Left += -(rect.Bounds.Width / 2); - } - else if(TextAnchor==eTextAnchor.End) - { - if (Math.Abs(Rotation) == 45) - { - const double COS45 = 0.70710678118654757; //Constant for Math.Sin(Math.PI / 4) --45 degrees - groupItem.Bounds.Left += -(rect.Bounds.Width * COS45); - groupItem.Bounds.Top += (rect.Bounds.Width * COS45); - } - else - { - groupItem.Bounds.Left += rect.Bounds.Height / 2; - groupItem.Bounds.Top += (rect.Bounds.Width); - } - } - groupItem.RenderItems.Add(titleItem); - - ////TextBody.SetHorizontalAlignmentPosition(); - //TextBody.Bounds.Left = LeftMargin; - //TextBody.Bounds.Top = TopMargin; - - //As the rect item is inside the group, we set the left and right to the group and top and left on the rect to 0. - groupItem.RenderItems.Add(rect); - TextBody.AppendRenderItems(groupItem.RenderItems); - } - internal void ImportParagraph(ExcelDrawingParagraph item, double startingY, string text = null) { _textBody.ImportParagraph(item, startingY, text); From 34c04afdaea76eed5b8e1220169a2551b2b503b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 10 Jun 2026 11:35:23 +0200 Subject: [PATCH 033/118] Added tests for new textbox --- .../SvgStandAloneTests.cs | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index ba014aa166..d4de4dfc23 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -268,12 +268,9 @@ public void SvgTextBodyVerticalAlignmentBottomGenerated() GenerateSvgFile("textBodyAlignVBottom", baseGroup.Bounds, baseGroup); } - - - [TestMethod] - public void BasicTextBox() + private RenderTextbox GenerateTextBox(out GroupRenderItem group) { - var group = GenerateGroupRenderItem(); + group = GenerateGroupRenderItem(); var textbox = new RenderTextbox(group.Bounds, 500d, 500d); textbox.TextBody = new SvgTextBodyRenderItem(group.Bounds, true); @@ -287,6 +284,13 @@ public void BasicTextBox() textbox.Rectangle.FillColor = "#F9F6C4"; + return textbox; + } + + [TestMethod] + public void BasicTextBox() + { + var textbox = GenerateTextBox(out GroupRenderItem group); textbox.AppendRenderItems(group.RenderItems); double delta = 0.001; @@ -294,21 +298,7 @@ public void BasicTextBox() Assert.AreEqual(107.95200681686401d, textbox.Width, delta); Assert.AreEqual(34.979735851287842d, textbox.Height, delta); - StringBuilder sb = new StringBuilder(); - var svgShapeRenderer = new SvgShapeRenderer(group.Bounds, sb); - - List renderItems = new List() { group }; - svgShapeRenderer.Render(renderItems); - - var svg = sb.ToString(); - //textbox.Rectangle.BorderColor = "green"; - //textbox.Rectangle.BorderWidth = 5; - - var fileName = "BasicTextbox"; - - SaveTextFileToWorkbook($"svg\\{fileName}.svg", svg); - - //var textBody = new (baseGroup.Bounds, true); + GenerateSvgFile("BasicTextBox", group.Bounds, group); } @@ -316,19 +306,7 @@ public void BasicTextBox() [TestMethod] public void TextBoxWithMargins() { - var group = GenerateGroupRenderItem(); - - var textbox = new RenderTextbox(group.Bounds, 500d, 500d); - textbox.TextBody = new SvgTextBodyRenderItem(group.Bounds, true); - var paragraph = textbox.TextBody.AddParagraph("Hello"); - - paragraph.AddText(" There"); - - var rtItem = new RichTextFormatSimple("Second paragraph", "Archivo Narrow", 16f, true); - rtItem.FontColor = Color.DarkGreen; - var para2 = textbox.TextBody.AddParagraph(rtItem); - - textbox.Rectangle.FillColor = "#F9F6C4"; + var textbox = GenerateTextBox(out GroupRenderItem group); double delta = 0.001; @@ -349,18 +327,37 @@ public void TextBoxWithMargins() Assert.AreEqual(117.95200681686401d, textbox.Width, delta); Assert.AreEqual(44.979735851287842d, textbox.Height, delta); - StringBuilder sb = new StringBuilder(); - var svgShapeRenderer = new SvgShapeRenderer(group.Bounds, sb); - List renderItems = new List() { group }; - svgShapeRenderer.Render(renderItems); + GenerateSvgFile("MarginTextBox", group.Bounds, group); + } - var svg = sb.ToString(); - var fileName = "MarginTextBox"; + [TestMethod] + public void TextBoxWithAllMargins() + { + var textbox = GenerateTextBox(out GroupRenderItem group); - SaveTextFileToWorkbook($"svg\\{fileName}.svg", svg); + double delta = 0.001; + + textbox.LeftMargin = 10d; + textbox.TopMargin = 10d; + textbox.RightMargin = 10d; + textbox.BottomMargin = 10d; + + textbox.AppendRenderItems(group.RenderItems); + + //Assert local position unchanged + Assert.AreEqual(0d, textbox.TextBody.Left); + Assert.AreEqual(0d, textbox.TextBody.Top); + + //Assert global position changed + Assert.AreEqual(10d, textbox.TextBody.Bounds.Position.X); + Assert.AreEqual(10d, textbox.TextBody.Bounds.Position.Y); + + //Assert width and height changed by margins + Assert.AreEqual(127.95200681686401d, textbox.Width, delta); + Assert.AreEqual(54.979735851287842d, textbox.Height, delta); - //var textBody = new (baseGroup.Bounds, true); + GenerateSvgFile("AllMarginsTextBox", group.Bounds, group); } } } From 29cd62cbdd95a11bf8cd53b3b7e96568c5ccd7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 10 Jun 2026 15:17:14 +0200 Subject: [PATCH 034/118] Fixed alignment bug and in ChartTitle.DisplayedText --- .../Chart/LineChartToSvgTests.cs | 39 +++----- .../SvgStandAloneTests.cs | 43 ++++++--- .../Shape/ShapeToSvgTests.cs | 50 +++++++---- .../Textbox/ParagraphRenderItem.cs | 12 +++ .../RenderItems/Textbox/RenderTextbox.cs | 8 +- src/EPPlus/Drawing/Chart/ExcelChartTitle.cs | 1 + src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 2 +- .../Textbox/DrawingParagraphRenderItem.cs | 2 +- .../RenderItems/Textbox/DrawingTextBody.cs | 88 ++++++++++++------- 9 files changed, 154 insertions(+), 91 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index 092936ce7c..44f5074ad0 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -19,17 +19,17 @@ public void GenerateSvgForLineCharts_sheet1() { var ws = p.Workbook.Worksheets[0]; - var ix = 6; - var c = ws.Drawings[ix]; - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\ChartForSvg_ind{ix++}.svg", svg); + //var ix = 6; + //var c = ws.Drawings[ix]; + //var svg = c.ToSvg(); + //SaveTextFileToWorkbook($"svg\\ChartForSvg_ind{ix++}.svg", svg); - //var ix = 0; - //foreach (ExcelChart c in ws.Drawings) - //{ - // var svg = c.ToSvg(); - // SaveTextFileToWorkbook($"svg\\ChartForSvg{ix++}.svg", svg); - //} + for(int i = 5; i< ws.Drawings.Count; i++) + { + var c = ws.Drawings[i]; + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\ChartForSvg{i}.svg", svg); + } } } @@ -128,25 +128,6 @@ public void GenerateSvgForLineCharts() } } } - [TestMethod] - public void GenerateSvgForLineCharts2() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("Superscript.xlsx")) - { - var ws = p.Workbook.Worksheets[0]; - //var ix = 1; - //var c = ws.Drawings[ix]; - //var svg = renderer.RenderDrawingToSvg(c); - //SaveTextFileToWorkbook($"svg\\LineChartForSvg_Single{ix++}.svg", svg); - var ix = 1; - foreach (var c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\ss{ix++}.svg", svg); - } - } - } [TestMethod] public void GenerateSvgForCharts_SecondaryAxis() diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index d4de4dfc23..e8001dbaee 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -301,8 +301,6 @@ public void BasicTextBox() GenerateSvgFile("BasicTextBox", group.Bounds, group); } - - [TestMethod] public void TextBoxWithMargins() { @@ -345,19 +343,44 @@ public void TextBoxWithAllMargins() textbox.AppendRenderItems(group.RenderItems); - //Assert local position unchanged - Assert.AreEqual(0d, textbox.TextBody.Left); - Assert.AreEqual(0d, textbox.TextBody.Top); - - //Assert global position changed - Assert.AreEqual(10d, textbox.TextBody.Bounds.Position.X); - Assert.AreEqual(10d, textbox.TextBody.Bounds.Position.Y); - //Assert width and height changed by margins Assert.AreEqual(127.95200681686401d, textbox.Width, delta); Assert.AreEqual(54.979735851287842d, textbox.Height, delta); GenerateSvgFile("AllMarginsTextBox", group.Bounds, group); } + + + /// + /// TODO: Discuss. Should it really work like this? + /// There IS an argument to be made that margin should BE textbody position + /// At the same time then positioning in accordance with vertical aligment then becomes difficult + /// And might affect the margin + /// + [TestMethod] + public void TextBoxWithAllMarginsANDTextbodyChanged() + { + var textbox = GenerateTextBox(out GroupRenderItem group); + + double delta = 0.001; + + textbox.TextBody.AutoSize = true; + + textbox.TextBody.Left = 15d; + textbox.TextBody.Top = 15d; + + textbox.LeftMargin = 10d; + textbox.TopMargin = 10d; + textbox.RightMargin = 10d; + textbox.BottomMargin = 10d; + + textbox.AppendRenderItems(group.RenderItems); + + //Assert width and height changed by margins and textbody + Assert.AreEqual(142.952006816864d, textbox.Width, delta); + Assert.AreEqual(69.979735851287842d, textbox.Height, delta); + + GenerateSvgFile("TextAnchor_TextBox", group.Bounds, group); + } } } diff --git a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs index 48c1479b8e..02f99f16ea 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs @@ -497,6 +497,41 @@ public void GenerateSvgForCircle() } } + [TestMethod] + public void SuperScriptShape() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("Superscript.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + //var ix = 1; + //var c = ws.Drawings[ix]; + //var svg = renderer.RenderDrawingToSvg(c); + //SaveTextFileToWorkbook($"svg\\LineChartForSvg_Single{ix++}.svg", svg); + var ix = 1; + foreach (var c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\ss{ix++}.svg", svg); + } + } + } + + [TestMethod] + public void SuperAndSubScript() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("SuperAndSubScript.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + + var currShape = ws.Drawings[0]; + + var svg = currShape.ToSvg(); + SaveTextFileToWorkbook("svg\\SuperAndSubScript.svg", svg); + } + } + [TestMethod] public void OpenRightAligned() { @@ -637,20 +672,5 @@ public void CreateChartsWithDifferentSize() SaveAndCleanup(p); } } - - [TestMethod] - public void SuperAndSubScript() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("SuperAndSubScript.xlsx")) - { - var ws = p.Workbook.Worksheets[0]; - - var currShape = ws.Drawings[0]; - - var svg = currShape.ToSvg(); - SaveTextFileToWorkbook("svg\\SuperAndSubScript.svg", svg); - } - } } } diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs index 9fe3e2a1f8..66da7c508c 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs @@ -180,6 +180,18 @@ void InitBasedOnParent(RenderTextBody textBody) ParentMaxWidth = textBody.MaxWidth; ParentMaxHeight = textBody.MaxHeight; AutoSize = textBody.AutoSize; + + if (AutoSize == false) + { + Bounds.Width = textBody.Width; + Bounds.Height = textBody.Height; + } + else + { + //Set to max until measured + Bounds.Width = ParentMaxWidth; + Bounds.Height = ParentMaxHeight; + } } TextLineCollection WrapFragmentsToLines(List? fragments = null) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs index 469a39740b..7939b54198 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs @@ -87,7 +87,7 @@ public double Width { get { - return LeftMargin + (TextBody?.Bounds?.Width ?? 0D) + RightMargin; + return LeftMargin + (TextBody?.Bounds?.Width ?? 0D) + RightMargin + TextBody.Left; } } public double WidthRotated @@ -104,7 +104,7 @@ public double Height { get { - return TopMargin + (TextBody?.Bounds.Height ?? 0d) + BottomMargin; + return TopMargin + (TextBody?.Bounds.Height ?? 0d) + BottomMargin + TextBody.Top; } } public double HeightWithRotation @@ -229,8 +229,8 @@ public override void AppendRenderItems(List renderItems) _group.RenderItems.Add(rect); //The textbox should be in local-space. - //If I.e. a user changes textbody left and right, changing margin on the parent should not change the Local coordinates - //Therefore a group inbetween should hold the margins + //If e.g. a user changes textbody left and right, changing margin on the parent should not change the Local coordinates + //Therefore a group in-between should hold the margins _marginGroup.Left = LeftMargin; _marginGroup.Top = TopMargin; diff --git a/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs index 1d41c5b709..400f28e5b7 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs @@ -463,6 +463,7 @@ public string DisplayedText { combinedString += address.Text + separator; } + return combinedString; } return LinkedCell.Text; } diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index 9ca2534a2a..8970b02219 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -183,7 +183,7 @@ private void PlaceHorizontalAxisTitle(ChartAxisRenderer horizontalAxis) { if (horizontalAxis.Axis.AxisPosition == eAxisPosition.Bottom) { - if (SecondHorizontalAxis.Axis.ActualAxisPosition == eActualAxisPosition.BottomSecond) + if (SecondHorizontalAxis != null && SecondHorizontalAxis.Axis.ActualAxisPosition == eActualAxisPosition.BottomSecond) { horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Bottom + SecondHorizontalAxis.Rectangle.Height; } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs index 61e1d725ae..eb29a9f132 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs @@ -52,7 +52,7 @@ public DrawingParagraphRenderItem(DrawingTextBody textBody, BoundingBox parent, { IsFirstParagraph = p == p._paragraphs[0]; ImportStyleInfo(textBody, p); - + HorizontalAlignment = (TextAlignment)(int)p.HorizontalAlignment; ImportMarginAndIndent(p); //ImportAlignment(textBody.AutoSize, textBody.MaxWidth, parent.Width); diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs index ecc7597480..872e313d45 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs @@ -80,33 +80,52 @@ public void ImportParagraph(ExcelDrawingParagraph item, double startingY, string Paragraphs.Add(paragraph); } - //internal void SetHorizontalAlignmentPosition() - //{ - // //if (AutoSize) - // //{ - // foreach (var p in Paragraphs) - // { - // switch (p.HorizontalAlignment) - // { - // case TextAlignment.Left: - // p.Bounds.Left = 0; - // break; - // case TextAlignment.Center: - // p.Bounds.Left = (Bounds.Width / 2) - (p.Bounds.Width / 2); - // break; - // case TextAlignment.Right: - // p.Bounds.Left = Bounds.Right - p.Bounds.Width; - // break; - // case TextAlignment.Distributed: - // case TextAlignment.Justified: - // case TextAlignment.JustifiedLow: - // case TextAlignment.ThaiDistributed: - // p.Bounds.Left = 0; //TODO: Set left for now as we do not support distributed spacing yet - // break; - // } - // } - // //} - //} + internal void SetHorizontalAlignmentPosition() + { + if (AutoSize) + { + foreach (var p in Paragraphs) + { + switch (p.HorizontalAlignment) + { + case TextAlignment.Left: + p.Bounds.Left = 0; + break; + case TextAlignment.Center: + p.Bounds.Left = (Bounds.Width / 2) - (p.Bounds.Width / 2); + break; + case TextAlignment.Right: + p.Bounds.Left = Bounds.Right - p.Bounds.Width; + break; + case TextAlignment.Distributed: + case TextAlignment.Justified: + case TextAlignment.JustifiedLow: + case TextAlignment.ThaiDistributed: + p.Bounds.Left = 0; //TODO: Set left for now as we do not support distributed spacing yet + break; + } + } + } + } + + internal TextAlignment TranslateHorizontalPosition(ExcelHorizontalAlignment alignment) + { + switch (alignment) + { + case ExcelHorizontalAlignment.Left: + return TextAlignment.Left; + case ExcelHorizontalAlignment.Center: + return TextAlignment.Center; + case ExcelHorizontalAlignment.Right: + return TextAlignment.Right; + case ExcelHorizontalAlignment.Distributed: + case ExcelHorizontalAlignment.CenterContinuous: + case ExcelHorizontalAlignment.Justify: + case ExcelHorizontalAlignment.General: + default: + return TextAlignment.Left; //TODO: Set left for now as we do not support distributed spacing yet + } + } internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHorizontalAlignment horizontalDefault = ExcelHorizontalAlignment.Left) { @@ -117,6 +136,8 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz double currentHeight = 0; double largestWidth = double.MinValue; + //var defaultAlignment = TranslateHorizontalPosition(horizontalDefault); + body.GetInsetsInPoints(out double left, out double top, out double right, out double bottom); if (AutoSize == false) @@ -134,14 +155,19 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz { ImportParagraph(paragraph, currentHeight); var addedPara = Paragraphs.Last(); + //addedPara.Bounds.Width = MaxWidth; + //addedPara.HorizontalAlignment = defaultAlignment; + currentHeight = addedPara.Bounds.Bottom; largestWidth = Math.Max(largestWidth, addedPara.Bounds.Width); } - //foreach (var paragraph in body.Paragraphs) - //{ - // SetHorizontalAlignmentPosition(); - //} + //Alignment adjustment for e.g. ChartTitles one paragraph may be longer than another + //Therefore as paragraphs have no awareness of eachother we must compare and adjust + foreach (var paragraph in body.Paragraphs) + { + SetHorizontalAlignmentPosition(); + } if (Paragraphs != null && Paragraphs.Count() > 0) { From f8e83b687c029f5d71d658ab767ac8c2be89caa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 10 Jun 2026 16:22:11 +0200 Subject: [PATCH 035/118] Partial trendline fix --- .../RenderItems/Textbox/RenderTextBody.cs | 39 ++++++++++++++----- .../Renderer/Chart/ChartTitleRenderer.cs | 16 ++++---- .../Trendlines/ChartTrendlineRenderer.cs | 4 ++ .../RenderItems/Textbox/DrawingTextBody.cs | 4 ++ 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs index 83b5ccab0e..a0bcca68b3 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -156,17 +156,37 @@ public void ApplyAutoSize() /// public void RecalculateParagraphs() { - double lastParagraphBottom = 0; - - foreach(var paragraph in Paragraphs) + if(Paragraphs != null && Paragraphs.Count != 0) { - paragraph.Bounds.Top = lastParagraphBottom; - lastParagraphBottom = paragraph.Bounds.Bottom; - } + double lastParagraphBottom = 0; + + double smallestLeft = double.MaxValue; + double largestWidth = double.MinValue; + + ContentBounds.Top = Paragraphs[0].Bounds.Top; - Bounds.Height = lastParagraphBottom; + foreach (var paragraph in Paragraphs) + { + paragraph.Bounds.Top = lastParagraphBottom; + lastParagraphBottom = paragraph.Bounds.Bottom; + + smallestLeft = Math.Min(smallestLeft, paragraph.Bounds.Left); + largestWidth = paragraph.Bounds.Width; + } + + ContentBounds.Left = smallestLeft; + ContentBounds.Width = largestWidth; + ContentBounds.Top = Paragraphs[0].Bounds.Top; + + Bounds.Height = lastParagraphBottom; + } } + /// + /// The total bounds of all paragraphs without margins + /// + protected BoundingBox ContentBounds = new BoundingBox(); + private void AdjustAndAddParagraph(ParagraphRenderItem paragraph) { paragraph.Bounds.Name = $"Container{Paragraphs.Count}"; @@ -189,6 +209,7 @@ private void AdjustAndAddParagraph(ParagraphRenderItem paragraph) } } Paragraphs.Add(paragraph); + RecalculateParagraphs(); } private double GetTopToAddNextParagraphAt() @@ -219,10 +240,10 @@ public double GetAlignmentVertical() //Center means center of a Shape's ENTIRE bounding box height. //Not center of the Inset GetRectangle case TextAnchoringType.Center: - alignmentY = (MaxHeight) / 2 - Bounds.Height; + alignmentY = (Bounds.Height) / 2 - ContentBounds.Height; break; case TextAnchoringType.Bottom: - alignmentY = MaxHeight - Bounds.Height; + alignmentY = Bounds.Height - ContentBounds.Height; break; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs index cbd4977a5c..d71eb3c3cd 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs @@ -112,20 +112,20 @@ private void SetAxisTitleRect(ChartRenderer sc, ChartAxisRenderer axis) switch (axis.Axis.AxisPosition) { case eAxisPosition.Left: - Rectangle.Top = sc.GetPlotAreaTop(); - Rectangle.Left = sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Left ? sc.Legend.Rectangle.Right + LeftMargin : margin; + TextBox.Top = sc.GetPlotAreaTop(); + TextBox.Left = sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Left ? sc.Legend.Rectangle.Right + LeftMargin : margin; break; case eAxisPosition.Right: - Rectangle.Top = sc.GetPlotAreaTop(); - Rectangle.Left = sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Right || sc.Chart.Legend.Position == eLegendPosition.TopRight ? sc.Legend.Rectangle.Left - Rectangle.Width - margin : sc.Bounds.Right - Rectangle.Width - margin; + TextBox.Top = sc.GetPlotAreaTop(); + TextBox.Left = sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Right || sc.Chart.Legend.Position == eLegendPosition.TopRight ? sc.Legend.Rectangle.Left - Rectangle.Width - margin : sc.Bounds.Right - Rectangle.Width - margin; break; case eAxisPosition.Bottom: - Rectangle.Top = sc.ChartArea.Rectangle.Height - margin - Rectangle.Height; - Rectangle.Left = GetHorizontalLeft(sc); + TextBox.Top = sc.ChartArea.Rectangle.Height - margin - Rectangle.Height; + TextBox.Left = GetHorizontalLeft(sc); break; case eAxisPosition.Top: - Rectangle.Top = sc.Title != null && sc.Title._title.Layout.HasLayout==false ? sc.Title.Rectangle.Bottom+margin : margin; - Rectangle.Left = GetHorizontalLeft(sc); + TextBox.Top = sc.Title != null && sc.Title._title.Layout.HasLayout==false ? sc.Title.Rectangle.Bottom+margin : margin; + TextBox.Left = GetHorizontalLeft(sc); break; //case eActualAxisPosition.BottomSecond: // Rectangle.Top = sc.HorizontalAxis.Rectangle.Bottom; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index 79ab7a93bb..5fa9cf92d5 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -240,6 +240,10 @@ private void AddLblText(DrawingTextBox lbl, string labelText) { lbl.TextBody.Paragraphs[0].AddText(labelText.Substring(pIx, labelText.Length - pIx)); } + + //Should probably be a callback + lbl.TextBody.RecalculateParagraphs(); + lbl.TextBody.Top = lbl.TextBody.GetAlignmentVertical(); } private void CalculateLinear() diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs index 872e313d45..f82d3b0435 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs @@ -78,6 +78,7 @@ public void ImportParagraph(ExcelDrawingParagraph item, double startingY, string } } Paragraphs.Add(paragraph); + RecalculateParagraphs(); } internal void SetHorizontalAlignmentPosition() @@ -162,6 +163,9 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz largestWidth = Math.Max(largestWidth, addedPara.Bounds.Width); } + //Ensure contentBounds are calculated and paragraphs don't overlap + RecalculateParagraphs(); + //Alignment adjustment for e.g. ChartTitles one paragraph may be longer than another //Therefore as paragraphs have no awareness of eachother we must compare and adjust foreach (var paragraph in body.Paragraphs) From 859353a185508518a09349652a7b1b9559303cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 10 Jun 2026 16:46:01 +0200 Subject: [PATCH 036/118] Partial fix for trendline autosize alignment issue --- .../RenderItems/Textbox/RenderTextBody.cs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs index a0bcca68b3..3e0f046370 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -158,12 +158,11 @@ public void RecalculateParagraphs() { if(Paragraphs != null && Paragraphs.Count != 0) { - double lastParagraphBottom = 0; + double lastParagraphBottom = Paragraphs[0].Bounds.Top; double smallestLeft = double.MaxValue; double largestWidth = double.MinValue; - - ContentBounds.Top = Paragraphs[0].Bounds.Top; + double totalHeight = 0; foreach (var paragraph in Paragraphs) { @@ -172,13 +171,19 @@ public void RecalculateParagraphs() smallestLeft = Math.Min(smallestLeft, paragraph.Bounds.Left); largestWidth = paragraph.Bounds.Width; + totalHeight += paragraph.Bounds.Height; } + ContentBounds.Top = Paragraphs[0].Bounds.Top; ContentBounds.Left = smallestLeft; ContentBounds.Width = largestWidth; - ContentBounds.Top = Paragraphs[0].Bounds.Top; + ContentBounds.Height = totalHeight; - Bounds.Height = lastParagraphBottom; + if (AutoSize) + { + Bounds.Height = totalHeight; + Bounds.Width = ContentBounds.Width; + } } } @@ -240,7 +245,10 @@ public double GetAlignmentVertical() //Center means center of a Shape's ENTIRE bounding box height. //Not center of the Inset GetRectangle case TextAnchoringType.Center: - alignmentY = (Bounds.Height) / 2 - ContentBounds.Height; + if(AutoSize == false) + { + alignmentY = (Bounds.Height) / 2 - ContentBounds.Height; + } break; case TextAnchoringType.Bottom: alignmentY = Bounds.Height - ContentBounds.Height; From 03a5f5dea3565a8a0b2a2fc0a762da95090720b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 10 Jun 2026 16:57:42 +0200 Subject: [PATCH 037/118] Functional Trendlines fix --- .../DrawingShapeRenderer/SvgStandAloneTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index e8001dbaee..bf24ef0fd7 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -228,7 +228,7 @@ public void SvgTextBodyVerticalAlignmentGenerated() textBody.ApplyAutoSize(); textBody.AutoSize = false; - textBody.MaxHeight = 500; + textBody.Height = 500; textBody.Bounds.Top = 0; textBody.VerticalAlignment = TextAnchoringType.Center; @@ -256,7 +256,7 @@ public void SvgTextBodyVerticalAlignmentBottomGenerated() textBody.ApplyAutoSize(); textBody.AutoSize = false; - textBody.MaxHeight = 500; + textBody.Height = 500; textBody.Bounds.Top = 0; textBody.VerticalAlignment = TextAnchoringType.Bottom; From 239f3cf40938fb9308fcea1e991b5dd60fdad7ff Mon Sep 17 00:00:00 2001 From: Mats Alm <897655+swmal@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:39:01 +0200 Subject: [PATCH 038/118] #2383 - Fixed issue where VLOOKUP and HLOOKUP sometimes returned wrong value on unsorted data. (#2384) * #2383 - Fixed issue where VLOOKUP and HLOOKUP sometimes returned wrong value on unsorted data * #2483 - Fix approximate VLOOKUP/HLOOKUP to match Excel --- .../Excel/Functions/RefAndLookup/HLookup.cs | 14 +- .../LookupUtils/LookupBinarySearch.cs | 150 +++++++- .../LookupUtils/XlookupScanner.cs | 41 -- .../Excel/Functions/RefAndLookup/VLookup.cs | 22 +- .../Functions/RefAndLookup/HlookupTests.cs | 241 ++++++++++++ .../RefAndLookup/RefAndLookupTests.cs | 212 ---------- .../Functions/RefAndLookup/VLookupTests.cs | 364 +++++++++++++++++- .../Issues/FormulaCalculationIssues.cs | 13 + 8 files changed, 773 insertions(+), 284 deletions(-) create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/HlookupTests.cs diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/HLookup.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/HLookup.cs index 82ee6f3018..824692eb25 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/HLookup.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/HLookup.cs @@ -74,7 +74,7 @@ public override CompileResult Execute(IList arguments, Parsing } else { - index = LookupBinarySearch.BinarySearch(searchedValue, lookupRange, true, new LookupComparer(LookupMatchMode.ExactMatchReturnNextSmaller), LookupRangeDirection.Horizontal); + index = LookupBinarySearch.SearchAscFullRange(searchedValue, lookupRange, new LookupComparer(LookupMatchMode.ExactMatchReturnNextSmaller), LookupRangeDirection.Horizontal); index = LookupBinarySearch.GetMatchIndex(index, lookupRange, LookupMatchMode.ExactMatchReturnNextSmaller, true); if (index < 0) { @@ -112,9 +112,9 @@ public override void GetNewParameterAddress(IList args, int index } } } - /// - /// If the function is allowed in a pivot table calculated field - /// - public override bool IsAllowedInCalculatedPivotTableField => false; - } -} + /// + /// If the function is allowed in a pivot table calculated field + /// + public override bool IsAllowedInCalculatedPivotTableField => false; + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/LookupBinarySearch.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/LookupBinarySearch.cs index fa2f05b697..7811d0cb0a 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/LookupBinarySearch.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/LookupBinarySearch.cs @@ -44,9 +44,9 @@ private static int SearchAsc(object s, IRangeInfo lookupRange, IComparer var result = comparer.Compare(s, searchRangeCell.Value); if (result < 0) { - if(mid==0 && high>0) //First item can be a header, so check the next item for equality as Excel does.. + if (mid == 0 && high > 0) //First item can be a header, so check the next item for equality as Excel does.. { - searchRangeCell = searchRange[mid+1]; + searchRangeCell = searchRange[mid + 1]; result = comparer.Compare(s, searchRangeCell.Value); if (result == 0) return 1; } @@ -148,6 +148,152 @@ internal static int BinarySearch(object lookupValue, IRangeInfo lookupRange, boo return asc ? SearchAsc(lookupValue, lookupRange, comparer, direction) : SearchDesc(lookupValue, lookupRange, comparer); } + /// + /// Binary search over the lookup range for approximate match lookups + /// (e.g. VLOOKUP/HLOOKUP with range_lookup = TRUE). + /// Leading empty cells are skipped, but inner and trailing empty cells are + /// kept in their original positions. This mirrors Excel: leading blank rows + /// (common when a whole-column reference such as B:C is used and the data + /// starts further down) must not drag the search away from the data, while + /// blank cells inside the data block affect how the binary search partitions + /// the range and must be preserved to match Excel's result on such data. + /// Note that the previous approach of removing all empty cells (via + /// ) shifted the midpoints and + /// could produce a different result than Excel. + /// Returns the 0-based offset (row offset for vertical, column offset for + /// horizontal) into the original range, or a bitwise complement of the + /// insertion point when no exact match is found (to be resolved by + /// ). + /// + internal static int SearchAscFullRange(object lookupValue, IRangeInfo lookupRange, IComparer comparer, LookupRangeDirection direction) + { + var count = direction == LookupRangeDirection.Vertical + ? lookupRange.Size.NumberOfRows + : lookupRange.Size.NumberOfCols; + if (count == 0) return -1; + + //Excel performs a plain binary search over the WHOLE range for approximate + //match. Empty cells are kept in their original positions (they affect how + //the search partitions the range), and an empty cell counts as "greater" + //than the lookup value so the search moves left. We therefore do NOT trim + //or compact the range in any way; doing so shifts the midpoint sequence and + //produces results that differ from Excel. + // + //GetRangeInfoByValue() is used only to obtain the value edges: + //- firstValueOffset: lets us apply the header rule and report #N/A when the + // lookup value is smaller than every value. + //- lastValueOffset: bounds the inner blank-skipping scan so a whole-column + // reference (A:A, count ~1,000,000) never scans the empty tail. This is a + // performance bound only; it does not change the result because everything + // past the last value is empty anyway. + if (!TryGetValueEdges(lookupRange, direction, out var firstValueOffset, out var lastValueOffset)) + { + return -1; + } + if (firstValueOffset < 0 || firstValueOffset >= count) return -1; + + int low = 0, high = count - 1, mid; + while (low <= high) + { + mid = low + high >> 1; + + //If the midpoint is an empty cell it gives no direction information. + //Look forward (towards higher indexes) to the next non-empty cell and + //compare against that, mirroring how Excel sees past blank cells. + //The scan is bounded by the last value position, so a whole-column + //reference never scans across the empty remainder of the column. + var probe = mid; + var scanLimit = high < lastValueOffset ? high : lastValueOffset; + var cellValue = GetCellValue(lookupRange, probe, direction); + while (probe < scanLimit && cellValue == null) + { + probe++; + cellValue = GetCellValue(lookupRange, probe, direction); + } + if (cellValue == null) + { + //Only empty cells from 'mid' up to the scan limit: nothing to match + //on the right, so narrow the search downwards. + high = mid - 1; + continue; + } + + var result = comparer.Compare(lookupValue, cellValue); + if (result < 0) + { + if (probe == firstValueOffset && high > probe) + { + //The first value can be a header that is not comparable to the + //lookup value (e.g. a text header above numeric data). Excel + //skips such a header, so check the next item for an exact match. + var nextValue = GetCellValue(lookupRange, probe + 1, direction); + if (nextValue != null && comparer.Compare(lookupValue, nextValue) == 0) + { + return probe + 1; + } + } + high = probe - 1; + } + else if (result > 0) + { + low = probe + 1; + } + else + { + return probe; + } + } + //The next-smaller insertion point is low - 1. If that falls before the first + //value, the lookup value is smaller than every value in the range, which + //Excel reports as #N/A. + var matchIndex = low - 1; + if (matchIndex < firstValueOffset) + { + return -1; + } + return matchIndex; + } + + private static object GetCellValue(IRangeInfo lookupRange, int offset, LookupRangeDirection direction) + { + return direction == LookupRangeDirection.Vertical + ? lookupRange.GetOffset(offset, 0) + : lookupRange.GetOffset(0, offset); + } + + /// + /// Gets the offsets (relative to the top-left of the range) of the first and + /// last non-empty cell along the search direction. Uses + /// , which locates the value edges + /// through the cell store rather than scanning each cell, so this is safe for + /// whole-column/row references. Returns false if the range contains no values. + /// + private static bool TryGetValueEdges(IRangeInfo lookupRange, LookupRangeDirection direction, out int startOffset, out int lastValueOffset) + { + startOffset = -1; + lastValueOffset = -1; + + var valueSubRange = lookupRange.GetRangeInfoByValue(); + if (valueSubRange == null) return false; + + var valueAddress = valueSubRange.Address; + var rangeAddress = lookupRange.Address; + if (valueAddress == null || rangeAddress == null) return false; + if (valueAddress.FromRow < 0 || valueAddress.FromCol < 0) return false; + + if (direction == LookupRangeDirection.Vertical) + { + startOffset = valueAddress.FromRow - rangeAddress.FromRow; + lastValueOffset = valueAddress.ToRow - rangeAddress.FromRow; + } + else + { + startOffset = valueAddress.FromCol - rangeAddress.FromCol; + lastValueOffset = valueAddress.ToCol - rangeAddress.FromCol; + } + return true; + } + internal static int GetMaxIndex(IRangeInfo returnArray) { return returnArray.Size.NumberOfRows > returnArray.Size.NumberOfCols ? diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/XlookupScanner.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/XlookupScanner.cs index b502f43d74..c244811db3 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/XlookupScanner.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/XlookupScanner.cs @@ -178,46 +178,5 @@ private int GetMaxItemsColumns(IRangeInfo lookupRange) } return _lookupRange.Size.NumberOfCols; } - - private int FindHorizontal() - { - var dimensionCols = _lookupRange.Worksheet.Dimension.Columns; - var maxCols = _lookupRange.Size.NumberOfCols > dimensionCols ? dimensionCols : _lookupRange.Size.NumberOfCols; - int closestBelowIx = -1; - int closestAboveIx = -1; - object closestBelow = null; - object closestAbove = null; - - for (var colIx = 0; colIx < maxCols; colIx++) - { - var value = _lookupRange.GetOffset(0, colIx); - var cr = _comparer.Compare(_lookupValue, value); - if (cr == 0) - { - return colIx; - } - else if (cr < 0) - { - if (closestBelow == null || _comparer.Compare(closestBelow, value) < 0) - { - closestBelow = value; - closestBelowIx = colIx; - } - if (closestAbove == null || _comparer.Compare(closestAbove, value) < 0) - { - closestAbove = value; - closestAboveIx = colIx; - } - } - } - if (_matchMode == LookupMatchMode.ExactMatchReturnNextLarger) - { - return closestAboveIx; - } - else - { - return closestBelowIx; - } - } } } diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/VLookup.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/VLookup.cs index 6f13a89f20..2c97651eb0 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/VLookup.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/VLookup.cs @@ -62,7 +62,7 @@ public override CompileResult Execute(IList arguments, Parsing } var rangeLookup = true; - if(arguments.Count > 3) + if (arguments.Count > 3) { rangeLookup = ArgToBool(arguments, 3); } @@ -88,21 +88,21 @@ public override CompileResult Execute(IList arguments, Parsing //} - index = LookupBinarySearch.BinarySearch(searchedValue, lookupRange, true, new LookupComparer(LookupMatchMode.ExactMatchReturnNextSmaller), LookupRangeDirection.Vertical); + index = LookupBinarySearch.SearchAscFullRange(searchedValue, lookupRange, new LookupComparer(LookupMatchMode.ExactMatchReturnNextSmaller), LookupRangeDirection.Vertical); index = LookupBinarySearch.GetMatchIndex(index, lookupRange, LookupMatchMode.ExactMatchReturnNextSmaller, true); if (index < 0) { return CompileResult.GetErrorResult(eErrorType.NA); } } - return CompileResultFactory.Create(lookupRange.GetOffset(index, lookupIndex - 1)); + return CompileResultFactory.Create(lookupRange.GetOffset(index, lookupIndex - 1)); } public override void GetNewParameterAddress(IList args, int index, ParsingContext ctx, ref Queue addresses) { if (args.Count > 2) { var lookupRange = args[1].Result as RangeInfo; - if (lookupRange!=null) + if (lookupRange != null) { try { @@ -114,7 +114,7 @@ public override void GetNewParameterAddress(IList args, int index addresses.Enqueue(lookupRange.Address.GetOffset(0, 0, lookupRange.Size.NumberOfRows, ix + 1).Address); } else - { + { addresses.Enqueue(lookupRange.Address.GetOffset(0, 0, lookupRange.Size.NumberOfRows, 1)); addresses.Enqueue(lookupRange.Address.GetOffset(0, ix, lookupRange.Size.NumberOfRows, 1)); } @@ -126,9 +126,9 @@ public override void GetNewParameterAddress(IList args, int index } } } - /// - /// If the function is allowed in a pivot table calculated field - /// - public override bool IsAllowedInCalculatedPivotTableField => false; - } -} + /// + /// If the function is allowed in a pivot table calculated field + /// + public override bool IsAllowedInCalculatedPivotTableField => false; + } +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/HlookupTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/HlookupTests.cs new file mode 100644 index 0000000000..3899323258 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/HlookupTests.cs @@ -0,0 +1,241 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup +{ + [TestClass] + public class HlookupTests + { + [TestMethod] + public void HLookupShouldReturnResultFromMatchingRow() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1"].Formula = "HLOOKUP(2,A1:B2,2)"; + sheet.Cells[1, 1].Value = 1; + sheet.Cells[1, 2].Value = 1; + sheet.Cells[2, 1].Value = 2; + sheet.Cells[2, 2].Value = 5; + sheet.Calculate(); + + Assert.AreEqual(5, sheet.Cells["F1"].Value); + } + } + + [TestMethod] + public void HLookupShouldReturnResultFromMatchingRow_Array() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1:G1"].CreateArrayFormula("HLOOKUP(A1:B1,A1:B2,2)"); + sheet.Cells[1, 1].Value = 1; + sheet.Cells[1, 2].Value = 3; + sheet.Cells[2, 1].Value = 2; + sheet.Cells[2, 2].Value = 5; + sheet.Calculate(); + + Assert.AreEqual(2, sheet.Cells["F1"].Value); + Assert.AreEqual(5, sheet.Cells["G1"].Value); + } + } + + [TestMethod] + public void HLookupShouldReturnResultFromMatchingRow_Wildcard() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1"].Formula = "HLOOKUP(\"*B*\",A1:B2,2,0)"; + sheet.Cells[1, 1].Value = "ABC"; + sheet.Cells[1, 2].Value = "DEF"; + sheet.Cells[2, 1].Value = 2; + sheet.Cells[2, 2].Value = 5; + sheet.Calculate(); + + Assert.AreEqual(2, sheet.Cells["F1"].Value); + } + } + + [TestMethod] + public void HLookupShouldReturnNaErrorIfNoMatchingRecordIsFoundWhenRangeLookupIsFalse() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1"].Formula = "HLOOKUP(2,A1:B2,2,false)"; + sheet.Cells[1, 1].Value = 3; + sheet.Cells[1, 2].Value = 1; + sheet.Cells[2, 1].Value = 2; + sheet.Cells[2, 2].Value = 5; + sheet.Calculate(); + var expectedResult = ExcelErrorValue.Create(eErrorType.NA); + Assert.AreEqual(expectedResult, sheet.Cells["F1"].Value); + } + } + + [TestMethod] + public void HLookupShouldReturnErrorIfNoMatchingRecordIsFoundWhenRangeLookupIsTrue() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1"].Formula = "HLOOKUP(1,A1:B2,2,true)"; + sheet.Cells[1, 1].Value = 2; + sheet.Cells[1, 2].Value = 3; + sheet.Cells[2, 1].Value = 3; + sheet.Cells[2, 2].Value = 5; + sheet.Calculate(); + var naError = ExcelErrorValue.Create(eErrorType.NA); + Assert.AreEqual(naError, sheet.Cells["F1"].Value); + } + } + + [TestMethod] + public void HLookupApproximateMatchShouldHandleBlankColumnsAroundData() + { + // Regression test: approximate match (range_lookup = TRUE) for HLOOKUP where the + // key row is preceded by blank columns. Mirrors the VLOOKUP case but horizontally. + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + // Blank columns A-C, keys in D1:H1, results in D2:H2. + ws.Cells["D1"].Value = 10; + ws.Cells["E1"].Value = 20; + ws.Cells["F1"].Value = 30; + ws.Cells["G1"].Value = 40; + ws.Cells["H1"].Value = 50; + ws.Cells["D2"].Value = 100; + ws.Cells["E2"].Value = 200; + ws.Cells["F2"].Value = 300; + ws.Cells["G2"].Value = 400; + ws.Cells["H2"].Value = 500; + + // Approximate match between keys returns the value of the largest key <= lookup. + ws.Cells["A4"].Formula = "HLOOKUP(35,A1:H2,2,TRUE)"; + // Exact matches at the edges of the data block. + ws.Cells["A5"].Formula = "HLOOKUP(10,A1:H2,2,TRUE)"; + ws.Cells["A6"].Formula = "HLOOKUP(50,A1:H2,2,TRUE)"; + // Lookup value smaller than every key returns #N/A. + ws.Cells["A7"].Formula = "HLOOKUP(5,A1:H2,2,TRUE)"; + ws.Calculate(); + + Assert.AreEqual(300, ws.Cells["A4"].Value); + Assert.AreEqual(100, ws.Cells["A5"].Value); + Assert.AreEqual(500, ws.Cells["A6"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), ws.Cells["A7"].Value); + } + + [TestMethod] + public void HLookupApproximateMatch_LeadingBlankColumns() + { + // Sorted ascending data preceded by blank columns. The leading blanks must + // be skipped so the search finds the data, matching Excel. + // C1=10, D1=20, E1=30, F1=40, G1=50 (A1, B1 blank), results in row 2. + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + // A1, B1 intentionally left blank + ws.Cells["C1"].Value = 10; + ws.Cells["D1"].Value = 20; + ws.Cells["E1"].Value = 30; + ws.Cells["F1"].Value = 40; + ws.Cells["G1"].Value = 50; + + ws.Cells["C2"].Value = 100; + ws.Cells["D2"].Value = 200; + ws.Cells["E2"].Value = 300; + ws.Cells["F2"].Value = 400; + ws.Cells["G2"].Value = 500; + + ws.Cells["A4"].Formula = "HLOOKUP(5,A1:G2,2,TRUE)"; // below the first value + ws.Cells["A5"].Formula = "HLOOKUP(10,A1:G2,2,TRUE)"; // exact, first value + ws.Cells["A6"].Formula = "HLOOKUP(15,A1:G2,2,TRUE)"; // approximate -> 10 + ws.Cells["A7"].Formula = "HLOOKUP(50,A1:G2,2,TRUE)"; // exact, last value + ws.Cells["A8"].Formula = "HLOOKUP(55,A1:G2,2,TRUE)"; // above the last value -> 50 + ws.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), ws.Cells["A4"].Value); + Assert.AreEqual(100, ws.Cells["A5"].Value); + Assert.AreEqual(100, ws.Cells["A6"].Value); + Assert.AreEqual(500, ws.Cells["A7"].Value); + Assert.AreEqual(500, ws.Cells["A8"].Value); + } + + [TestMethod] + public void HLookupApproximateMatch_InnerBlankBeforeLastValue() + { + // Sorted ascending data with an inner blank immediately before the last value: + // A1=10, B1=20, C1=, D1=30, E1=40, F1=, G1=50 + // HLOOKUP(50, A1:G2, 2, TRUE) must find the exact match 50 and return 500, + // seeing past the inner blank at F1 instead of stopping there. + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Value = 10; + ws.Cells["B1"].Value = 20; + // C1 intentionally left blank + ws.Cells["D1"].Value = 30; + ws.Cells["E1"].Value = 40; + // F1 intentionally left blank + ws.Cells["G1"].Value = 50; + + ws.Cells["A2"].Value = 100; + ws.Cells["B2"].Value = 200; + ws.Cells["D2"].Value = 300; + ws.Cells["E2"].Value = 400; + ws.Cells["G2"].Value = 500; + + ws.Cells["A4"].Formula = "HLOOKUP(25,A1:G2,2,TRUE)"; + ws.Cells["A5"].Formula = "HLOOKUP(45,A1:G2,2,TRUE)"; + ws.Cells["A6"].Formula = "HLOOKUP(50,A1:G2,2,TRUE)"; + ws.Calculate(); + + Assert.AreEqual(200, ws.Cells["A4"].Value); + Assert.AreEqual(400, ws.Cells["A5"].Value); + Assert.AreEqual(500, ws.Cells["A6"].Value); + } + + [TestMethod] + public void HLookupApproximateMatch_TrailingBlankColumns() + { + // Sorted ascending data followed by blank columns. The trailing blanks must + // not affect the result, matching Excel. + // A1=10, B1=20, C1=30, D1=40, E1=50 (F1, G1 blank), results in row 2. + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Value = 10; + ws.Cells["B1"].Value = 20; + ws.Cells["C1"].Value = 30; + ws.Cells["D1"].Value = 40; + ws.Cells["E1"].Value = 50; + // F1, G1 intentionally left blank + + ws.Cells["A2"].Value = 100; + ws.Cells["B2"].Value = 200; + ws.Cells["C2"].Value = 300; + ws.Cells["D2"].Value = 400; + ws.Cells["E2"].Value = 500; + + ws.Cells["A4"].Formula = "HLOOKUP(5,A1:G2,2,TRUE)"; // below the first value + ws.Cells["A5"].Formula = "HLOOKUP(10,A1:G2,2,TRUE)"; // exact, first value + ws.Cells["A6"].Formula = "HLOOKUP(35,A1:G2,2,TRUE)"; // approximate -> 30 + ws.Cells["A7"].Formula = "HLOOKUP(50,A1:G2,2,TRUE)"; // exact, last value + ws.Cells["A8"].Formula = "HLOOKUP(55,A1:G2,2,TRUE)"; // above the last value -> 50 + ws.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), ws.Cells["A4"].Value); + Assert.AreEqual(100, ws.Cells["A5"].Value); + Assert.AreEqual(300, ws.Cells["A6"].Value); + Assert.AreEqual(500, ws.Cells["A7"].Value); + Assert.AreEqual(500, ws.Cells["A8"].Value); + } + } +} diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/RefAndLookupTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/RefAndLookupTests.cs index fcfb67c51c..9aaff4321b 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/RefAndLookupTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/RefAndLookupTests.cs @@ -88,218 +88,6 @@ public void LookupArgumentsShouldSetRangeLookupToTrueWhenTrueIsSupplied() Assert.IsTrue(lookupArgs.RangeLookup); } - [TestMethod] - public void VLookupShouldReturnResultFromMatchingRow() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1"].Formula = "VLOOKUP(2,A1:B2,2)"; - sheet.Cells[1, 1].Value = 1; - sheet.Cells[1, 2].Value = 1; - sheet.Cells[2, 1].Value = 2; - sheet.Cells[2, 2].Value = 5; - sheet.Calculate(); - - Assert.AreEqual(5, sheet.Cells["F1"].Value); - } - } - - [TestMethod] - public void VLookupShouldReturnResultFromMatchingRow_Array() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1:F2"].CreateArrayFormula("VLOOKUP(A1:A2,A1:B2,2)"); - sheet.Cells[1, 1].Value = 1; - sheet.Cells[1, 2].Value = 1; - sheet.Cells[2, 1].Value = 2; - sheet.Cells[2, 2].Value = 5; - sheet.Calculate(); - - Assert.AreEqual(1, sheet.Cells["F1"].Value); - Assert.AreEqual(5, sheet.Cells["F2"].Value); - } - } - - [TestMethod] - public void VLookupShouldReturnResultFromMatchingRow_Wildcard() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1"].Formula = "VLOOKUP(\"*B*\",A1:B2,2,0)"; - sheet.Cells[1, 1].Value = "ABC"; - sheet.Cells[1, 2].Value = 2; - sheet.Cells[2, 1].Value = "DEF"; - sheet.Cells[2, 2].Value = 5; - sheet.Calculate(); - - Assert.AreEqual(2, sheet.Cells["F1"].Value); - } - } - - [TestMethod] - public void VLookupShouldReturnClosestValueBelowWhenRangeLookupIsTrue() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1"].Formula = "VLOOKUP(4,A1:B2,2,true)"; - sheet.Cells[1, 1].Value = 3; - sheet.Cells[1, 2].Value = 1; - sheet.Cells[2, 1].Value = 5; - sheet.Cells[2, 2].Value = 4; - sheet.Calculate(); - - Assert.AreEqual(1, sheet.Cells["F1"].Value); - } - } - - [TestMethod] - public void VLookupShouldReturnClosestStringValueBelowWhenRangeLookupIsTrue() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1"].Formula = "VLOOKUP(\"B\",A1:B2,2,true)"; - sheet.Cells[1, 1].Value = "A"; - sheet.Cells[1, 2].Value = 1; - sheet.Cells[2, 1].Value = "C"; - sheet.Cells[2, 2].Value = 4; - sheet.Calculate(); - - Assert.AreEqual(1, sheet.Cells["F1"].Value); - } - } - - [TestMethod] - public void VLookupShouldIgnoreCase() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1"].Formula = "VLOOKUP(\"b\",A1:B2,2,true)"; - sheet.Cells[1, 1].Value = "A"; - sheet.Cells[1, 2].Value = 1; - sheet.Cells[2, 1].Value = "C"; - sheet.Cells[2, 2].Value = 4; - sheet.Calculate(); - - Assert.AreEqual(1, sheet.Cells["F1"].Value); - } - } - [TestMethod] - public void VLookupHeaderIncluded() - { - using (var package = new ExcelPackage()) - { - var ws = package.Workbook.Worksheets.Add("sheet1"); - - ws.Cells["A1"].Value = "Header"; - ws.Cells["A2"].Value = 1; - ws.Cells["A3"].Value = 2; - ws.Cells["A4"].Value = 3; - ws.Cells["A5"].Value = 4; - ws.Cells["B1"].Value = "Result"; - ws.Cells["B2"].Value = "Found1"; - ws.Cells["B3"].Value = "Found2"; - ws.Cells["B4"].Value = "Found3"; - ws.Cells["B5"].Value = "Found4"; - - var result = ws.Calculate("VLOOKUP(1,A1:B5,2,TRUE)"); - Assert.AreEqual("Found1", result); - } - } - - - [TestMethod] - public void HLookupShouldReturnResultFromMatchingRow() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1"].Formula = "HLOOKUP(2,A1:B2,2)"; - sheet.Cells[1, 1].Value = 1; - sheet.Cells[1, 2].Value = 1; - sheet.Cells[2, 1].Value = 2; - sheet.Cells[2, 2].Value = 5; - sheet.Calculate(); - - Assert.AreEqual(5, sheet.Cells["F1"].Value); - } - } - - [TestMethod] - public void HLookupShouldReturnResultFromMatchingRow_Array() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1:G1"].CreateArrayFormula("HLOOKUP(A1:B1,A1:B2,2)"); - sheet.Cells[1, 1].Value = 1; - sheet.Cells[1, 2].Value = 3; - sheet.Cells[2, 1].Value = 2; - sheet.Cells[2, 2].Value = 5; - sheet.Calculate(); - - Assert.AreEqual(2, sheet.Cells["F1"].Value); - Assert.AreEqual(5, sheet.Cells["G1"].Value); - } - } - - [TestMethod] - public void HLookupShouldReturnResultFromMatchingRow_Wildcard() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1"].Formula = "HLOOKUP(\"*B*\",A1:B2,2,0)"; - sheet.Cells[1, 1].Value = "ABC"; - sheet.Cells[1, 2].Value = "DEF"; - sheet.Cells[2, 1].Value = 2; - sheet.Cells[2, 2].Value = 5; - sheet.Calculate(); - - Assert.AreEqual(2, sheet.Cells["F1"].Value); - } - } - - [TestMethod] - public void HLookupShouldReturnNaErrorIfNoMatchingRecordIsFoundWhenRangeLookupIsFalse() - { - using(var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1"].Formula = "HLOOKUP(2,A1:B2,2,false)"; - sheet.Cells[1, 1].Value = 3; - sheet.Cells[1, 2].Value = 1; - sheet.Cells[2, 1].Value = 2; - sheet.Cells[2, 2].Value = 5; - sheet.Calculate(); - var expectedResult = ExcelErrorValue.Create(eErrorType.NA); - Assert.AreEqual(expectedResult, sheet.Cells["F1"].Value); - } - } - - [TestMethod] - public void HLookupShouldReturnErrorIfNoMatchingRecordIsFoundWhenRangeLookupIsTrue() - { - using (var package = new ExcelPackage()) - { - var sheet = package.Workbook.Worksheets.Add("sheet1"); - sheet.Cells["F1"].Formula = "HLOOKUP(1,A1:B2,2,true)"; - sheet.Cells[1, 1].Value = 2; - sheet.Cells[1, 2].Value = 3; - sheet.Cells[2, 1].Value = 3; - sheet.Cells[2, 2].Value = 5; - sheet.Calculate(); - var naError = ExcelErrorValue.Create(eErrorType.NA); - Assert.AreEqual(naError, sheet.Cells["F1"].Value); - } - } - [TestMethod] public void LookupShouldReturnResultFromMatchingRowArrayVertical() { diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/VLookupTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/VLookupTests.cs index b376ebbe93..e3a9da1517 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/VLookupTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/VLookupTests.cs @@ -7,6 +7,131 @@ namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup [TestClass] public class VLookupTests : TestBase { + [TestMethod] + public void VLookupShouldReturnResultFromMatchingRow() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1"].Formula = "VLOOKUP(2,A1:B2,2)"; + sheet.Cells[1, 1].Value = 1; + sheet.Cells[1, 2].Value = 1; + sheet.Cells[2, 1].Value = 2; + sheet.Cells[2, 2].Value = 5; + sheet.Calculate(); + + Assert.AreEqual(5, sheet.Cells["F1"].Value); + } + } + + [TestMethod] + public void VLookupShouldReturnResultFromMatchingRow_Array() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1:F2"].CreateArrayFormula("VLOOKUP(A1:A2,A1:B2,2)"); + sheet.Cells[1, 1].Value = 1; + sheet.Cells[1, 2].Value = 1; + sheet.Cells[2, 1].Value = 2; + sheet.Cells[2, 2].Value = 5; + sheet.Calculate(); + + Assert.AreEqual(1, sheet.Cells["F1"].Value); + Assert.AreEqual(5, sheet.Cells["F2"].Value); + } + } + + [TestMethod] + public void VLookupShouldReturnResultFromMatchingRow_Wildcard() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1"].Formula = "VLOOKUP(\"*B*\",A1:B2,2,0)"; + sheet.Cells[1, 1].Value = "ABC"; + sheet.Cells[1, 2].Value = 2; + sheet.Cells[2, 1].Value = "DEF"; + sheet.Cells[2, 2].Value = 5; + sheet.Calculate(); + + Assert.AreEqual(2, sheet.Cells["F1"].Value); + } + } + + [TestMethod] + public void VLookupShouldReturnClosestValueBelowWhenRangeLookupIsTrue() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1"].Formula = "VLOOKUP(4,A1:B2,2,true)"; + sheet.Cells[1, 1].Value = 3; + sheet.Cells[1, 2].Value = 1; + sheet.Cells[2, 1].Value = 5; + sheet.Cells[2, 2].Value = 4; + sheet.Calculate(); + + Assert.AreEqual(1, sheet.Cells["F1"].Value); + } + } + + [TestMethod] + public void VLookupShouldReturnClosestStringValueBelowWhenRangeLookupIsTrue() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1"].Formula = "VLOOKUP(\"B\",A1:B2,2,true)"; + sheet.Cells[1, 1].Value = "A"; + sheet.Cells[1, 2].Value = 1; + sheet.Cells[2, 1].Value = "C"; + sheet.Cells[2, 2].Value = 4; + sheet.Calculate(); + + Assert.AreEqual(1, sheet.Cells["F1"].Value); + } + } + + [TestMethod] + public void VLookupShouldIgnoreCase() + { + using (var package = new ExcelPackage()) + { + var sheet = package.Workbook.Worksheets.Add("sheet1"); + sheet.Cells["F1"].Formula = "VLOOKUP(\"b\",A1:B2,2,true)"; + sheet.Cells[1, 1].Value = "A"; + sheet.Cells[1, 2].Value = 1; + sheet.Cells[2, 1].Value = "C"; + sheet.Cells[2, 2].Value = 4; + sheet.Calculate(); + + Assert.AreEqual(1, sheet.Cells["F1"].Value); + } + } + [TestMethod] + public void VLookupHeaderIncluded() + { + using (var package = new ExcelPackage()) + { + var ws = package.Workbook.Worksheets.Add("sheet1"); + + ws.Cells["A1"].Value = "Header"; + ws.Cells["A2"].Value = 1; + ws.Cells["A3"].Value = 2; + ws.Cells["A4"].Value = 3; + ws.Cells["A5"].Value = 4; + ws.Cells["B1"].Value = "Result"; + ws.Cells["B2"].Value = "Found1"; + ws.Cells["B3"].Value = "Found2"; + ws.Cells["B4"].Value = "Found3"; + ws.Cells["B5"].Value = "Found4"; + + var result = ws.Calculate("VLOOKUP(1,A1:B5,2,TRUE)"); + Assert.AreEqual("Found1", result); + } + } + [TestMethod] public void VlookupShouldHandleWholeColumn() { @@ -149,7 +274,7 @@ public void ApproximateOutOfRangePositiveShouldRefError() } } - [DataTestMethod] + [TestMethod] [DataRow(0)] [DataRow(-1)] public void ApproximateOutOfRangeNonPositiveShouldValueError(int offset) @@ -444,15 +569,232 @@ public void FullCols() Assert.AreEqual(13d, ws.Cells["A5"].Value); } } - //[TestMethod] - //public void LookupTest() - //{ - // using (var p = OpenTemplatePackage("LookupTest.xlsx")) - // { - // var ws = p.Workbook.Worksheets[0]; - // ws.Calculate(); - // Assert.AreEqual(19, ws.Cells["C1"].Value); - // } - //} + + [TestMethod] + public void VLookupApproximateMatchShouldHandleBlankRowsAroundData() + { + // Regression test: approximate match (range_lookup = TRUE) over a whole-column + // reference where the data is preceded and followed by blank rows. + // The lookup column is not trimmed/compacted, so leading blanks must be skipped + // while the binary search still partitions the range the same way Excel does. + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + // Blank rows 1-3, data in rows 4-8, blank rows below. + ws.Cells["B4"].Value = 10; + ws.Cells["B5"].Value = 20; + ws.Cells["B6"].Value = 30; + ws.Cells["B7"].Value = 40; + ws.Cells["B8"].Value = 50; + ws.Cells["C4"].Value = 100; + ws.Cells["C5"].Value = 200; + ws.Cells["C6"].Value = 300; + ws.Cells["C7"].Value = 400; + ws.Cells["C8"].Value = 500; + + // Approximate match between keys returns the value of the largest key <= lookup. + ws.Cells["E1"].Formula = "VLOOKUP(35,B:C,2,TRUE)"; + // Exact matches at the edges of the data block. + ws.Cells["E2"].Formula = "VLOOKUP(10,B:C,2,TRUE)"; + ws.Cells["E3"].Formula = "VLOOKUP(50,B:C,2,TRUE)"; + // Lookup value smaller than every key returns #N/A. + ws.Cells["E4"].Formula = "VLOOKUP(5,B:C,2,TRUE)"; + ws.Calculate(); + + Assert.AreEqual(300, ws.Cells["E1"].Value); + Assert.AreEqual(100, ws.Cells["E2"].Value); + Assert.AreEqual(500, ws.Cells["E3"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), ws.Cells["E4"].Value); + } + + [TestMethod] + public void VLookupApproximateMatch_InnerBlankBeforeLastValue() + { + // Sorted ascending data with an inner blank immediately before the last value: + // A1=10, A2=20, A3=, A4=30, A5=40, A6=, A7=50 + // VLOOKUP(50, A1:B7, 2, TRUE) must find the exact match 50 and return 500, + // as Excel does. The approximate binary search sees past the inner blank at + // A6 to the value at A7 instead of treating the blank as a stopping point. + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Value = 10; + ws.Cells["A2"].Value = 20; + // A3 intentionally left blank + ws.Cells["A4"].Value = 30; + ws.Cells["A5"].Value = 40; + // A6 intentionally left blank + ws.Cells["A7"].Value = 50; + + ws.Cells["B1"].Value = 100; + ws.Cells["B2"].Value = 200; + ws.Cells["B4"].Value = 300; + ws.Cells["B5"].Value = 400; + ws.Cells["B7"].Value = 500; + + ws.Cells["D1"].Formula = "VLOOKUP(25,A1:B7,2,TRUE)"; + ws.Cells["D2"].Formula = "VLOOKUP(45,A1:B7,2,TRUE)"; + ws.Cells["D3"].Formula = "VLOOKUP(50,A1:B7,2,TRUE)"; + ws.Calculate(); + + // These already agree with Excel. + Assert.AreEqual(200, ws.Cells["D1"].Value); + Assert.AreEqual(400, ws.Cells["D2"].Value); + Assert.AreEqual(500, ws.Cells["D3"].Value); + } + + [TestMethod] + public void VLookupApproximateMatch_LeadingBlankRows() + { + // Sorted ascending data preceded by blank rows (e.g. a whole-column + // reference where the data starts further down). The leading blanks must + // be skipped so the search finds the data, matching Excel. + // A1=, A2=, A3=10, A4=20, A5=30, A6=40, A7=50 + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + // A1, A2 intentionally left blank + ws.Cells["A3"].Value = 10; + ws.Cells["A4"].Value = 20; + ws.Cells["A5"].Value = 30; + ws.Cells["A6"].Value = 40; + ws.Cells["A7"].Value = 50; + + ws.Cells["B3"].Value = 100; + ws.Cells["B4"].Value = 200; + ws.Cells["B5"].Value = 300; + ws.Cells["B6"].Value = 400; + ws.Cells["B7"].Value = 500; + + ws.Cells["D1"].Formula = "VLOOKUP(5,A1:B7,2,TRUE)"; // below the first value + ws.Cells["D2"].Formula = "VLOOKUP(10,A1:B7,2,TRUE)"; // exact, first value + ws.Cells["D3"].Formula = "VLOOKUP(15,A1:B7,2,TRUE)"; // approximate -> 10 + ws.Cells["D4"].Formula = "VLOOKUP(50,A1:B7,2,TRUE)"; // exact, last value + ws.Cells["D5"].Formula = "VLOOKUP(55,A1:B7,2,TRUE)"; // above the last value -> 50 + ws.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), ws.Cells["D1"].Value); + Assert.AreEqual(100, ws.Cells["D2"].Value); + Assert.AreEqual(100, ws.Cells["D3"].Value); + Assert.AreEqual(500, ws.Cells["D4"].Value); + Assert.AreEqual(500, ws.Cells["D5"].Value); + } + + [TestMethod] + public void VLookupApproximateMatch_TrailingBlankRows() + { + // Sorted ascending data followed by blank rows. The trailing blanks must + // not affect the result, matching Excel. + // A1=10, A2=20, A3=30, A4=40, A5=50, A6=, A7= + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Value = 10; + ws.Cells["A2"].Value = 20; + ws.Cells["A3"].Value = 30; + ws.Cells["A4"].Value = 40; + ws.Cells["A5"].Value = 50; + // A6, A7 intentionally left blank + + ws.Cells["B1"].Value = 100; + ws.Cells["B2"].Value = 200; + ws.Cells["B3"].Value = 300; + ws.Cells["B4"].Value = 400; + ws.Cells["B5"].Value = 500; + + ws.Cells["D1"].Formula = "VLOOKUP(5,A1:B7,2,TRUE)"; // below the first value + ws.Cells["D2"].Formula = "VLOOKUP(10,A1:B7,2,TRUE)"; // exact, first value + ws.Cells["D3"].Formula = "VLOOKUP(35,A1:B7,2,TRUE)"; // approximate -> 30 + ws.Cells["D4"].Formula = "VLOOKUP(50,A1:B7,2,TRUE)"; // exact, last value + ws.Cells["D5"].Formula = "VLOOKUP(55,A1:B7,2,TRUE)"; // above the last value -> 50 + ws.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), ws.Cells["D1"].Value); + Assert.AreEqual(100, ws.Cells["D2"].Value); + Assert.AreEqual(300, ws.Cells["D3"].Value); + Assert.AreEqual(500, ws.Cells["D4"].Value); + Assert.AreEqual(500, ws.Cells["D5"].Value); + } + + [TestMethod] + public void VLookupApproximateMatch_UnsortedWithLeadingBlanks_MatchesExcel() + { + // Approximate match runs as a plain binary search over the whole range with + // blanks kept in their original positions, exactly like Excel. On unsorted + // data the result is whatever that binary search lands on - it is not + // "correct" in a sorted sense, but it matches Excel, which is the contract. + // + // Data (2 leading blanks, then unsorted keys): + // A1=, A2=, A3=30, A4=10, A5=50, A6=20, A7=40 + // + // Verified against Excel: + // VLOOKUP(35) -> key 20 (B6) - binary search lands here, not on 30 + // VLOOKUP(30) -> key 20 (B6) - the exact 30 is never visited by the search + // VLOOKUP(5) -> #N/A + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + // A1, A2 intentionally left blank + ws.Cells["A3"].Value = 30; + ws.Cells["A4"].Value = 10; + ws.Cells["A5"].Value = 50; + ws.Cells["A6"].Value = 20; + ws.Cells["A7"].Value = 40; + + ws.Cells["B3"].Value = 300; + ws.Cells["B4"].Value = 100; + ws.Cells["B5"].Value = 500; + ws.Cells["B6"].Value = 200; + ws.Cells["B7"].Value = 400; + + ws.Cells["D1"].Formula = "VLOOKUP(35,A1:B7,2,TRUE)"; + ws.Cells["D2"].Formula = "VLOOKUP(30,A1:B7,2,TRUE)"; + ws.Cells["D3"].Formula = "VLOOKUP(5,A1:B7,2,TRUE)"; + ws.Calculate(); + + Assert.AreEqual(200, ws.Cells["D1"].Value); + Assert.AreEqual(200, ws.Cells["D2"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), ws.Cells["D3"].Value); + } + + [TestMethod] + public void VLookupApproximateMatch_FullColumn_ScanStopsAtDimension() + { + // Debugging aid for the inner blank-skipping scan bound. + // + // A:B is a whole-column reference (1,048,576 rows) but the data only occupies + // rows 1-5. For an approximate match the first midpoints land deep in the empty + // tail (e.g. row ~524288). Without the dimension bound, the skip-right scan + // would walk forward through hundreds of thousands of empty cells looking for a + // value. With the bound (scan limited to the last value position), each such + // midpoint does a single read and the whole lookup completes in ~20 reads. + // + // To verify in the debugger: set a breakpoint inside the inner + // 'while (probe < scanLimit && cellValue == null)' loop in + // LookupBinarySearch.SearchAscFullRange. For the early midpoints you should see + // 'mid' in the hundred-thousands while 'scanLimit' equals the last value offset + // (4), so the loop body never executes. + // + // VLOOKUP(35, A:B, 2, TRUE) -> largest key <= 35 is 30 -> 300. + using var p = new ExcelPackage(); + var ws = p.Workbook.Worksheets.Add("Sheet1"); + + ws.Cells["A1"].Value = 10; + ws.Cells["A2"].Value = 20; + ws.Cells["A3"].Value = 30; + ws.Cells["A4"].Value = 40; + ws.Cells["A5"].Value = 50; + + ws.Cells["B1"].Value = 100; + ws.Cells["B2"].Value = 200; + ws.Cells["B3"].Value = 300; + ws.Cells["B4"].Value = 400; + ws.Cells["B5"].Value = 500; + + ws.Cells["D1"].Formula = "VLOOKUP(35,A:B,2,TRUE)"; + ws.Calculate(); + + Assert.AreEqual(300, ws.Cells["D1"].Value); + } } } diff --git a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs index 016d8ca436..149b54db18 100644 --- a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs +++ b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs @@ -1666,6 +1666,19 @@ public void s1050() SaveWorkbook("s1050-saved.xlsx", p); } } + + [TestMethod] + public void s1054() + { + using (var p = OpenTemplatePackage("issues\\1054\\Payroll and FBL3N.xlsx")) + { + var ws = p.Workbook.Worksheets["Journal Calculation"]; + ws.Cells["E2"].Calculate(); + + var result = ws.Cells["E2"].Value; + Assert.AreEqual(1258679d, result); + } + } } } From 796e9c907664fc6da5b026b72bce505dfa636466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 11 Jun 2026 09:41:48 +0200 Subject: [PATCH 039/118] Fixed center-alignment for shapes --- .../Shape/ShapeToSvgTests.cs | 1 - .../Textbox/ParagraphRenderItem.cs | 6 +- .../RenderItems/Textbox/DrawingTextBody.cs | 55 ++++++++++--------- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs index 02f99f16ea..ce95ac8260 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs @@ -633,7 +633,6 @@ public void GenerateShapeCenteredParagraph() _currentShape.GetSizeInPixels(out int testWidth, out int testHeight); - var svg = _currentShape.ToSvg(); SaveTextFileToWorkbook("svg\\centeredParagraph.svg", svg); SaveAndCleanup(p); diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs index 66da7c508c..ad2c1494e2 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/ParagraphRenderItem.cs @@ -276,10 +276,8 @@ protected void WrapTextFragmentsAndGenerateTextRuns() widthOfLargestLine = Lines.LargestWidthWithoutSpace; combinedHeight = Lines.GetHeightOfCollection(_lsMultiplier, lineSpacingResult); - if(AutoSize) - { - Bounds.Width = widthOfLargestLine + RightMargin; - } + + Bounds.Width = widthOfLargestLine + RightMargin; //SetHorizontalAlignment(widthOfLargestLine); int lineIdx = 0; diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs index f82d3b0435..4a1d282266 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs @@ -81,30 +81,32 @@ public void ImportParagraph(ExcelDrawingParagraph item, double startingY, string RecalculateParagraphs(); } + //Horizontal alignment should technically be set directly in paragraph + //But as the text is not measured until a paragraph has been imported an autosized textbody + //does not know its maximum size until all its paragraphs has been imported + //thus after all have we need to adjust. + //The alternative would be to perform the performance heavy measurement twice. internal void SetHorizontalAlignmentPosition() { - if (AutoSize) + foreach (var p in Paragraphs) { - foreach (var p in Paragraphs) + switch (p.HorizontalAlignment) { - switch (p.HorizontalAlignment) - { - case TextAlignment.Left: - p.Bounds.Left = 0; - break; - case TextAlignment.Center: - p.Bounds.Left = (Bounds.Width / 2) - (p.Bounds.Width / 2); - break; - case TextAlignment.Right: - p.Bounds.Left = Bounds.Right - p.Bounds.Width; - break; - case TextAlignment.Distributed: - case TextAlignment.Justified: - case TextAlignment.JustifiedLow: - case TextAlignment.ThaiDistributed: - p.Bounds.Left = 0; //TODO: Set left for now as we do not support distributed spacing yet - break; - } + case TextAlignment.Left: + p.Bounds.Left = 0; + break; + case TextAlignment.Center: + p.Bounds.Left = (Bounds.Width / 2) - (p.Bounds.Width / 2); + break; + case TextAlignment.Right: + p.Bounds.Left = Bounds.Right - p.Bounds.Width; + break; + case TextAlignment.Distributed: + case TextAlignment.Justified: + case TextAlignment.JustifiedLow: + case TextAlignment.ThaiDistributed: + p.Bounds.Left = 0; //TODO: Set left for now as we do not support distributed spacing yet + break; } } } @@ -150,6 +152,8 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz MaxHeight = MaxHeight - top - bottom; MaxWidth = MaxWidth - left - right; + Height = MaxHeight; + Width = MaxWidth; } foreach (var paragraph in body.Paragraphs) @@ -163,6 +167,12 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz largestWidth = Math.Max(largestWidth, addedPara.Bounds.Width); } + + if (Paragraphs != null && Paragraphs.Count() > 0 && AutoSize) + { + Bounds.Height = currentHeight; + } + //Ensure contentBounds are calculated and paragraphs don't overlap RecalculateParagraphs(); @@ -173,11 +183,6 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz SetHorizontalAlignmentPosition(); } - if (Paragraphs != null && Paragraphs.Count() > 0) - { - Bounds.Height = currentHeight; - } - Bounds.Top = GetAlignmentVertical(); } From 1c09dd8d175a171fe06c77d76afa8a3a2a16c1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 11 Jun 2026 10:21:52 +0200 Subject: [PATCH 040/118] Fixed sIcon location on datalabel --- .../Trendlines/ChartTrendlineRenderer.cs | 14 +++++++------- .../Renderer/Chart/DataLabels/SvgDataLabelPoint.cs | 4 +++- .../RenderItems/Textbox/DrawingTextBody.cs | 10 +--------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index 5fa9cf92d5..9ef938bbc5 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -220,12 +220,6 @@ private void AddLblText(DrawingTextBox lbl, string labelText) int pIx = 0; var ix = labelText.IndexOf("|ss:"); - //lbl.TextBody.Paragraphs[0].HorizontalAlignment = TextAlignment.Center; - - if (ix < 0) - { - lbl.TextBody.Paragraphs[0].AddText(labelText); - } while(ix>=0 && ix < labelText.Length) { lbl.TextBody.Paragraphs[0].AddText(labelText.Substring(pIx, ix - pIx)); @@ -236,7 +230,13 @@ private void AddLblText(DrawingTextBox lbl, string labelText) ix = labelText.IndexOf("|ss:", pIx); } - if(pIx < labelText.Length) + //Uncommenting this doubles the line in the case ix <0 + //Left as comment just in case there is some edge-cases + //if (ix < 0) + //{ + // lbl.TextBody.Paragraphs[0].AddText(labelText); + //} + if (pIx < labelText.Length) { lbl.TextBody.Paragraphs[0].AddText(labelText.Substring(pIx, labelText.Length - pIx)); } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index fe439a7dac..afb1b0c122 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -493,7 +493,9 @@ public override void AppendRenderItems(List renderItems) height = _txtBox.Height; } //Currently series icon always has a y1 y2 of 2 - var iconGrp = new GroupRenderItem(new BoundingBox(_seriesIcon.Bounds.Left, height / 2 - 2)); + var iconGrp = new GroupRenderItem(new BoundingBox(_seriesIcon.Bounds.Left, height / 2)); + iconGrp.Left = _seriesIcon.Bounds.Left; + iconGrp.Top = (height / 2) - 2; group.RenderItems.Add(iconGrp); iconGrp.RenderItems.Add(_seriesIcon); } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs index 4a1d282266..a3d5851673 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs @@ -52,14 +52,7 @@ public void ImportParagraph(ExcelDrawingParagraph item, double startingY, string var paragraph = CreateParagraph(this, item, Bounds, text); paragraph.Bounds.Name = $"Container{Paragraphs.Count}"; - //if (startingY < 0) - //{ - // paragraph.Bounds.Top = GetAlignmentVertical(); - //} - //else - //{ - paragraph.Bounds.Top = startingY; - //} + paragraph.Bounds.Top = startingY; if (AutoSize) { @@ -160,7 +153,6 @@ internal virtual void ImportTextBodyAndParagraphs(ExcelTextBody body, ExcelHoriz { ImportParagraph(paragraph, currentHeight); var addedPara = Paragraphs.Last(); - //addedPara.Bounds.Width = MaxWidth; //addedPara.HorizontalAlignment = defaultAlignment; currentHeight = addedPara.Bounds.Bottom; From 9cae12a6d09a8eb05a14ac0249ad6a14fd87c081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Thu, 11 Jun 2026 11:16:07 +0200 Subject: [PATCH 041/118] Fixes axis position --- src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs | 8 ++++---- .../Drawing/Renderer/Chart/ChartPlotareaRenderer.cs | 8 ++++---- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs index 02be725181..7d4174febf 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs @@ -186,7 +186,7 @@ public eActualAxisPosition ActualAxisPosition { if (LabelPosition == eTickLabelPosition.Low) { - if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition == eTickLabelPosition.High) + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition != eTickLabelPosition.Low) { return eActualAxisPosition.Left; } @@ -197,7 +197,7 @@ public eActualAxisPosition ActualAxisPosition } else { - if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition == eTickLabelPosition.Low) + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition != eTickLabelPosition.High) { return eActualAxisPosition.Right; } @@ -211,7 +211,7 @@ public eActualAxisPosition ActualAxisPosition { if (LabelPosition == eTickLabelPosition.Low) { - if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition == eTickLabelPosition.High) + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition != eTickLabelPosition.Low) { return eActualAxisPosition.Bottom; } @@ -222,7 +222,7 @@ public eActualAxisPosition ActualAxisPosition } else { - if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition == eTickLabelPosition.Low) + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition != eTickLabelPosition.High) { return eActualAxisPosition.Top; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs index d4d24f01af..22082fb41f 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs @@ -70,8 +70,8 @@ private double GetPlotAreaHeight(RectRenderItem rect) double vaHeight = 0; if (bottomAxis!=null) { - var bottomSecondAxis = GetAxisActualByPosition(eActualAxisPosition.Bottom); - vaHeight = (bottomAxis.Rectangle?.Height ?? 0D) + (bottomAxis.Title?.TextBox?.GetActualHeight() ?? 0D) + (bottomSecondAxis.Rectangle?.Height ?? 0D); + var bottomSecondAxis = GetAxisActualByPosition(eActualAxisPosition.BottomSecond); + vaHeight = (bottomAxis.Rectangle?.Height ?? 0D) + (bottomAxis.Title?.TextBox?.GetActualHeight() ?? 0D) + (bottomSecondAxis?.Rectangle?.Height ?? 0D); } if (Chart.Legend?.Position == eLegendPosition.Bottom) { @@ -97,7 +97,7 @@ private double GetPlotAreaWidth(RectRenderItem rect) } else { - rightAxisWidth = (rightAxis.Title?.TextBox.GetActualWidth() ?? 0D) + (rightAxis.Rectangle?.Width ?? 0D) + (rightSecondAxis.Rectangle?.Width ?? 0D); + rightAxisWidth = (rightAxis.Title?.TextBox.GetActualWidth() ?? 0D) + (rightAxis.Rectangle?.Width ?? 0D) + (rightSecondAxis?.Rectangle?.Width ?? 0D); } var width = right - rightAxisWidth - rect.GlobalLeft; @@ -166,7 +166,7 @@ private double GetPlotAreaTop() } else { - haHeight = (topAxis.Rectangle?.Height ?? 0D) + (topSecondAxis.Rectangle?.Height ?? 0D) + (topAxis.Title?.TextBox?.GetActualHeight() ?? 0D); + haHeight = (topAxis.Rectangle?.Height ?? 0D) + (topSecondAxis?.Rectangle?.Height ?? 0D) + (topAxis.Title?.TextBox?.GetActualHeight() ?? 0D); } return (Chart.Legend?.Position == eLegendPosition.Top ? ChartRenderer.Legend.Rectangle.Bounds.Bottom : ChartRenderer.Title?.Rectangle?.GlobalBottom ?? 0d) + haHeight + TopMargin; diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index 8970b02219..2416c5ea73 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -99,7 +99,7 @@ private void SetAxisPositionsFromPlotarea() if (HorizontalAxis != null && HorizontalAxis.Rectangle != null) { - PlaceHorizontalAxis(HorizontalAxis, false); + PlaceHorizontalAxis(HorizontalAxis, false); //Make sure the horizontal axis is moved up if the vertical axis has a negative minimum value, so that the 0 value is at the correct position. if (HorizontalAxis.Axis.TickLabelPosition == eTickLabelPosition.NextTo && VerticalAxis.Axis.AxisType == eAxisType.Val && VerticalAxis.Min < 0D) @@ -284,7 +284,7 @@ private void PlaceVerticalAxisTitle(ChartAxisRenderer verticalAxis) } else { - verticalAxis.Title.TextBox.Left = ChartArea.LeftMargin; + verticalAxis.Title.TextBox.Left = Plotarea.Group.Left - verticalAxis.Rectangle.Width - verticalAxis.Title.TextBox.GetActualWidth() - 1.5; } } else From 7b1403f58cddf24a9caa9bccedbb11d4ceb050da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 11 Jun 2026 12:56:37 +0200 Subject: [PATCH 042/118] Fixed width of textbox --- .../RenderItems/Textbox/RenderTextBody.cs | 2 +- .../Renderer/Chart/DataLabels/SvgDataLabelPoint.cs | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs index 3e0f046370..49c3534530 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -170,7 +170,7 @@ public void RecalculateParagraphs() lastParagraphBottom = paragraph.Bounds.Bottom; smallestLeft = Math.Min(smallestLeft, paragraph.Bounds.Left); - largestWidth = paragraph.Bounds.Width; + largestWidth = Math.Max(largestWidth, paragraph.Bounds.Width); totalHeight += paragraph.Bounds.Height; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index afb1b0c122..f8b11e96f3 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -143,10 +143,15 @@ internal void ImportDataLabel(ExcelChartStandardSerie serie, ExcelChartDataLabel _txtBox = txtBox; - if (dataLabel.Fill.IsEmpty == false) - { - _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); - } + _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); + //if (dataLabel.Fill.IsEmpty == false) + //{ + // _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); + //} + //else + //{ + // _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); + //} if (dataLabel.Font.IsEmpty == false) { txtBox.TextBody.FontColorString = "#" + dataLabel.Font.Color.ToColorString(); From c04fb81a87011608871dd6cad7de08ae0b3448fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 11 Jun 2026 15:28:50 +0200 Subject: [PATCH 043/118] Added fix for html images --- .../Internal/HtmlExporterBaseInternal.cs | 4 ++-- .../Export/HtmlExport/TableExporterTests.cs | 3 +++ .../Issues/ConditionalFormattingIssues.cs | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs index e970079292..e51b64ea2a 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs @@ -148,7 +148,7 @@ protected HTMLElement GetThead(ExcelRangeBase range, List headers = null AddTableData(table, contentElement, col); - if (Settings.Pictures.Include == (ePictureInclude.Include | ePictureInclude.IncludeInHtmlOnly)) + if ((Settings.Pictures.Include == ePictureInclude.Include) || (Settings.Pictures.Include == ePictureInclude.IncludeInHtmlOnly)) { image = GetImage(cell.Worksheet.PositionId, cell._fromRow, cell._fromCol); } @@ -269,7 +269,7 @@ protected HTMLElement GetTableBody(ExcelRangeBase range, int row, int endRow) SetColRowSpan(range, tblData, cell); - if (Settings.Pictures.Include == (ePictureInclude.Include | ePictureInclude.IncludeInHtmlOnly)) + if ((Settings.Pictures.Include == ePictureInclude.Include) || (Settings.Pictures.Include == ePictureInclude.IncludeInHtmlOnly)) { image = GetImage(cell.Worksheet.PositionId, cell._fromRow, cell._fromCol); } diff --git a/src/EPPlusTest/Export/HtmlExport/TableExporterTests.cs b/src/EPPlusTest/Export/HtmlExport/TableExporterTests.cs index e8aeb5bb13..bf4e961317 100644 --- a/src/EPPlusTest/Export/HtmlExport/TableExporterTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/TableExporterTests.cs @@ -443,6 +443,9 @@ public async Task WriteImages_TableAsync() exporter.Settings.SetColumnWidth = true; exporter.Settings.SetRowHeight = true; exporter.Settings.Pictures.Include = ePictureInclude.Include; + exporter.Settings.Pictures.Position = ePicturePosition.Absolute; + exporter.Settings.Pictures.AddMarginLeft = true; + exporter.Settings.Pictures.AddMarginTop = true; exporter.Settings.Minify = false; var html = exporter.GetSinglePage(); var htmlAsync = await exporter.GetSinglePageAsync(); diff --git a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs index b095972a41..2f41f5b008 100644 --- a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs +++ b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs @@ -161,5 +161,24 @@ public void VerifyReadWritePriority() //SaveAndCleanup(p); } } + + [TestMethod] + public void i2381() + { + using (var package = OpenTemplatePackage("Cabinet_template_test_clean.xlsx")) + { + var copyCount = package.Workbook.Worksheets.Count; + var worksheets = package.Workbook.Worksheets; + + //for (int i = 0; i < copyCount; i++) + //{ + // package.Workbook.Worksheets.Copy( + // worksheets[i].Name, $"{worksheets[i].Name}_{i}"); + //} + + SaveAndCleanup(package); + } + } + } } From 57f45517aa9047e12567db131d047660cb081256 Mon Sep 17 00:00:00 2001 From: karlkallman <137038948+karlkallman@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:02:44 +0200 Subject: [PATCH 044/118] Feature/regexfunctions (#2373) * WIP * WIP * WIP * WIP * WIP * Fix REGEXEXTRACT/REGEXREPLACE scalar bugs: invalid return_mode validation, no-match now returns #N/A, empty pattern inserts replacement. Split regex tests into one file per function, verified against Excel desktop. * Refactored unit tests --------- Co-authored-by: swmal <897655+swmal@users.noreply.github.com> Co-authored-by: swmal <{ID}+username}@users.noreply.github.com> --- .../Excel/Functions/BuiltInFunctions.cs | 3 + .../Excel/Functions/Text/RegexExtract.cs | 197 ++++++++ .../Excel/Functions/Text/RegexFunctionBase.cs | 35 ++ .../Excel/Functions/Text/RegexReplace.cs | 166 +++++++ .../Text/RegexReplacementExpander.cs | 301 ++++++++++++ .../Excel/Functions/Text/RegexTest.cs | 88 ++++ .../TextFunctions/RegexExtractTests.cs | 382 ++++++++++++++++ .../TextFunctions/RegexReplaceTests.cs | 430 ++++++++++++++++++ .../Functions/TextFunctions/RegexTestTests.cs | 408 +++++++++++++++++ 9 files changed, 2010 insertions(+) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexExtract.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexFunctionBase.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplace.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplacementExpander.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexTest.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexExtractTests.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexReplaceTests.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexTestTests.cs diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index 48c6b15f58..5339a85710 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs @@ -68,6 +68,9 @@ public BuiltInFunctions() Functions["usdollar"] = new UsDollar(); Functions["encodeurl"] = new EncodeUrl(); Functions["code"] = new CodeFunction(); + Functions["regextest"] = new RegexTest(); + Functions["regexextract"] = new RegexExtract(); + Functions["regexreplace"] = new RegexReplace(); Functions["textsplit"] = new TextSplit(); Functions["textbefore"] = new TextBefore(DelimiterFunction.TextBefore); Functions["textafter"] = new TextAfter(DelimiterFunction.TextAfter); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexExtract.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexExtract.cs new file mode 100644 index 0000000000..985473c18a --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexExtract.cs @@ -0,0 +1,197 @@ +using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using OfficeOpenXml.RichData.IndexRelations; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.Text +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.Text, + EPPlusVersion = "8.6", + Description = "Extracts text matching a regular expression pattern from input string values.", + SupportsArrays = true)] + internal class RegexExtract : RegexFunctionBase + { + public override int ArgumentMinLength => 2; + + public override string NamespacePrefix => "_xlfn."; + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + bool textIsRange = arguments[0].IsExcelRange; + bool patternIsRange = arguments[1].IsExcelRange; + int returnMode = arguments.Count > 2 ? ArgToInt(arguments, 2, 0) : 0; + int caseSensitivity = arguments.Count > 3 ? ArgToInt(arguments, 3, 0) : 0; + + if (!textIsRange && !patternIsRange) + { + var text = arguments[0].Value?.ToString(); + var pattern = arguments[1].Value?.ToString(); + + if (text == null || pattern == null) + return CreateResult(ExcelErrorValue.Create(eErrorType.NA), DataType.ExcelError); + if (caseSensitivity > 1 || caseSensitivity < 0 || returnMode < 0 || returnMode > 2) + return CreateResult(ExcelErrorValue.Create(eErrorType.Value), DataType.ExcelError); + + if (returnMode == 1) + { + var matches = GetMatches(text, pattern, caseSensitivity); + if (matches.Length == 0) + return CreateResult(ExcelErrorValue.Create(eErrorType.NA), DataType.ExcelError); + + var arr = new InMemoryRange((short)1, (short)matches.Length); + for (int i = 0; i < matches.Length; i++) + arr.SetValue(0, i, matches[i]); + + return CreateDynamicArrayResult(arr, DataType.ExcelRange); + } + else if (returnMode == 2) + { + // Read the number of capturing groups from the pattern (GetGroupNumbers + // includes group 0). A failed match reports Groups.Count == 1, so this must + // not be read from the match. No groups -> #VALUE!; groups but no match -> #N/A. + var regex = new Regex(pattern, (RegexOptions)caseSensitivity); + if (regex.GetGroupNumbers().Length <= 1) + return CreateResult(ExcelErrorValue.Create(eErrorType.Value), DataType.ExcelError); + + var match = regex.Match(text); + if (!match.Success) + return CreateResult(ExcelErrorValue.Create(eErrorType.NA), DataType.ExcelError); + + var groups = match.Groups + .Cast() + .Skip(1) + .Select(g => g.Value) + .ToArray(); + + var arr = new InMemoryRange((short)1, (short)groups.Length); + for (int i = 0; i < groups.Length; i++) + arr.SetValue(0, i, groups[i]); + + return CreateDynamicArrayResult(arr, DataType.ExcelRange); + } + var firstMatch = Regex.Match(text, pattern, (RegexOptions)caseSensitivity); + if (!firstMatch.Success) + return CreateResult(ExcelErrorValue.Create(eErrorType.NA), DataType.ExcelError); + return CreateResult(firstMatch.Value, DataType.String); + } + + var texts = textIsRange ? arguments[0].ValueAsRangeInfo : null; + var patterns = patternIsRange ? arguments[1].ValueAsRangeInfo : null; + + int textRows = texts != null ? texts.Size.NumberOfRows : 1; + int textCols = texts != null ? texts.Size.NumberOfCols : 1; + int patternRows = patterns != null ? patterns.Size.NumberOfRows : 1; + int patternCols = patterns != null ? patterns.Size.NumberOfCols : 1; + + var nRows = ExpandedSize(textRows, patternRows); + var nCols = ExpandedSize(textCols, patternCols); + + var result = new InMemoryRange(nRows, nCols); + + for (int row = 0; row < nRows; row++) + { + for (int col = 0; col < nCols; col++) + { + var textValue = GetValue(texts, arguments[0], textRows, textCols, row, col); + var patternValue = GetValue(patterns, arguments[1], patternRows, patternCols, row, col); + + if (textValue == null || patternValue == null) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.NA)); + } + // Use the same validation as the scalar branch. The previous Math.Abs check + // let negative arguments through, which fell into mode 0 or reached + // (RegexOptions)(-1). Excel returns #VALUE! per cell for these. + else if (caseSensitivity > 1 || caseSensitivity < 0 || returnMode < 0 || returnMode > 2) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.Value)); + } + else + { + // Compute per cell and catch invalid-pattern exceptions here so that a + // single bad cell becomes #VALUE! in place, while the other cells are + // still calculated (verified against Excel). + try + { + var options = (RegexOptions)caseSensitivity; + if (returnMode == 2) + { + // A failed match reports Groups.Count == 1, so the number of + // capturing groups must be read from the pattern itself (via + // GetGroupNumbers, which includes group 0) rather than from the + // match. No groups -> #VALUE!; groups but no match -> #N/A. + var regex = new Regex(patternValue, options); + if (regex.GetGroupNumbers().Length <= 1) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.Value)); + } + else + { + var match = regex.Match(textValue); + if (!match.Success) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.NA)); + } + else + { + // In range mode only the first group is returned per cell. + result.SetValue(row, col, match.Groups[1].Value); + } + } + } + else if (returnMode == 1) + { + var matches = GetMatches(textValue, patternValue, caseSensitivity); + if (matches.Length == 0) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.NA)); + } + else + { + // In range mode only the first match is returned per cell. + result.SetValue(row, col, matches[0]); + } + } + else + { + var match = Regex.Match(textValue, patternValue, options); + if (!match.Success) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.NA)); + } + else + { + result.SetValue(row, col, match.Value); + } + } + } + catch (ArgumentException) + { + // Invalid regex pattern in this cell -> #VALUE! for this cell only. + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.Value)); + } + } + } + } + + return CreateDynamicArrayResult(result, DataType.ExcelRange); + } + + private string[] GetMatches(string text, string pattern, int caseSensitive) + { + return Regex.Matches(text, pattern, (RegexOptions)caseSensitive) + .Cast() + .Select(m => m.Value) + .ToArray(); + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexFunctionBase.cs new file mode 100644 index 0000000000..6ad366246f --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexFunctionBase.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.Text +{ + internal abstract class RegexFunctionBase : ExcelFunction + { + protected static string GetValue( + IRangeInfo range, + FunctionArgument scalar, + int nRows, int nCols, + int row, int col) + { + if (range == null) + return scalar.Value?.ToString(); + + int r = nRows == 1 ? 0 : row; + int c = nCols == 1 ? 0 : col; + + if (r >= nRows || c >= nCols) + return null; + + return range.GetOffset(r, c)?.ToString(); + } + + protected static short ExpandedSize(int a, int b) + { + if (a == 1) return (short)b; + if (b == 1) return (short)a; + return (short)Math.Max(a, b); + } + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplace.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplace.cs new file mode 100644 index 0000000000..72c81914bb --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplace.cs @@ -0,0 +1,166 @@ +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; + + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.Text +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.Text, + EPPlusVersion = "8.6", + Description = "Replaces text matching a regular expression pattern with specified replacement values in strings.", + SupportsArrays = true)] + internal class RegexReplace : RegexFunctionBase + { + public override int ArgumentMinLength => 3; + + public override string NamespacePrefix => "_xlfn."; + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + bool textIsRange = arguments[0].IsExcelRange; + bool patternIsRange = arguments[1].IsExcelRange; + bool replacementIsRange = arguments[2].IsExcelRange; + + int occurnance = arguments.Count > 3 ? ArgToInt(arguments, 3, 0) : 0; + int caseSensitive = arguments.Count > 4 ? ArgToInt(arguments, 4, 0) : 0; + + if (!textIsRange && !patternIsRange && !replacementIsRange) + { + var text = arguments[0].Value?.ToString() ?? string.Empty; + var pattern = arguments[1].Value?.ToString() ?? string.Empty; + var replacement = arguments[2].Value?.ToString() ?? string.Empty; + + if (caseSensitive > 1 || caseSensitive < 0) + return CreateResult(ExcelErrorValue.Create(eErrorType.Value), DataType.ExcelError); + var res = GetRegexReplaced(text, pattern, replacement, occurnance, caseSensitive); + if (res == null) + return CreateResult(ExcelErrorValue.Create(eErrorType.Value), DataType.ExcelError); + return CreateResult(res, DataType.String); + } + + var texts = textIsRange ? arguments[0].ValueAsRangeInfo : null; + var patterns = patternIsRange ? arguments[1].ValueAsRangeInfo : null; + var replacements = replacementIsRange ? arguments[2].ValueAsRangeInfo : null; + + int textRows = texts != null ? texts.Size.NumberOfRows : 1; + int textCols = texts != null ? texts.Size.NumberOfCols : 1; + int patternRows = patterns != null ? patterns.Size.NumberOfRows : 1; + int patternCols = patterns != null ? patterns.Size.NumberOfCols : 1; + int replacementsRows = replacements != null ? replacements.Size.NumberOfRows : 1; + int replacementsCols = replacements != null ? replacements.Size.NumberOfCols : 1; + + + var nRows = ExpandedSizeRegexReplace(textRows, patternRows, replacementsRows); + var nCols = ExpandedSizeRegexReplace(textCols, patternCols, replacementsCols); + + var result = new InMemoryRange(nRows, nCols); + + for (int row = 0; row < nRows; row++) + { + for (int col = 0; col < nCols; col++) + { + var textValue = GetRegexReplaceValue(texts, arguments[0], textRows, textCols, row, col); + var patternValue = GetRegexReplaceValue(patterns, arguments[1], patternRows, patternCols, row, col); + // Keep null for an out-of-range replacement (do not collapse it to an empty + // string). An empty cell within range still returns "" from GetRegexReplaceValue, + // so a legitimate empty replacement is preserved. + var replacementValue = GetRegexReplaceValue(replacements, arguments[2], replacementsRows, replacementsCols, row, col); + + if (textValue != null && patternValue == null) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.Value)); + } + else if (textValue == null || patternValue == null || replacementValue == null) + { + // Any input out of range -> #N/A (verified against Excel for the + // replacement dimension). + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.NA)); + } + else if (caseSensitive > 1 || caseSensitive < 0) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.Value)); + } + else + { + // Catch an invalid pattern per cell so that a single bad cell becomes + // #VALUE! in place, while the other cells are still calculated + // (verified against Excel). + try + { + var val = GetRegexReplaced(textValue, patternValue, replacementValue, occurnance, caseSensitive); + if (val == null) + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.Value)); + else + result.SetValue(row, col, val); + } + catch (ArgumentException) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.Value)); + } + } + } + } + + return CreateDynamicArrayResult(result, DataType.ExcelRange); + } + + private short ExpandedSizeRegexReplace(int a, int b, int c) + { + return (short)Math.Max(a, Math.Max(b, c)); + } + private string GetRegexReplaced(string text, string pattern, string replacement, int occurnance, int caseSensitive) + { + var regex = new Regex(pattern, (RegexOptions)caseSensitive); + int maxGroup = regex.GetGroupNumbers().Max(); + + // Validate the replacement against Excel's grammar (independent of any match). + // An invalid template (bad escape, out-of-range group, malformed $) -> #VALUE!. + if (!RegexReplacementExpander.IsValidTemplate(replacement, maxGroup)) + return null; + + if (Math.Abs(occurnance) > 0) + { + var allReplaceMatches = regex.Matches(text); + var targetIndex = occurnance > 0 ? occurnance - 1 + : allReplaceMatches.Count + occurnance; // search from end + if (targetIndex < 0 || targetIndex >= allReplaceMatches.Count) + { + return text; + } + var targetMatch = allReplaceMatches[targetIndex]; + return text.Substring(0, targetMatch.Index) + + RegexReplacementExpander.Expand(targetMatch, replacement, maxGroup) + + text.Substring(targetMatch.Index + targetMatch.Length); + } + else + { + return regex.Replace(text, m => RegexReplacementExpander.Expand(m, replacement, maxGroup)); + } + } + + private static string GetRegexReplaceValue( + IRangeInfo range, + FunctionArgument scalar, + int argRows, int argCols, + int row, int col) + { + if (range == null) + return scalar.Value?.ToString() ?? string.Empty; + + int r = argRows == 1 ? 0 : row; + int c = argCols == 1 ? 0 : col; + + if (r >= argRows || c >= argCols) + return null; + + return range.GetOffset(r, c)?.ToString() ?? string.Empty; + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplacementExpander.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplacementExpander.cs new file mode 100644 index 0000000000..18363f6c0c --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplacementExpander.cs @@ -0,0 +1,301 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 11/6/2026 EPPlus Software AB EPPlus v8 + *************************************************************************************************/ +using System.Text; +using System.Text.RegularExpressions; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.Text +{ + /// + /// Expands the replacement string of REGEXREPLACE using Excel's replacement grammar, which + /// differs from the grammar used by .NET's . + /// + /// The grammar was verified against Excel desktop: + /// Group references (greedy, consume all following digits): + /// \N -> group N (N must be 1..groupCount, \0 is invalid) + /// $N -> group N (N must be 0..groupCount, $0 is the whole match) + /// Literals: + /// $$ -> literal $ + /// \\ -> literal \ + /// \ + non-letter -> the literal character (e.g. \, \- \. \$ \) \) + /// Character escapes (-> the corresponding character): + /// \n \t \r \f \a \v \e \b, \cX (control), \xNN (hex) + /// \u is NOT supported and is silently dropped (Excel quirk: \u0041 -> "0041") + /// Invalid (-> #VALUE!): + /// \w \W \s \S \d \D and any other letter escape (cannot become a single character) + /// $ followed by anything other than a digit or $ (including a trailing $) + /// Edge: + /// a trailing lone \ is silently dropped + /// + internal static class RegexReplacementExpander + { + /// + /// Validates a replacement template against Excel's grammar without needing a match. + /// Returns false when the template should produce a #VALUE! error. + /// + /// The replacement string. + /// The highest capturing group number in the pattern (0 if none). + public static bool IsValidTemplate(string template, int maxGroup) + { + return ScanAndExpand(template, maxGroup, null, null); + } + + /// + /// Expands a replacement template for a specific match. The template is assumed to have + /// already passed . + /// + public static string Expand(Match match, string template, int maxGroup) + { + var output = new StringBuilder(); + ScanAndExpand(template, maxGroup, match, output); + return output.ToString(); + } + + /// + /// Single left-to-right scan that both validates and (when and + /// are supplied) expands the template. Returns false on the + /// first construct that Excel rejects with #VALUE!. + /// + private static bool ScanAndExpand(string template, int maxGroup, Match match, StringBuilder output) + { + if (template == null) + { + return true; + } + + int i = 0; + int n = template.Length; + while (i < n) + { + char ch = template[i]; + if (ch == '\\') + { + if (i + 1 >= n) + { + // Trailing lone backslash -> silently dropped. + i++; + continue; + } + + char next = template[i + 1]; + if (next >= '0' && next <= '9') + { + // Greedy group reference \N. \0 is invalid, N must be 1..maxGroup. + int start = i + 1; + int j = start; + while (j < n && template[j] >= '0' && template[j] <= '9') + { + j++; + } + int groupNo = ParseGroupNumber(template, start, j); + if (groupNo < 1 || groupNo > maxGroup) + { + return false; + } + if (output != null) + { + output.Append(match.Groups[groupNo].Value); + } + i = j; + } + else if (next == '\\') + { + if (output != null) + { + output.Append('\\'); + } + i += 2; + } + else if (IsControlEscapeLetter(next)) + { + if (output != null) + { + output.Append(ControlEscapeChar(next)); + } + i += 2; + } + else if (next == 'x') + { + // \xNN hex escape, up to two hex digits. + int start = i + 2; + int j = start; + while (j < n && j < start + 2 && IsHexDigit(template[j])) + { + j++; + } + if (j == start) + { + // \x with no hex digit. + return false; + } + if (output != null) + { + output.Append((char)HexValue(template, start, j)); + } + i = j; + } + else if (next == 'c') + { + // \cX control escape; X must be a letter. + if (i + 2 >= n || !IsAsciiLetter(template[i + 2])) + { + return false; + } + if (output != null) + { + output.Append((char)(template[i + 2] & 0x1F)); + } + i += 3; + } + else if (next == 'u') + { + // Excel does not support \u; it is silently dropped and the following + // characters remain literal (verified: \u0041 -> "0041"). + i += 2; + } + else if (IsAsciiLetter(next)) + { + // Any other letter escape (\w \W \s \S \d \D, ...) cannot become a single + // character -> #VALUE!. + return false; + } + else + { + // Backslash before a non-letter, non-digit -> the literal character. + if (output != null) + { + output.Append(next); + } + i += 2; + } + } + else if (ch == '$') + { + if (i + 1 >= n) + { + // Lone trailing $ -> #VALUE!. + return false; + } + + char next = template[i + 1]; + if (next == '$') + { + if (output != null) + { + output.Append('$'); + } + i += 2; + } + else if (next >= '0' && next <= '9') + { + // Greedy group reference $N. $0 is the whole match, N must be 0..maxGroup. + int start = i + 1; + int j = start; + while (j < n && template[j] >= '0' && template[j] <= '9') + { + j++; + } + int groupNo = ParseGroupNumber(template, start, j); + if (groupNo < 0 || groupNo > maxGroup) + { + return false; + } + if (output != null) + { + output.Append(match.Groups[groupNo].Value); + } + i = j; + } + else + { + // $ followed by anything other than a digit or $ -> #VALUE!. + return false; + } + } + else + { + if (output != null) + { + output.Append(ch); + } + i++; + } + } + + return true; + } + + private static int ParseGroupNumber(string s, int start, int end) + { + long acc = 0; + for (int k = start; k < end; k++) + { + acc = acc * 10 + (s[k] - '0'); + if (acc > int.MaxValue) + { + // Larger than any possible group number; the caller's range check rejects it. + return int.MaxValue; + } + } + return (int)acc; + } + + private static bool IsControlEscapeLetter(char c) + { + return c == 'n' || c == 't' || c == 'r' || c == 'f' + || c == 'a' || c == 'v' || c == 'b' || c == 'e'; + } + + private static char ControlEscapeChar(char c) + { + switch (c) + { + case 'n': return '\n'; + case 't': return '\t'; + case 'r': return '\r'; + case 'f': return '\f'; + case 'a': return '\a'; + case 'v': return '\v'; + case 'b': return '\b'; + case 'e': return (char)27; + default: return c; + } + } + + private static bool IsAsciiLetter(char c) + { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + private static bool IsHexDigit(char c) + { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + + private static int HexValue(string s, int start, int end) + { + int value = 0; + for (int k = start; k < end; k++) + { + value = value * 16 + HexDigit(s[k]); + } + return value; + } + + private static int HexDigit(char c) + { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return c - 'A' + 10; + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexTest.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexTest.cs new file mode 100644 index 0000000000..de016487ab --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexTest.cs @@ -0,0 +1,88 @@ +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.Text +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.Text, + EPPlusVersion = "8.6", + Description = "Tests whether input text matches a given regular expression pattern and returns boolean result.", + SupportsArrays = true)] + internal class RegexTest : RegexFunctionBase + { + public override int ArgumentMinLength => 2; + public override string NamespacePrefix => "_xlfn."; + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + bool textIsRange = arguments[0].IsExcelRange; + bool patternIsRange = arguments[1].IsExcelRange; + int caseSensitivity = arguments.Count > 2 ? ArgToInt(arguments, 2, 0) : 0; + + if (!textIsRange && !patternIsRange) + { + var text = arguments[0].Value?.ToString(); + var pattern = arguments[1].Value?.ToString(); + + if (text == null || pattern == null) + return CreateResult(ExcelErrorValue.Create(eErrorType.NA), DataType.ExcelError); + if (caseSensitivity > 1 || caseSensitivity < 0) + return CreateResult(ExcelErrorValue.Create(eErrorType.Value), DataType.ExcelError); + + return CreateResult(GetRegexTest(text, pattern, caseSensitivity), DataType.Boolean); + } + + var texts = textIsRange ? arguments[0].ValueAsRangeInfo : null; + var patterns = patternIsRange ? arguments[1].ValueAsRangeInfo : null; + + int textRows = texts != null ? texts.Size.NumberOfRows : 1; + int textCols = texts != null ? texts.Size.NumberOfCols : 1; + int patternRows = patterns != null ? patterns.Size.NumberOfRows : 1; + int patternCols = patterns != null ? patterns.Size.NumberOfCols : 1; + + var nRows = ExpandedSize(textRows, patternRows); + var nCols = ExpandedSize(textCols, patternCols); + + var result = new InMemoryRange(nRows, nCols); + + for (int row = 0; row < nRows; row++) + { + for (int col = 0; col < nCols; col++) + { + var textValue = GetValue(texts, arguments[0], textRows, textCols, row, col); + var patternValue = GetValue(patterns, arguments[1], patternRows, patternCols, row, col); + + if (textValue == null || patternValue == null) + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.NA)); + else if (caseSensitivity > 1 || caseSensitivity < 0) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.Value)); + } + else + { + // Catch an invalid pattern per cell so that a single bad cell becomes + // #VALUE! in place, while the other cells are still calculated + // (verified against Excel). + try + { + result.SetValue(row, col, GetRegexTest(textValue, patternValue, caseSensitivity)); + } + catch (ArgumentException) + { + result.SetValue(row, col, ExcelErrorValue.Create(eErrorType.Value)); + } + } + } + } + + return CreateDynamicArrayResult(result, DataType.ExcelRange); + } + + private static bool GetRegexTest(string text, string pattern, int caseSensitive) + => Regex.IsMatch(text, pattern, (RegexOptions)caseSensitive); + } +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexExtractTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexExtractTests.cs new file mode 100644 index 0000000000..fdd29c6107 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexExtractTests.cs @@ -0,0 +1,382 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.TextFunctions +{ + /// + /// Tests for the REGEXEXTRACT function. All expected values are verified against + /// the calculation of Excel desktop (see REGEX verification workbook). + /// + [TestClass] + public class RegexExtractTests : TestBase + { + // ------------------------------------------------------------------- + // return_mode 0 (first match, default) + // ------------------------------------------------------------------- + + [TestMethod] + public void ReturnMode0_ReturnsFirstMatch() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a1 b2 c3"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"\\w\\d\",0)"; + sheet.Calculate(); + + Assert.AreEqual("a1", sheet.Cells["B1"].Value); + } + } + + [TestMethod] + public void ReturnMode0_NoMatch_ReturnsNA() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"\\d+\",0)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["B1"].Value); + } + } + + // ------------------------------------------------------------------- + // return_mode 1 (all matches, spills horizontally) + // ------------------------------------------------------------------- + + [TestMethod] + public void ReturnMode1_ReturnsAllMatches() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Just #fitness finished 5k! #running"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"#\\w+\",1)"; + sheet.Calculate(); + + Assert.AreEqual("#fitness", sheet.Cells["B1"].Value); + Assert.AreEqual("#running", sheet.Cells["C1"].Value); + } + } + + [TestMethod] + public void ReturnMode1_NoMatch_ReturnsNA() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"\\d+\",1)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["B1"].Value); + } + } + + // ------------------------------------------------------------------- + // return_mode 2 (capturing groups, spills horizontally) + // ------------------------------------------------------------------- + + [TestMethod] + public void ReturnMode2_ReturnsCapturingGroups() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "9183-Green-M"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"(\\d{4})-(\\w+)-(\\w+)\",2)"; + sheet.Calculate(); + + Assert.AreEqual("9183", sheet.Cells["B1"].Value); // anchor verified against Excel + Assert.AreEqual("Green", sheet.Cells["C1"].Value); + Assert.AreEqual("M", sheet.Cells["D1"].Value); + } + } + + [TestMethod] + public void ReturnMode2_NoMatch_ReturnsNA() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + sheet.Cells["A2"].Value = "abc"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"(\\d+)\",2)"; + sheet.Cells["B2"].Formula = "REGEXEXTRACT(A2,\"(\\d+)\",2)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["B1"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["B2"].Value); + } + } + + [TestMethod] + public void ReturnMode2_PatternWithoutGroups_ReturnsValueError() + { + // Mode 2 with a pattern that has no capturing groups returns #VALUE!, + // both scalar and in range mode (verified against Excel). + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "9183"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"\\d+\",2)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + } + } + + // ------------------------------------------------------------------- + // Invalid return_mode + // ------------------------------------------------------------------- + + [TestMethod] + public void ReturnMode3_ReturnsValueError() + { + // Excel: return_mode 3 is out of range and returns #VALUE!. + // KNOWN BUG: the scalar branch validates "returnMode > 3" instead of "> 2", + // so mode 3 currently falls through and returns the first match ("a1"). + // This test is expected to FAIL until the scalar validation is changed to "> 2". + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a1 b2"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"\\w\\d\",3)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + } + } + + [TestMethod] + public void ReturnModeNegative_ReturnsValueError() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a1 b2"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"\\w\\d\",-1)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + } + } + + // ------------------------------------------------------------------- + // Invalid pattern + // ------------------------------------------------------------------- + + [TestMethod] + public void InvalidPattern_ReturnsValueError() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + sheet.Cells["B1"].Formula = "REGEXEXTRACT(A1,\"[\")"; + sheet.Cells["B2"].Formula = "REGEXEXTRACT(A1,\"(\")"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B2"].Value); + } + } + + // ------------------------------------------------------------------- + // Range / broadcast behavior + // ------------------------------------------------------------------- + + // UNVERIFIED ASSERT: the email-pattern results over a range were not confirmed against + // Excel. The pattern also has an unescaped dot before the TLD, so it matches more loosely + // than it looks. Verify the expected values (and consider escaping the dot) before trusting. + [TestMethod] + public void Range_Mode0_EmailPattern() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + + sheet.Cells["A1"].Value = "Kossa Mail@mail.se"; + sheet.Cells["A2"].Value = "FÃ¥r enmail@mef.se sd"; + sheet.Cells["A3"].Value = "mailens@hemma.com"; + sheet.Cells["A4"].Value = "mail@se.se"; + sheet.Cells["A5"].Value = "Tupp ska gala gmail@adress.net dwqdw"; + sheet.Cells["A6"].Value = "Katt"; + + sheet.Cells["B1"].Value = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}"; + + sheet.Cells["D1"].Formula = "REGEXEXTRACT(A1:A6, B1)"; + sheet.Calculate(); + + Assert.AreEqual("Mail@mail.se", sheet.Cells["D1"].Value); + Assert.AreEqual("enmail@mef.se sd", sheet.Cells["D2"].Value); + Assert.AreEqual("mailens@hemma.com", sheet.Cells["D3"].Value); + Assert.AreEqual("mail@se.se", sheet.Cells["D4"].Value); + Assert.AreEqual("gmail@adress.net dwqdw", sheet.Cells["D5"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["D6"].Value); + } + } + + [TestMethod] + public void Range_Mode1_ReturnsFirstMatchPerRow() + { + // Verifies that range mode 1 returns only the FIRST match per row (no horizontal + // spill inside a range result). + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + + sheet.Cells["A1"].Value = "Just #fitness finished 5k! #running"; + sheet.Cells["A2"].Value = "Look at this picture #nature #instagram"; + sheet.Cells["B1"].Value = "#\\w+"; + + sheet.Cells["D1"].Formula = "REGEXEXTRACT(A1:A2, B1, 1)"; + sheet.Calculate(); + + Assert.AreEqual("#fitness", sheet.Cells["D1"].Value); + Assert.AreNotEqual("#running", sheet.Cells["E1"].Value); + Assert.AreEqual("#nature", sheet.Cells["D2"].Value); + Assert.AreNotEqual("#instagram", sheet.Cells["E2"].Value); + } + } + + [TestMethod] + public void Range_Mode1_NoMatchCell_ReturnsNA() + { + // Range mode 1 yields the FIRST match per row; a non-matching row gives #N/A. + // Current code calls .First() on an empty collection -> throws (whole array fails). + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "#fitness #running"; + sheet.Cells["A2"].Value = "#nature"; + sheet.Cells["A3"].Value = "Katt utan tagg"; + sheet.Cells["A4"].Value = "#a #b"; + sheet.Cells["B1"].Value = "#\\w+"; + + sheet.Cells["D1"].Formula = "REGEXEXTRACT(A1:A4,B1,1)"; + sheet.Calculate(); + + Assert.AreEqual("#fitness", sheet.Cells["D1"].Value); + Assert.AreEqual("#nature", sheet.Cells["D2"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["D3"].Value); + Assert.AreEqual("#a", sheet.Cells["D4"].Value); // first match only, per row + } + } + + [TestMethod] + public void Range_Mode2_ReturnsFirstGroupPerRow() + { + // Verifies that range mode 2 returns only the FIRST group per row. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + + sheet.Cells["A1"].Value = "9183-Green-M"; + sheet.Cells["A2"].Value = "2546-Black-XL"; + sheet.Cells["B1"].Value = "(\\d{4})-(\\w+)-(\\w+)"; + + sheet.Cells["D1"].Formula = "REGEXEXTRACT(A1:A2, B1, 2)"; + sheet.Calculate(); + + Assert.AreEqual("9183", sheet.Cells["D1"].Value); + Assert.AreEqual("2546", sheet.Cells["D2"].Value); + } + } + + [TestMethod] + public void Range_Mode2_PatternWithoutGroups_ReturnsValueError() + { + // Mode 2 with a pattern that has no capturing groups -> #VALUE! per cell. + // Current code calls Skip(1).First() on an empty collection -> throws. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "9183"; + sheet.Cells["A2"].Value = "abcd"; + sheet.Cells["B1"].Value = "\\d+"; // no capturing group + + sheet.Cells["D1"].Formula = "REGEXEXTRACT(A1:A2,B1,2)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["D1"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["D2"].Value); + } + } + + [TestMethod] + public void Range_Mode2_GroupsButNoMatchCell_ReturnsNA() + { + // Mode 2 with groups: matching row gives the first group, non-matching row gives #N/A. + // Current code returns an empty string for the non-matching row (no throw). + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "9183-Green-M"; + sheet.Cells["A2"].Value = "ingen match"; + sheet.Cells["B1"].Value = "(\\d{4})-(\\w+)-(\\w+)"; + + sheet.Cells["D1"].Formula = "REGEXEXTRACT(A1:A2,B1,2)"; + sheet.Calculate(); + + Assert.AreEqual("9183", sheet.Cells["D1"].Value); // first group only, in range mode + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["D2"].Value); + } + } + + [TestMethod] + public void Range_NegativeReturnMode_ReturnsValueError() + { + // Range branch validates with Math.Abs, so -1/-2 slip through into mode 0. + // Excel returns #VALUE! per cell, as the scalar branch already does. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a1 b2"; + sheet.Cells["A2"].Value = "c3 d4"; + sheet.Cells["B1"].Value = "\\w\\d"; + + sheet.Cells["D1"].Formula = "REGEXEXTRACT(A1:A2,B1,-1)"; + sheet.Cells["F1"].Formula = "REGEXEXTRACT(A1:A2,B1,-2)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["D1"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["D2"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["F1"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["F2"].Value); + } + } + + [TestMethod] + public void Range_NegativeCaseSensitivity_ReturnsValueError() + { + // caseSensitivity -1 passes the Math.Abs check and reaches (RegexOptions)(-1). + // Excel returns #VALUE! per cell. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a1 b2"; + sheet.Cells["A2"].Value = "c3 d4"; + sheet.Cells["B1"].Value = "\\w\\d"; + + sheet.Cells["D1"].Formula = "REGEXEXTRACT(A1:A2,B1,0,-1)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["D1"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["D2"].Value); + } + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexReplaceTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexReplaceTests.cs new file mode 100644 index 0000000000..5ab6ed0de0 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexReplaceTests.cs @@ -0,0 +1,430 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.TextFunctions +{ + /// + /// Tests for the REGEXREPLACE function. All expected values are verified against + /// the calculation of Excel desktop (see REGEX verification workbook). + /// + [TestClass] + public class RegexReplaceTests : TestBase + { + // ------------------------------------------------------------------- + // occurrence argument + // 0 or omitted = replace all, positive N = the Nth match, + // negative N = the Nth match counted from the end. + // Out-of-range occurrences leave the text unchanged. + // ------------------------------------------------------------------- + + [TestMethod] + public void Occurrence_ZeroReplacesAll() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a-b-c-d"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"-\",\"+\",0)"; + sheet.Calculate(); + + Assert.AreEqual("a+b+c+d", sheet.Cells["B1"].Value); + } + } + + [TestMethod] + public void Occurrence_OmittedReplacesAll() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a-b-c-d"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"-\",\"+\")"; + sheet.Calculate(); + + Assert.AreEqual("a+b+c+d", sheet.Cells["B1"].Value); + } + } + + [TestMethod] + public void Occurrence_PositiveReplacesNthMatch() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a-b-c-d"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"-\",\"+\",1)"; + sheet.Cells["B2"].Formula = "REGEXREPLACE(A1,\"-\",\"+\",2)"; + sheet.Cells["B3"].Formula = "REGEXREPLACE(A1,\"-\",\"+\",3)"; + sheet.Calculate(); + + Assert.AreEqual("a+b-c-d", sheet.Cells["B1"].Value); + Assert.AreEqual("a-b+c-d", sheet.Cells["B2"].Value); + Assert.AreEqual("a-b-c+d", sheet.Cells["B3"].Value); + } + } + + [TestMethod] + public void Occurrence_NegativeCountsFromEnd() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a-b-c-d"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"-\",\"+\",-1)"; + sheet.Cells["B2"].Formula = "REGEXREPLACE(A1,\"-\",\"+\",-2)"; + sheet.Calculate(); + + Assert.AreEqual("a-b-c+d", sheet.Cells["B1"].Value); // last match + Assert.AreEqual("a-b+c-d", sheet.Cells["B2"].Value); // second from end + } + } + + [TestMethod] + public void Occurrence_OutOfRangeLeavesTextUnchanged() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a-b-c-d"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"-\",\"+\",99)"; + sheet.Cells["B2"].Formula = "REGEXREPLACE(A1,\"-\",\"+\",-99)"; + sheet.Calculate(); + + Assert.AreEqual("a-b-c-d", sheet.Cells["B1"].Value); + Assert.AreEqual("a-b-c-d", sheet.Cells["B2"].Value); + } + } + + // ------------------------------------------------------------------- + // Back references + // ------------------------------------------------------------------- + + [TestMethod] + public void ValidBackReference_IsResolved() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "2026-Q2"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"(\\d{4})-(\\w+)\",\"$2_$1\")"; + sheet.Calculate(); + + Assert.AreEqual("Q2_2026", sheet.Cells["B1"].Value); + } + } + + [TestMethod] + public void InvalidBackReference_ReturnsValueError() + { + // $1 without any capturing group, and $2 when only one group exists. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "2026"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"[0-9]+\",\"$1\")"; + sheet.Cells["B2"].Formula = "REGEXREPLACE(A1,\"([0-9]+)\",\"$2\")"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B2"].Value); + } + } + + [TestMethod] + public void LiteralDollarSign_ViaDoubleDollar() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "price: $5"; + + // "$$" -> literal $, "$1" -> group 1 ("5"), so the result is unchanged "price: $5". + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"\\$(\\d)\",\"$$$1\")"; + sheet.Calculate(); + + Assert.AreEqual("price: $5", sheet.Cells["B1"].Value); + } + } + + // ------------------------------------------------------------------- + // Pattern semantics + // ------------------------------------------------------------------- + + [TestMethod] + public void UnescapedDot_MatchesEveryCharacter() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a.b.c"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\".\",\"-\")"; + sheet.Calculate(); + + Assert.AreEqual("-----", sheet.Cells["B1"].Value); + } + } + + [TestMethod] + public void EmptyPattern_InsertsReplacementAtEveryPosition() + { + // Excel treats an empty pattern as an empty match at every position and + // inserts the replacement: "abc" with "x" -> "xaxbxcx". + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "abc"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"\",\"x\")"; + sheet.Calculate(); + + Assert.AreEqual("xaxbxcx", sheet.Cells["B1"].Value); + } + } + + // ------------------------------------------------------------------- + // Invalid arguments + // ------------------------------------------------------------------- + + [TestMethod] + public void InvalidPattern_ReturnsValueError() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"[\",\"x\")"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + } + } + + [TestMethod] + public void InvalidCaseArgument_ReturnsValueError() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + // signature: REGEXREPLACE(text, pattern, replacement, [occurrence], [case_sensitivity]) + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"k\",\"x\",0,2)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + } + } + + // ------------------------------------------------------------------- + // Range / broadcast behavior + // ------------------------------------------------------------------- + + [TestMethod] + public void Range_SimpleReplace_BlankReplacement() + { + // Verified against Excel desktop (Swedish locale). + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "044-5654-6546"; + sheet.Cells["B1"].Value = "[^0-9]"; + + sheet.Cells["D1"].Formula = "REGEXREPLACE(A1,B1,C1)"; + sheet.Calculate(); + + Assert.AreEqual("04456546546", sheet.Cells["D1"].Value); + } + } + + [TestMethod] + public void Range_NegativeOccurrence_ReplacesFromEnd() + { + // Verified against Excel desktop (Swedish locale). + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "044-5654-6546"; + sheet.Cells["B1"].Value = "[^0-9]"; + + sheet.Cells["D1"].Formula = "REGEXREPLACE(A1,B1,C1, -1)"; + sheet.Calculate(); + + Assert.AreEqual("044-56546546", sheet.Cells["D1"].Value); + } + } + + // UNVERIFIED ASSERT: this range case overlaps the broadcast / out-of-range #N/A fix we are + // landing. The replacement range (B1:C2) reuses pattern-like text and the dimensions are + // uneven - verify the expected values against Excel, as the fix may change row 3. + [TestMethod] + public void Range_UnevenDimensions_OutOfRangeRowGivesNA() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + + sheet.Cells["A1"].Value = "044-5654-6546"; + sheet.Cells["A2"].Value = "0546-4654-565"; + + sheet.Cells["D1"].Value = "[^0-9]"; + sheet.Cells["D2"].Value = "[^0-9]"; + + sheet.Cells["E1"].Formula = "REGEXREPLACE(A1:B2,D1:D3, B1:C2)"; + sheet.Calculate(); + + Assert.AreEqual("04456546546", sheet.Cells["E1"].Value); + Assert.AreEqual("05464654565", sheet.Cells["E2"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["E3"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["F3"].Value); + } + } + + [TestMethod] + public void Range_ReplacementOutOfRange_ReturnsNA() + { + // When the replacement range is shorter than the text range, the unmatched row + // gets #N/A (like REGEXTEST/REGEXEXTRACT). Current code computes an empty + // replacement instead, producing "ef". Expected to FAIL until the range fix lands. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a-b"; + sheet.Cells["A2"].Value = "c-d"; + sheet.Cells["A3"].Value = "e-f"; + sheet.Cells["C1"].Value = "-"; + sheet.Cells["D1"].Value = "+"; + sheet.Cells["D2"].Value = "*"; + + sheet.Cells["E1"].Formula = "REGEXREPLACE(A1:A3,C1,D1:D2)"; + sheet.Calculate(); + + Assert.AreEqual("a+b", sheet.Cells["E1"].Value); + Assert.AreEqual("c*d", sheet.Cells["E2"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["E3"].Value); + } + } + + [TestMethod] + public void Range_InvalidPattern_FailsPerCell() + { + // Excel isolates the invalid pattern to its own cell (#VALUE!) and computes + // the rest. Current code lets the exception bubble, failing the whole array. + // The last row (after the error cell) verifies the loop does not break early. + // Expected to FAIL until per-cell error handling lands. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + sheet.Cells["A2"].Value = "Linkoping"; + sheet.Cells["A3"].Value = "Orebro"; + sheet.Cells["A4"].Value = "Malmo"; + sheet.Cells["B1"].Value = "S"; + sheet.Cells["B2"].Value = "[A-Z]"; + sheet.Cells["B3"].Value = "["; // invalid pattern + sheet.Cells["B4"].Value = "M"; + + sheet.Cells["E1"].Formula = "REGEXREPLACE(A1:A4,B1:B4,\"x\")"; + sheet.Calculate(); + + Assert.AreEqual("xtockholm", sheet.Cells["E1"].Value); + Assert.AreEqual("xinkoping", sheet.Cells["E2"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["E3"].Value); + Assert.AreEqual("xalmo", sheet.Cells["E4"].Value); // computed AFTER the error cell + } + } + + [TestMethod] + public void Range_InvalidBackreference_PerCellValidation() + { + // Test 1: replacement with back references ($3_$1). + // Row 1 has 3 groups -> ok. Rows 2-3 have empty pattern -> 0 groups -> #VALUE!. + // Test 2: replacement without back reference ("s"). + // Empty pattern matches every position -> "s" inserted everywhere. + // Test 3 & 4: scalar versions for comparison. + // UNVERIFIED ASSERT: blank-cell pattern in range mode (rows 2-3 in test 2) + // was not directly confirmed against Excel desktop. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + + sheet.Cells["A1"].Value = "2026-Stockholm-Q2"; + sheet.Cells["A2"].Value = "2025-Linkoping-Q1"; + sheet.Cells["A3"].Value = "2024-Orebro-Q4"; + + // Only C1 is set (3 groups); C2 and C3 are empty. + sheet.Cells["C1"].Value = @"(\d{4})-(\w+)-(\w+)"; + + sheet.Cells["E1"].Formula = "REGEXREPLACE(A1:A3, C1:C3, \"$3_$1\")"; + sheet.Cells["G1"].Formula = "REGEXREPLACE(A1:A3, C1:C3, \"s\")"; + sheet.Cells["I1"].Formula = "REGEXREPLACE(A1, C1, \"$3_$1\")"; + sheet.Cells["I2"].Formula = "REGEXREPLACE(A1, \"[0-9]+\", \"$1\")"; + sheet.Calculate(); + + // Test 1 + Assert.AreEqual("Q2_2026", sheet.Cells["E1"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["E2"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["E3"].Value); + + // Test 2 + Assert.AreEqual("s", sheet.Cells["G1"].Value); + Assert.AreEqual("s2s0s2s5s-sLsisnsksospsisnsgs-sQs1s", sheet.Cells["G2"].Value); + Assert.AreEqual("s2s0s2s4s-sOsrsesbsrsos-sQs4s", sheet.Cells["G3"].Value); + + // Test 3 + Assert.AreEqual("Q2_2026", sheet.Cells["I1"].Value); + + // Test 4: $1 does not exist ([0-9]+ has no groups) -> #VALUE! + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["I2"].Value); + } + } + + // ------------------------------------------------------------------- + // Regression guards — intentionally RED until replacement-grammar fix lands. + // Do not change these asserts; they encode verified Excel behavior. + // ------------------------------------------------------------------- + + // REGRESSION GUARD: Excel rejects unknown backslash escapes (\d, \w) in the + // replacement string with #VALUE!. Current code passes the replacement straight + // to Regex.Replace and inserts it literally instead. + [TestMethod] + public void RegexReplaceValueError() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + + sheet.Cells["A1"].Value = "044-5654-6546"; + sheet.Cells["D1"].Value = "(\\d{4})-(\\w+)-(\\w+)"; + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,C1,D1)"; + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + } + } + + // REGRESSION GUARD: Excel treats \1 in the replacement as a back reference (group 1), + // yielding "ax1y". Current code passes it through literally, yielding "ax\1y". + [TestMethod] + public void Replacement_BackslashGroupReference_MatchesExcel() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "a1"; + sheet.Cells["B1"].Formula = "REGEXREPLACE(A1,\"(\\d)\",\"x\\1y\")"; + sheet.Calculate(); + + Assert.AreEqual("ax1y", sheet.Cells["B1"].Value); + } + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexTestTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexTestTests.cs new file mode 100644 index 0000000000..8391e00703 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/RegexTestTests.cs @@ -0,0 +1,408 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.TextFunctions +{ + /// + /// Tests for the REGEXTEST function. All expected values are verified against + /// the calculation of Excel desktop (see REGEX verification workbook). + /// + [TestClass] + public class RegexTestTests : TestBase + { + // ------------------------------------------------------------------- + // Case sensitivity + // Verified in Excel: argument 0 = case-SENSITIVE, argument 1 = case-INSENSITIVE. + // (This matches (RegexOptions)0 = None and (RegexOptions)1 = IgnoreCase.) + // ------------------------------------------------------------------- + + [TestMethod] + public void CaseSensitivity_Arg0_IsCaseSensitive() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + sheet.Cells["B1"].Formula = "REGEXTEST(A1,\"stockholm\",0)"; + sheet.Cells["B2"].Formula = "REGEXTEST(A1,\"STOCKHOLM\",0)"; + sheet.Cells["B3"].Formula = "REGEXTEST(A1,\"Stockholm\",0)"; + sheet.Calculate(); + + Assert.AreEqual(false, sheet.Cells["B1"].Value); // different case does not match + Assert.AreEqual(false, sheet.Cells["B2"].Value); + Assert.AreEqual(true, sheet.Cells["B3"].Value); // exact case matches + } + } + + [TestMethod] + public void CaseSensitivity_Arg1_IsCaseInsensitive() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + sheet.Cells["B1"].Formula = "REGEXTEST(A1,\"stockholm\",1)"; + sheet.Cells["B2"].Formula = "REGEXTEST(A1,\"STOCKHOLM\",1)"; + sheet.Cells["B3"].Formula = "REGEXTEST(A1,\"Stockholm\",1)"; + sheet.Calculate(); + + Assert.AreEqual(true, sheet.Cells["B1"].Value); // case ignored -> matches + Assert.AreEqual(true, sheet.Cells["B2"].Value); + Assert.AreEqual(true, sheet.Cells["B3"].Value); + } + } + + [TestMethod] + public void CaseSensitivity_SwedishCharacters() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "ÖREBRO"; + + sheet.Cells["B1"].Formula = "REGEXTEST(A1,\"örebro\",0)"; + sheet.Cells["B2"].Formula = "REGEXTEST(A1,\"örebro\",1)"; + sheet.Calculate(); + + Assert.AreEqual(false, sheet.Cells["B1"].Value); // case-sensitive, no match + Assert.AreEqual(true, sheet.Cells["B2"].Value); // case-insensitive, matches + } + } + + [TestMethod] + public void CaseSensitivity_Omitted_DefaultsToCaseSensitive() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + sheet.Cells["B1"].Formula = "REGEXTEST(A1,\"stockholm\")"; + sheet.Cells["B2"].Formula = "REGEXTEST(A1,\"STOCKHOLM\")"; + sheet.Cells["B3"].Formula = "REGEXTEST(A1,\"Stockholm\")"; + sheet.Calculate(); + + Assert.AreEqual(false, sheet.Cells["B1"].Value); // default == case-sensitive + Assert.AreEqual(false, sheet.Cells["B2"].Value); + Assert.AreEqual(true, sheet.Cells["B3"].Value); + } + } + + // ------------------------------------------------------------------- + // Invalid arguments + // ------------------------------------------------------------------- + + [TestMethod] + public void InvalidCaseArgument_ReturnsValueError() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + sheet.Cells["B1"].Formula = "REGEXTEST(A1,\"k\",2)"; // 2 is out of range + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + } + } + + [TestMethod] + public void InvalidPattern_ReturnsValueError() + { + // Excel returns #VALUE! for syntactically invalid regex patterns. + // The function must catch the regex exception and return a Value error. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + + sheet.Cells["B1"].Formula = "REGEXTEST(A1,\"[\")"; // unterminated class + sheet.Cells["B2"].Formula = "REGEXTEST(A1,\"(\")"; // unterminated group + sheet.Cells["B3"].Formula = "REGEXTEST(A1,\"*abc\")"; // quantifier without expression + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B1"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B2"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["B3"].Value); + } + } + + // ------------------------------------------------------------------- + // Numeric input is coerced to text. The coercion is culture sensitive, + // so the test pins the culture (verified against Swedish Excel). + // ------------------------------------------------------------------- + + [TestMethod] + public void NumericInput_IntegerMatchesPattern() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = 2026; + sheet.Cells["A2"].Value = 1000000; + + sheet.Cells["B1"].Formula = "REGEXTEST(A1,\"202[456]\")"; + sheet.Cells["B2"].Formula = "REGEXTEST(A2,\"^\\d+$\")"; + sheet.Calculate(); + + Assert.AreEqual(true, sheet.Cells["B1"].Value); + Assert.AreEqual(true, sheet.Cells["B2"].Value); // no thousand separator in coercion + } + } + + [TestMethod] + public void NumericInput_DecimalSeparatorIsCultureSensitive() + { + // Swedish culture renders 3.14 as "3,14", so a comma pattern matches + // and a literal-dot pattern does not. + SwitchToCulture("sv-SE"); + try + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = 3.14; + + sheet.Cells["B1"].Formula = "REGEXTEST(A1,\"3[.,]14\")"; // comma or dot + sheet.Cells["B2"].Formula = "REGEXTEST(A1,\"3\\.14\")"; // literal dot only + sheet.Calculate(); + + Assert.AreEqual(true, sheet.Cells["B1"].Value); + Assert.AreEqual(false, sheet.Cells["B2"].Value); + } + } + finally + { + SwitchBackToCurrentCulture(); + } + } + + // ------------------------------------------------------------------- + // Range / broadcast behavior + // ------------------------------------------------------------------- + + [TestMethod] + public void RangeInput_PairwiseEqualDimensions() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + sheet.Cells["A2"].Value = "Linköping"; + sheet.Cells["A3"].Value = "Örebro"; + + sheet.Cells["B1"].Value = "^S"; // matches Stockholm + sheet.Cells["B2"].Value = "^Q"; // no match + sheet.Cells["B3"].Value = "[A-ZÅÄÖ]"; // matches (starts with capital) + + sheet.Cells["D1"].Formula = "REGEXTEST(A1:A3,B1:B3)"; + sheet.Calculate(); + + Assert.AreEqual(true, sheet.Cells["D1"].Value); + Assert.AreEqual(false, sheet.Cells["D2"].Value); + Assert.AreEqual(true, sheet.Cells["D3"].Value); + } + } + + [TestMethod] + public void RangeInput_BroadcastAnchorIsTrue() + { + // 3 texts vs 2 patterns. Only the anchor cell is verified against Excel (TRUE). + // The full spill (row 2 TRUE, row 3 #N/A) is expected but should be confirmed + // once the round-2 verification workbook is filled in. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm 2026"; + sheet.Cells["A2"].Value = "Linköping 2025"; + sheet.Cells["A3"].Value = "Örebro 2024"; + + sheet.Cells["C1"].Value = "\\d{4}"; + sheet.Cells["C2"].Value = "[A-ZÅÄÖ]\\w+"; + + sheet.Cells["E1"].Formula = "REGEXTEST(A1:A3,C1:C2)"; + sheet.Calculate(); + + Assert.AreEqual(true, sheet.Cells["E1"].Value); // "Stockholm 2026" vs \d{4} + } + } + + [TestMethod] + public void RangeInput_InvalidPattern_FailsPerCell() + { + // Excel isolates an invalid pattern to its own cell (#VALUE!) and still + // computes the others. The current range loop lets the exception bubble out, + // turning the WHOLE array into #VALUE!. The last row verifies the loop does + // not stop at the error cell. Expected to FAIL until the loop catches per cell. + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + sheet.Cells["A1"].Value = "Stockholm"; + sheet.Cells["A2"].Value = "Linkoping"; + sheet.Cells["A3"].Value = "Orebro"; + sheet.Cells["A4"].Value = "Malmo"; + sheet.Cells["B1"].Value = "S"; + sheet.Cells["B2"].Value = "[A-Z]"; + sheet.Cells["B3"].Value = "["; // invalid pattern + sheet.Cells["B4"].Value = "M"; + + sheet.Cells["D1"].Formula = "REGEXTEST(A1:A4,B1:B4)"; + sheet.Calculate(); + + Assert.AreEqual(true, sheet.Cells["D1"].Value); + Assert.AreEqual(true, sheet.Cells["D2"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Value), sheet.Cells["D3"].Value); + Assert.AreEqual(true, sheet.Cells["D4"].Value); // computed AFTER the error cell + } + } + + [TestMethod] + public void RangeInput_MixedPatterns_PairwiseResults() + { + // Each text is matched against its corresponding pattern. + // Verified against Excel desktop (Swedish locale). + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + + sheet.Cells["A1"].Value = "Stockholm"; + sheet.Cells["A2"].Value = "Linköping"; + sheet.Cells["A3"].Value = "Örebro"; + sheet.Cells["A4"].Value = "Stockholm"; + sheet.Cells["A5"].Value = "Örebro"; + sheet.Cells["A6"].Value = "Linköping"; + + sheet.Cells["B1"].Value = "Stockholm"; + sheet.Cells["B2"].Value = "^S"; + sheet.Cells["B3"].Value = "Q[0-9]"; + sheet.Cells["B4"].Value = "202[456]"; + sheet.Cells["B5"].Value = "^[0-9]{5}$"; + sheet.Cells["B6"].Value = "[A-ZÅÄÖ][a-zåäö]+"; + + sheet.Cells["D1"].Formula = "REGEXTEST(A1:A6, B1:B6)"; + sheet.Calculate(); + + Assert.AreEqual(true, sheet.Cells["D1"].Value); + Assert.AreEqual(false, sheet.Cells["D2"].Value); + Assert.AreEqual(false, sheet.Cells["D3"].Value); + Assert.AreEqual(false, sheet.Cells["D4"].Value); + Assert.AreEqual(false, sheet.Cells["D5"].Value); + Assert.AreEqual(true, sheet.Cells["D6"].Value); + } + } + + [TestMethod] + public void RangeInput_MultipleColumns_PairwiseResults() + { + // Two text columns (A, B) broadcast against one pattern column (C). + // Verified against Excel desktop (Swedish locale). + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + + sheet.Cells["A1"].Value = "Stockholm"; + sheet.Cells["A2"].Value = "Linköping"; + sheet.Cells["A3"].Value = "Örebro"; + sheet.Cells["A4"].Value = "Stockholm"; + sheet.Cells["A5"].Value = "Örebro"; + sheet.Cells["A6"].Value = "Linköping"; + + sheet.Cells["B1"].Value = "Stockholm"; + sheet.Cells["B2"].Value = "Linköping"; + sheet.Cells["B3"].Value = "Örebro"; + sheet.Cells["B4"].Value = "Stockholm"; + sheet.Cells["B5"].Value = "Örebro"; + sheet.Cells["B6"].Value = "Linköping"; + + sheet.Cells["C1"].Value = "Stockholm"; + sheet.Cells["C2"].Value = "^S"; + sheet.Cells["C3"].Value = "Q[0-9]"; + sheet.Cells["C4"].Value = "202[456]"; + sheet.Cells["C5"].Value = "^[0-9]{5}$"; + sheet.Cells["C6"].Value = "[A-ZÅÄÖ][a-zåäö]+"; + + sheet.Cells["D1"].Formula = "REGEXTEST(A1:B6, C1:C6)"; + sheet.Calculate(); + + Assert.AreEqual(true, sheet.Cells["D1"].Value); + Assert.AreEqual(false, sheet.Cells["D2"].Value); + Assert.AreEqual(false, sheet.Cells["D3"].Value); + Assert.AreEqual(false, sheet.Cells["D4"].Value); + Assert.AreEqual(false, sheet.Cells["D5"].Value); + Assert.AreEqual(true, sheet.Cells["D6"].Value); + + Assert.AreEqual(true, sheet.Cells["E1"].Value); + Assert.AreEqual(false, sheet.Cells["E2"].Value); + Assert.AreEqual(false, sheet.Cells["E3"].Value); + Assert.AreEqual(false, sheet.Cells["E4"].Value); + Assert.AreEqual(false, sheet.Cells["E5"].Value); + Assert.AreEqual(true, sheet.Cells["E6"].Value); + } + } + + // UNVERIFIED ASSERT: the #N/A fill pattern for uneven ranges with numeric input was not + // confirmed against Excel. This overlaps the broadcast behavior we are still fixing - + // verify the expected values before trusting this as a regression guard. + [TestMethod] + public void RangeInput_UnevenDimensions_NumericInput() + { + using (var package = OpenPackage("Testpackage")) + { + var sheet = package.Workbook.Worksheets.Add("testsheet"); + + sheet.Cells["A1"].Value = "Stockholm"; + sheet.Cells["A2"].Value = "Linköping"; + sheet.Cells["A3"].Value = "Örebro"; + sheet.Cells["A4"].Value = "Stockholm"; + sheet.Cells["A5"].Value = "Örebro"; + sheet.Cells["A6"].Value = "Linköping"; + + sheet.Cells["B1"].Value = "Stockholm"; + sheet.Cells["B2"].Value = "Linköping"; + sheet.Cells["B3"].Value = "Örebro"; + sheet.Cells["B4"].Value = "Stockholm"; + sheet.Cells["B5"].Value = "Örebro"; + sheet.Cells["B6"].Value = "Linköping"; + + sheet.Cells["C1"].Value = 2026; + sheet.Cells["C2"].Value = 2026; + sheet.Cells["C3"].Value = 2025; + sheet.Cells["C4"].Value = 2025; + sheet.Cells["C5"].Value = 2025; + sheet.Cells["C6"].Value = 2024; + + sheet.Cells["D4"].Value = "202[456]"; + sheet.Cells["D5"].Value = "^[0-9]{5}$"; + sheet.Cells["D6"].Value = "[A-ZÅÄÖ][a-zåäö]+"; + sheet.Cells["D7"].Value = "[0-9]+"; + + sheet.Cells["E1"].Formula = "REGEXTEST(A1:C6, D4:D7)"; + sheet.Calculate(); + + Assert.AreEqual(false, sheet.Cells["E1"].Value); + Assert.AreEqual(false, sheet.Cells["E2"].Value); + Assert.AreEqual(true, sheet.Cells["E3"].Value); + Assert.AreEqual(false, sheet.Cells["E4"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["E5"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["E6"].Value); + + Assert.AreEqual(false, sheet.Cells["F1"].Value); + Assert.AreEqual(false, sheet.Cells["F2"].Value); + Assert.AreEqual(true, sheet.Cells["F3"].Value); + Assert.AreEqual(false, sheet.Cells["F4"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["F5"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["F6"].Value); + + Assert.AreEqual(true, sheet.Cells["G1"].Value); + Assert.AreEqual(false, sheet.Cells["G2"].Value); + Assert.AreEqual(false, sheet.Cells["G3"].Value); + Assert.AreEqual(true, sheet.Cells["G4"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["G5"].Value); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.NA), sheet.Cells["G6"].Value); + } + } + } +} \ No newline at end of file From 3ed60c455a9ccc648bd3f651a259aa5d1b22f027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 15 Jun 2026 10:50:16 +0200 Subject: [PATCH 045/118] Fixed multiple paragraph test --- .../Integration/LayoutSystemTests.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs index e5a4dc525b..9e28aa3a12 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs @@ -394,13 +394,22 @@ public void TestLayoutSystemMultipleParagraphs() Size = 11, SubFamily = FontSubFamily.Bold }; + + var font2 = new FontFormatBase() + { + Family = "Aptos Narrow", + Size = 11, + SubFamily = FontSubFamily.Italic + }; + var fragments = new List() { - new TextFragment() {Text = lstOfRichText[0], Font = font } + new TextFragment() {Text = lstOfRichText[0], Font = font }, + new TextFragment() {Text = lstOfRichText[1], Font = font2 } }; var layout = new LayoutSystem(fragments); - Assert.AreEqual(3, layout.GetParagraphSeparatorCount()); + Assert.AreEqual(5, layout.GetParagraphSeparatorCount()); //var rtCollection = new RichTextCollectionBase(); //var someTextRt = rtCollection.Add("SomeText", true); //var richRt = rtCollection.Add("rich"); From dd28f16a9f446652fba14faee85fbdb258e730c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 15 Jun 2026 12:21:36 +0200 Subject: [PATCH 046/118] Temporary fix for exact font --- .../FallbackFonts/FontProviderTests.cs | 34 +++++++++++++++++++ .../Integration/LayoutSystemTests.cs | 25 -------------- .../OpenTypeFontEngine.cs | 18 ++++++++-- .../Fonts/FontAvailability.cs | 4 ++- 4 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/FontProviderTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/FontProviderTests.cs index 6f8f1fd93d..80c6c84dab 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/FontProviderTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/FallbackFonts/FontProviderTests.cs @@ -79,6 +79,40 @@ public void DefaultFontProvider_MixedTextAndEmoji_ShouldUseMultipleFonts() Assert.AreNotEqual(_robotoFont, usedFonts[1], "Second font should be emoji fallback"); } + [TestMethod] + public void DefaultFontProvider_EnsureLastFallbackDoesNotThrowOnExactAllowEmbed() + { + var engine = new OpenTypeFontEngine(cfg => + { + foreach (var folder in FontFolders) + cfg.FontDirectories.Add(folder); + cfg.SearchSystemDirectories = false; + cfg.SetScriptFallback(UnicodeScript.Latin, "Archivo Narrow"); + }); + + engine.LeastRequiredAvailability = FontAvailability.ExactAllowEmbed; + + var shaper = engine.GetTextShaper("Archivo Narrow", FontSubFamily.Regular); + + var shaped = shaper.Shape("Hello There World"); + var usedFonts = shaper.GetUsedFonts().ToList(); + } + + [TestMethod] + public void DefaultFontProvider_EnsureLastFallbackThrowOnExact() + { + var engine = new OpenTypeFontEngine(cfg => + { + foreach (var folder in FontFolders) + cfg.FontDirectories.Add(folder); + cfg.SearchSystemDirectories = false; + cfg.SetScriptFallback(UnicodeScript.Latin, "Archivo Narrow"); + }); + + engine.LeastRequiredAvailability = FontAvailability.Exact; + Assert.ThrowsExactly(() => { engine.GetTextShaper("Archivo Narrow", FontSubFamily.Regular); }); + } + [TestMethod] public void TextShaper_SurrogatePair_ShouldMapToSingleGlyph() { diff --git a/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs index 9e28aa3a12..0374c10028 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs @@ -410,31 +410,6 @@ public void TestLayoutSystemMultipleParagraphs() var layout = new LayoutSystem(fragments); Assert.AreEqual(5, layout.GetParagraphSeparatorCount()); - //var rtCollection = new RichTextCollectionBase(); - //var someTextRt = rtCollection.Add("SomeText", true); - //var richRt = rtCollection.Add("rich"); - //var richerRt = rtCollection.Add("richer"); - //var richestRt = rtCollection.Add("richest"); - //var wealthyRt = rtCollection.Add("Wealthy"); - - //richRt.Info.FontFamily = "Roboto"; - //richRt.Info.Italic = true; - - //richerRt.Info.FontFamily = "Roboto"; - //richerRt.Info.Size = 16; - //richerRt.Info.Italic = true; - //richerRt.Info.UnderlineType = (int)ExcelUnderLineType.Single; - - //richestRt.Info.FontFamily = "Oi"; - //richestRt.Info.FontColor = Color.BlueViolet; - //richestRt.Info.Bold = true; - //richestRt.Info.Italic = true; - //richerRt.Info.Size = 18; - - - //someTextRt.FontData.Family = "Archivo Narrow"; - - //richRt } } } diff --git a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs index 78d3136271..08c3584a8c 100644 --- a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs +++ b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs @@ -106,8 +106,8 @@ public OpenTypeFontEngine(Action configure) // Public API // ----------------------------------------------------------------------------------------- - public FontAvailability LeastRequiredAvailability { get; set; } = FontAvailability.Exact; - bool RequireExactFoundFont { get { return LeastRequiredAvailability == FontAvailability.Exact; } } + public FontAvailability LeastRequiredAvailability { get; set; } = FontAvailability.ExactAllowEmbed; + bool RequireExactFoundFont { get { return LeastRequiredAvailability == FontAvailability.ExactAllowEmbed || LeastRequiredAvailability == FontAvailability.Exact; } } bool RequireFamilyFont{ get { return RequireExactFoundFont || LeastRequiredAvailability == FontAvailability.FamilyOnly; } } //public FontAvailability FallBackAvailablility = FontAvailability.Exact; @@ -138,7 +138,19 @@ public TextShaper GetTextShaper(string fontName, FontSubFamily subFamily = FontS var availability = GetFontAvailability(fontName, subFamily); if (RequireExactFoundFont && availability != FontAvailability.Exact) { - throw new FileNotFoundException($"Could not find Font: {fontName} fallbacked to font:{font.GetEnglishFontFamilyName()}"); + var fullFontName = font.GetEnglishFontFamilyName(); + if(LeastRequiredAvailability == FontAvailability.ExactAllowEmbed) + { + //If we fallbacked to the correct embedded font despite not finding it in specified folders there's no reason to throw. + if (fullFontName.Contains(fontName) == false) + { + throw new FileNotFoundException($"Could not find Font: {fontName} fallbacked to font: {fullFontName}"); + } + } + else + { + throw new FileNotFoundException($"Could not find Font: {fontName} fallbacked to font: {fullFontName}"); + } } else if(RequireFamilyFont && availability != FontAvailability.FamilyOnly && availability != FontAvailability.Exact) { diff --git a/src/EPPlus.Interfaces/Fonts/FontAvailability.cs b/src/EPPlus.Interfaces/Fonts/FontAvailability.cs index 213f632459..be33bde183 100644 --- a/src/EPPlus.Interfaces/Fonts/FontAvailability.cs +++ b/src/EPPlus.Interfaces/Fonts/FontAvailability.cs @@ -26,6 +26,8 @@ public enum FontAvailability FamilyOnly, /// The exact font family and subfamily is available. - Exact + Exact, + /// Exact but does not throw if the embedded fallback font is asked for + ExactAllowEmbed } } \ No newline at end of file From 243cefef75b0f557cf547a845ba4824dbb7b10ba Mon Sep 17 00:00:00 2001 From: OssianEPPlus <122265629+OssianEPPlus@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:22:27 +0200 Subject: [PATCH 047/118] Bugi2381 - ConditionalFormatting DxfStyleId miss-match (#2386) * Potential solution/verification ids off by 1 * Implemented better solution that does not break tests * Cleanup * Additional cleanup --- .../Rules/ExcelConditionalFormattingRule.cs | 3 +- src/EPPlus/Style/Dxf/DxfStyleHandler.cs | 8 ++- .../Dxf/ExcelDxfStyleConditionalFormatting.cs | 2 - .../Issues/ConditionalFormattingIssues.cs | 50 ++++++++++++++++--- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/EPPlus/ConditionalFormatting/Rules/ExcelConditionalFormattingRule.cs b/src/EPPlus/ConditionalFormatting/Rules/ExcelConditionalFormattingRule.cs index eb50650f3b..d5b47d37e8 100644 --- a/src/EPPlus/ConditionalFormatting/Rules/ExcelConditionalFormattingRule.cs +++ b/src/EPPlus/ConditionalFormatting/Rules/ExcelConditionalFormattingRule.cs @@ -298,7 +298,8 @@ internal ExcelConditionalFormattingRule(eExcelConditionalFormattingRuleType type if (DxfId >= 0 && DxfId < _ws.Workbook.Styles.Dxfs.Count) { _ws.Workbook.Styles.Dxfs[DxfId].AllowChange = true; //This Id is referenced by CF, so we can use it when we save. - _style = ((ExcelDxfStyleBase)_ws.Workbook.Styles.Dxfs[DxfId]).ToDxfConditionalFormattingStyle(); //Clone, so it can be altered without affecting other dxf styles + var origStyle = _ws.Workbook.Styles.Dxfs[DxfId]; + _style = origStyle.ToDxfConditionalFormattingStyle(); //Clone, so it can be altered without affecting other dxf styles } var tempAddress = ""; diff --git a/src/EPPlus/Style/Dxf/DxfStyleHandler.cs b/src/EPPlus/Style/Dxf/DxfStyleHandler.cs index e6539e3093..e830f44400 100644 --- a/src/EPPlus/Style/Dxf/DxfStyleHandler.cs +++ b/src/EPPlus/Style/Dxf/DxfStyleHandler.cs @@ -212,10 +212,13 @@ private static void UpdateConditionalFormatting(ExcelWorksheet ws, ExcelStyleCol { if (cf.Style.HasValue) { - int ix = dxfs.FindIndexById(cf.Style.Id); + var standardDxfStyle = cf.Style.ToDxfStyle(); + + int ix = dxfs.FindIndexById(standardDxfStyle.Id); if (ix < 0) { ((ExcelConditionalFormattingRule)cf).DxfId = dxfs.Count; + cf.Style.DxfId = ((ExcelConditionalFormattingRule)cf).DxfId; dxfs.Add(cf.Style.Id, cf.Style); var elem = dxfsNode.OwnerDocument.CreateElement("dxf", ExcelPackage.schemaMain); cf.Style.CreateNodes(new XmlHelperInstance(ws.NameSpaceManager, elem), ""); @@ -224,9 +227,12 @@ private static void UpdateConditionalFormatting(ExcelWorksheet ws, ExcelStyleCol else { ((ExcelConditionalFormattingRule)cf).DxfId = ix; + cf.Style.DxfId = ix; + //cf.Style.DxfId = ix; } } } + //var num = dxfs._list[129]; } internal static void CopyDxfStylesTable(ExcelTable tblFrom, ExcelTable tblTo) { diff --git a/src/EPPlus/Style/Dxf/ExcelDxfStyleConditionalFormatting.cs b/src/EPPlus/Style/Dxf/ExcelDxfStyleConditionalFormatting.cs index ca15280b71..b92af96808 100644 --- a/src/EPPlus/Style/Dxf/ExcelDxfStyleConditionalFormatting.cs +++ b/src/EPPlus/Style/Dxf/ExcelDxfStyleConditionalFormatting.cs @@ -37,8 +37,6 @@ internal override string Id { get { - - return GetId() + ExcelDxfAlignment.GetEmptyId() + ExcelDxfProtection.GetEmptyId(); } } diff --git a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs index 2f41f5b008..7170eaff8d 100644 --- a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs +++ b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs @@ -169,16 +169,54 @@ public void i2381() { var copyCount = package.Workbook.Worksheets.Count; var worksheets = package.Workbook.Worksheets; + var ws = worksheets[0]; + var range1 = ws.Cells["A6:AL23"]; - //for (int i = 0; i < copyCount; i++) - //{ - // package.Workbook.Worksheets.Copy( - // worksheets[i].Name, $"{worksheets[i].Name}_{i}"); - //} + var cfS = range1.ConditionalFormatting.GetConditionalFormattings(); + var cfStyle = cfS[0].Style; + var borderBottom = cfS[0].Style.Border.HasValue; - SaveAndCleanup(package); + var file = GetOutputFile("", "Cabinet_template_test_clean_Output.xlsx"); + package.SaveAs(file); + } + + using (var package = OpenPackage("Cabinet_template_test_clean_Output.xlsx")) + { + var copyCount = package.Workbook.Worksheets.Count; + var worksheets = package.Workbook.Worksheets; + var ws = worksheets[0]; + var range1 = ws.Cells["A6:AL23"]; + + var cfS = range1.ConditionalFormatting.GetConditionalFormattings(); + var cfStyle = cfS[0].Style; + var borderBottom = cfS[0].Style.Border.HasValue; + + var style = ws.Workbook.Styles.Dxfs[128]; + + var test = "why"; + + var file = GetOutputFile("", "Cabinet_template_test_clean_OutputReRead.xlsx"); + package.SaveAs(file); } } + [TestMethod] + public void i2381Excel() + { + using (var package = OpenTemplatePackage("Cabinet_template_test_clean_excelSaved.xlsx")) + { + var copyCount = package.Workbook.Worksheets.Count; + var worksheets = package.Workbook.Worksheets; + var ws = worksheets[0]; + var range1 = ws.Cells["A6:AL23"]; + + var cfS = range1.ConditionalFormatting.GetConditionalFormattings(); + var cfStyle = cfS[0].Style; + var borderBottom = cfS[0].Style.Border.HasValue; + + var file = GetOutputFile("", "Cabinet_template_test_clean_ExcelOutput.xlsx"); + package.SaveAs(file); + } + } } } From 6531b1b845e06be25f4531e65b89fc496f349f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Mon, 15 Jun 2026 13:42:34 +0200 Subject: [PATCH 048/118] WIP:Added first test for errorbars --- .../Chart/ErrorbarsTests.cs | 30 +++++++++++++++++++ .../Tables/Name/NameTable.cs | 11 ++++++- src/EPPlus/Drawing/Chart/ExcelChartSerie.cs | 18 ++++++++++- .../Renderer/Chart/ChartLegendRenderer.cs | 6 ++-- src/EPPlus/ExcelRangeBase.cs | 20 ++++++++++++- 5 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs new file mode 100644 index 0000000000..db5a3b97bd --- /dev/null +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs @@ -0,0 +1,30 @@ +using OfficeOpenXml; +using OfficeOpenXml.Drawing.Chart; + +namespace EPPlus.Export.ImageRenderer.Tests.Chart +{ + [TestClass] + public class ErrorbarsTests : TestBase + { + [TestMethod] + public void GenerateSvgForErrorbars_Sheet1() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("Errorbars.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + //var ix = 4; + //var c = ws.Drawings[ix]; + //var svg = c.ToSvg(); + //SaveTextFileToWorkbook($"svg\\Trendline_sheet1_ind{ix++}.svg", svg); + + var ix = 0; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\Errorbar_sheet1_{ix++}.svg", svg); + } + } + } + } +} diff --git a/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs b/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs index eeaaa4ccb0..f29a296ebf 100644 --- a/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs +++ b/src/EPPlus.Fonts.OpenType/Tables/Name/NameTable.cs @@ -153,12 +153,16 @@ private static Encoding GetEncodingForRecord(NameRecord record) // Fallback for unknown platforms return Encoding.UTF8; } - + private static readonly Dictionary _fallbackEncodings = new Dictionary(); // ADD GetWindowsEncoding() method: private static Encoding GetWindowsEncoding(ushort encodingId) { try { + if(_fallbackEncodings.TryGetValue(encodingId, out var cached)) + { + return cached; + } switch (encodingId) { case 0: @@ -204,6 +208,11 @@ private static Encoding GetWindowsEncoding(ushort encodingId) { // If encoding doesn't exist (e.g. in .NET Standard without System.Text.Encoding.CodePages) // Fallback to UTF-16BE - this is safe for most modern fonts + lock (_fallbackEncodings) + { + _fallbackEncodings.Add(encodingId, Encoding.BigEndianUnicode); + } + return Encoding.BigEndianUnicode; } catch (ArgumentException) diff --git a/src/EPPlus/Drawing/Chart/ExcelChartSerie.cs b/src/EPPlus/Drawing/Chart/ExcelChartSerie.cs index 27b397c37e..580c003d56 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartSerie.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartSerie.cs @@ -302,7 +302,23 @@ private string GetAddressValue(ExcelAddressBase address) } if (ws != null) { - return ws.Cells[HeaderAddress.Address].Offset(0, 0).Text; + if (HeaderAddress.IsSingleCell) + { + return ws.Cells[HeaderAddress.Address].Offset(0, 0).Text; + } + else + { + var sb = new StringBuilder(); + foreach (var cell in ws.Cells[HeaderAddress.Address]) + { + if (sb.Length != 0) + { + sb.Append(" "); + } + sb.Append(cell.TextMerged); + } + return sb.ToString(); + } } } return ExcelErrorValue.Values.Ref; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index 2789199c67..fd51c489a0 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -59,12 +59,12 @@ internal ChartLegendRenderer(ChartRenderer sc, bool isDataLabelLegend = false) : { case eLegendPosition.Top: case eLegendPosition.Bottom: - _maxWidth = sc.ChartArea.Rectangle.Width * 0.8; + _maxWidth = sc.ChartArea.Rectangle.Width * 0.85; _maxHeight = sc.ChartArea.Rectangle.Height * 0.6; break; default: _maxWidth = sc.ChartArea.Rectangle.Width * 0.6; - _maxHeight = sc.ChartArea.Rectangle.Height * 0.8; + _maxHeight = sc.ChartArea.Rectangle.Height * 0.85; break; } double entryWidth, entryHeight; @@ -150,7 +150,7 @@ private RectRenderItem GetLegendRectangleAndEntrySize(ExcelChartLegend l, out do { widestLine = width + RightMargin; } - width = RightMargin + widest; + width = RightMargin + entryWidth; } else { diff --git a/src/EPPlus/ExcelRangeBase.cs b/src/EPPlus/ExcelRangeBase.cs index 288464982d..d11c845020 100644 --- a/src/EPPlus/ExcelRangeBase.cs +++ b/src/EPPlus/ExcelRangeBase.cs @@ -19,6 +19,7 @@ Date Author Change using OfficeOpenXml.Export.HtmlExport.Interfaces; using OfficeOpenXml.FormulaParsing; using OfficeOpenXml.FormulaParsing.Excel.Functions; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; using OfficeOpenXml.FormulaParsing.LexicalAnalysis; using OfficeOpenXml.Sorting; using OfficeOpenXml.Style; @@ -867,7 +868,24 @@ public string Text } } } - + /// + /// Returns the Text for a merged cell. + /// + internal string TextMerged + { + get + { + if(Merge) + { + var ma = _worksheet.MergedCells[_fromRow, _fromCol]; + if(ma!=null) + { + return _worksheet.Cells[ma].Text; + } + } + return Text; + } + } /// /// Used to add/remove cell pictures in the range /// From d1706a971b7c3d9cd3f822fb46618459455af89f Mon Sep 17 00:00:00 2001 From: AdrianEPPlus <162118292+AdrianEPPlus@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:30:08 +0200 Subject: [PATCH 049/118] Fixed infinite loop in GetTolColumnFromPixels. (#2382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed infinite loop in GetTolColumnFromPixels. Also fixed a crash when saving workbook with groupshape as a shape in a chart. * Fixed positioning of shapes in charts * get top and left no longer returns 0. * fixed From To calculation for chart drawings. * fixed absolute positioning --------- Co-authored-by: Jan Källman --- src/EPPlus/Drawing/ExcelDrawing.cs | 61 +++++++++++-------- src/EPPlus/Drawing/ExcelGroupShape.cs | 2 +- src/EPPlus/ExcelWorksheet.cs | 1 + .../Drawing/Chart/ChartShapeTest.cs | 20 +++++- src/EPPlusTest/Issues/DrawingIssues.cs | 7 +++ src/EPPlusTest/TestBase.cs | 2 +- 6 files changed, 62 insertions(+), 31 deletions(-) diff --git a/src/EPPlus/Drawing/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index 73a182e693..d4c4633e93 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -788,7 +788,7 @@ internal int GetPixelLeft() { return (int)(Math.Round(From.X * _drawings._screenWidth)); } - return 0; + return Position == null ? 0 : Position.X / EMU_PER_PIXEL; } if (CellAnchor == eEditAs.Absolute) { @@ -818,7 +818,7 @@ internal int GetPixelTop() { return (int)(Math.Round(From.Y * _drawings._screenHeight)); } - return 0; + return Position == null ? 0 : Position.Y / EMU_PER_PIXEL; } if (CellAnchor == eEditAs.Absolute) @@ -928,14 +928,7 @@ internal void SetPixelTop(double pixels) _doNotAdjust = true; if (CellAnchor == eEditAs.Absolute) { - if (_collectionType == DrawingsCollectionType.Worksheet) - { - Position.Y = (int)(pixels * EMU_PER_PIXEL); - } - else - { - From.Y= (double)pixels/_drawings._screenHeight; - } + Position.Y = (int)(pixels * EMU_PER_PIXEL); } else { @@ -977,14 +970,7 @@ internal void SetPixelLeft(double pixels) _doNotAdjust = true; if (CellAnchor == eEditAs.Absolute) { - if (_collectionType == DrawingsCollectionType.Worksheet) - { - Position.X = (int)(pixels * EMU_PER_PIXEL); - } - else - { - From.X = (double)pixels / _drawings._screenWidth; - } + Position.X = (int)(pixels * EMU_PER_PIXEL); } else { @@ -1143,7 +1129,7 @@ internal void GetToColumnFromPixels(double pixels, out int col, out int colOff, double pixOff = pixels - (PixelHelper.GetColumnWidth(ws, fromColumn + 1) - fromColumnOff / EMU_PER_PIXEL); double offset = (double)fromColumnOff / EMU_PER_PIXEL + pixels; col = fromColumn + 2; - while (pixOff >= 0) + while (pixOff >= 0 && col < ExcelPackage.MaxColumns) { offset = pixOff; pixOff -= PixelHelper.GetColumnWidth(ws, col++); @@ -1266,6 +1252,11 @@ private void AdjustFromToXY(double x, double y) } else { + if (EditAs != eEditAs.Absolute) + { + To.X = x + To.X - From.X; + To.Y = y + To.Y - From.Y; + } From.X = x; From.Y = y; } @@ -1532,8 +1523,18 @@ private void SetWidthChartShape(double PixelWidth) } if (To != null) { - To.X = (From.X + PixelWidth / _drawings._screenWidth); - if (To.X > 1) To.X = 1; else if (To.X < 0) To.X = 0; + var widthFraction = PixelWidth / _drawings._screenWidth; + To.X = From.X + widthFraction; + if (To.X > 1) + { + To.X = 1; + From.X = Math.Max(0, 1 - widthFraction); + } + else if (To.X < 0) + { + From.X = 0; + To.X = widthFraction; + } } if (Size != null) { @@ -1548,12 +1549,18 @@ private void SetHeightChartShape(double PixelHeight) } if (To != null) { - - - if (To.X > 1) To.X = 1; else if (To.X < 0) To.X = 0; - - To.Y = (From.Y + PixelHeight / _drawings._screenHeight); - if (To.Y > 1) To.Y = 1; else if (To.Y < 0) To.Y = 0; + var heightFraction = PixelHeight / _drawings._screenHeight; + To.Y = From.Y + heightFraction; + if (To.Y > 1) + { + To.Y = 1; + From.Y = Math.Max(0, 1 - heightFraction); + } + else if (To.Y < 0) + { + From.Y = 0; + To.Y = heightFraction; + } } if (Size != null) { diff --git a/src/EPPlus/Drawing/ExcelGroupShape.cs b/src/EPPlus/Drawing/ExcelGroupShape.cs index 2371c414d8..b969dfbef8 100644 --- a/src/EPPlus/Drawing/ExcelGroupShape.cs +++ b/src/EPPlus/Drawing/ExcelGroupShape.cs @@ -120,7 +120,7 @@ private void AdjustXmlAndMoveToGroup(ExcelDrawing d) } else if (d._drawings._collectionType == DrawingsCollectionType.Chart) { - if (d is not ExcelGroupShape) + if (d.ParentGroup is ExcelGroupShape) { d.RemoveFromToNodes(); d.Position = new ExcelDrawingCoordinate(d.NameSpaceManager, d.TopNode, d.GetPositionSize); diff --git a/src/EPPlus/ExcelWorksheet.cs b/src/EPPlus/ExcelWorksheet.cs index ee10b4c0ff..dd507d8bd9 100644 --- a/src/EPPlus/ExcelWorksheet.cs +++ b/src/EPPlus/ExcelWorksheet.cs @@ -2556,6 +2556,7 @@ internal void Save(bool hasLoadedPivotTables) } } } + private void SaveSlicers() { SlicerXmlSources.Save(); diff --git a/src/EPPlusTest/Drawing/Chart/ChartShapeTest.cs b/src/EPPlusTest/Drawing/Chart/ChartShapeTest.cs index bb404751d0..0468899718 100644 --- a/src/EPPlusTest/Drawing/Chart/ChartShapeTest.cs +++ b/src/EPPlusTest/Drawing/Chart/ChartShapeTest.cs @@ -21,6 +21,18 @@ public void ShapeInChart() SaveAndCleanup(p); } + [TestMethod] + public void ShapeInChartTest2() + { + using var p = OpenTemplatePackage("ShapeInChartTest2.xlsx"); + var ws = p.Workbook.Worksheets[0]; + var chart = ws.Drawings[0] as ExcelChartStandard; + var cdr = chart.Drawings[0]; + cdr.SetPosition(150, 200); + cdr.SetSize(200, 200); + + SaveAndCleanup(p); + } [TestMethod] public void ShapeInChartTest() @@ -257,6 +269,7 @@ public void GroupShapesWithPictures() Assert.IsTrue(chart.Drawings.Count == 5); var group = pic1.Group(pic2, pic3, pic4, pic5); Assert.IsTrue(chart.Drawings.Count == 1); + SaveWorkbook("groupshapes2.xlsx", p); } [TestMethod] public void GroupShapesWithGroupShapes() @@ -272,11 +285,14 @@ public void GroupShapesWithGroupShapes() var equal = chart.Drawings.AddShape("Equal", eShapeStyle.MathEqual); var roundRect = chart.Drawings.AddShape("RoundRect", eShapeStyle.Round1Rect); var triangle = chart.Drawings.AddShape("Triangle", eShapeStyle.Triangle); - + Assert.IsTrue(chart.Drawings.Count == 4); var group1 = arrow.Group(equal); var group2 = group1.Group(roundRect, triangle); - Assert.IsTrue(chart.Drawings.Count == 1); + //Assert.IsTrue(chart.Drawings.Count == 1); + group2.SetPosition(100, 100); + //group2.SetPosition(20, 20); + SaveWorkbook("groupshapes1.xlsx", p); } [TestMethod] public void GroupShapesMixed() diff --git a/src/EPPlusTest/Issues/DrawingIssues.cs b/src/EPPlusTest/Issues/DrawingIssues.cs index 8d52b8b5bb..c0c621402c 100644 --- a/src/EPPlusTest/Issues/DrawingIssues.cs +++ b/src/EPPlusTest/Issues/DrawingIssues.cs @@ -216,6 +216,13 @@ public void s1045() Console.WriteLine("Done (no crash)."); SaveAndCleanup(destPackage); } + + [TestMethod] + public void s1055() + { + using var p = OpenTemplatePackage("s1055.xlsx"); + SaveAndCleanup(p); + } } } diff --git a/src/EPPlusTest/TestBase.cs b/src/EPPlusTest/TestBase.cs index 37b30470ac..f73c7f9193 100644 --- a/src/EPPlusTest/TestBase.cs +++ b/src/EPPlusTest/TestBase.cs @@ -249,7 +249,7 @@ protected static void SaveWorkbook(string name, ExcelPackage pck) var fi = new FileInfo(_worksheetPath + name); if (fi.Exists) { - //fi.Delete(); + fi.Delete(); } pck.SaveAs(fi); } From 7d8560e372a2d2b0c601b5b1643d57a78bc364f6 Mon Sep 17 00:00:00 2001 From: AdrianEPPlus <162118292+AdrianEPPlus@users.noreply.github.com> Date: Tue, 16 Jun 2026 07:55:44 +0200 Subject: [PATCH 050/118] Fixed issue where rotated text would partially hide border (#2380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed issue where rotated text would partially hide border * Fixed several bugs in the HTML export. Row height was not set using invariant culture. Pattern fill was incorrect when the color was blank. Border style was not correct for Right and Bottom borders on merged cells * Solved rotated column headers + savefile location --------- Co-authored-by: Jan Källman Co-authored-by: Ossian Edström --- .../HtmlExport/Determinator/StyleChecker.cs | 2 +- .../Internal/HtmlExporterBaseInternal.cs | 45 ++++++++++- .../Translators/CssFillTranslator.cs | 19 ++++- .../Translators/CssTextFormatTranslator.cs | 5 -- .../Export/HtmlExport/RangeExporterTests.cs | 76 ++++++++++++++----- 5 files changed, 115 insertions(+), 32 deletions(-) diff --git a/src/EPPlus/Export/HtmlExport/Determinator/StyleChecker.cs b/src/EPPlus/Export/HtmlExport/Determinator/StyleChecker.cs index 7e92fc704b..7a7b3519dd 100644 --- a/src/EPPlus/Export/HtmlExport/Determinator/StyleChecker.cs +++ b/src/EPPlus/Export/HtmlExport/Determinator/StyleChecker.cs @@ -82,7 +82,7 @@ internal bool ShouldAdd internal bool ShouldAddWithBorders(int bottomStyleId, int rightStyleId) { - if (IsAdded(bottomStyleId, rightStyleId)) + if (IsAdded(bottomStyleId, rightStyleId) && _styleList.Count > 1) { return false; } diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs index e51b64ea2a..550687db2c 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs @@ -16,6 +16,7 @@ Date Author Change using OfficeOpenXml.Export.HtmlExport.Accessibility; using OfficeOpenXml.Export.HtmlExport.HtmlCollections; using OfficeOpenXml.Export.HtmlExport.Parsers; +using OfficeOpenXml.Export.HtmlExport.Settings; using OfficeOpenXml.Export.HtmlExport.Translators; using OfficeOpenXml.Table; using OfficeOpenXml.Utils; @@ -507,7 +508,7 @@ internal void AddRowHeightStyle(HTMLElement element, ExcelRangeBase range, int r { if (rowInternal.Height != -1 && rowInternal.Height != range.Worksheet.DefaultRowHeight) { - element.AddAttribute("style", $"height:{rowInternal.Height}pt"); + element.AddAttribute("style", $"height:{rowInternal.Height.ToString(CultureInfo.InvariantCulture)}pt"); return; } } @@ -731,8 +732,50 @@ internal void GetClassData(HTMLElement element, bool isTable, HtmlImage image, E element.AddChildElement(childHtml); } } + + var textRotation = cell.Style.TextRotation; + if (textRotation != 0 && textRotation != 255 && IsTextRotationExcluded(settings, isHeader) == false) + { + var rotationValue = textRotation > 90 ? textRotation - 90 : 360 - textRotation; + var rotationWrapper = new HTMLElement("div"); + + string rotationStyle = ""; + if (rotationValue == 90 || rotationValue == 270) + { + if (rotationValue > 90) + { + rotationStyle = "writing-mode: sideways-lr;"; + } + else + { + rotationStyle += " writing-mode: sideways-rl;"; + } + } + else + { + rotationStyle = $"$display:inline-block;transform:rotate({rotationValue.ToString(CultureInfo.InvariantCulture)}deg);"; + } + rotationWrapper.AddAttribute("style", $"{rotationStyle}"); + element.AddChildElement(rotationWrapper); + valueElement = rotationWrapper; + } } + private static bool IsTextRotationExcluded(HtmlExportSettings settings, bool isHeader) + { + if (settings is HtmlRangeExportSettings rangeSettings) + { + return rangeSettings.Css.CssExclude.TextRotation; + } + if (settings is HtmlTableExportSettings tableSettings) + { + var exclude = isHeader ? tableSettings.Css.Exclude.TableStyle : tableSettings.Css.Exclude.CellStyle; + return exclude.TextRotation; + } + return false; + } + + public void AddTableDataFromCell(ExcelRangeBase cell, string dataType, HTMLElement element, HtmlExportSettings settings, bool addRowScope, HtmlImage image, ExporterContext content) { if (dataType != ColumnDataTypeManager.HtmlDataTypes.String && settings.RenderDataAttributes) diff --git a/src/EPPlus/Export/HtmlExport/Translators/CssFillTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssFillTranslator.cs index 438a2a3c15..7eb8b10a99 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/CssFillTranslator.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/CssFillTranslator.cs @@ -13,8 +13,11 @@ Date Author Change using OfficeOpenXml.Drawing.Theme; using OfficeOpenXml.Export.HtmlExport.CssCollections; +using OfficeOpenXml.Export.HtmlExport.StyleCollectors; using OfficeOpenXml.Export.HtmlExport.StyleCollectors.StyleContracts; using OfficeOpenXml.Style; +using OfficeOpenXml.Style.Dxf; +using OfficeOpenXml.Utils.TypeConversion; using System.Collections.Generic; using System.Linq; @@ -60,10 +63,18 @@ internal override List GenerateDeclarationList(TranslatorContext co } else { - string bgColor = _fill.GetBackgroundColor(_theme)??"#0"; - string patternColor = _fill.GetPatternColor(_theme)??"#0"; - - var svg = PatternFills.GetPatternSvgConvertedOnly(_fill.PatternType, bgColor, patternColor); + string bgColor, patternColor; + if (_fill is FillDxf) + { + bgColor = _fill.GetBackgroundColor(_theme) ?? "#" + ColorConverter.GetThemeColor(_theme.ColorScheme.GetColorByEnum(Drawing.eThemeSchemeColor.Background1)).ToArgb().ToString("x8").Substring(2); + patternColor = _fill.GetPatternColor(_theme) ?? "#" + ColorConverter.GetThemeColor(_theme.ColorScheme.GetColorByEnum(Drawing.eThemeSchemeColor.Text1)).ToArgb().ToString("x8").Substring(2); + } + else + { + patternColor = _fill.GetBackgroundColor(_theme) ?? "#" + ColorConverter.GetThemeColor(_theme.ColorScheme.GetColorByEnum(Drawing.eThemeSchemeColor.Text1)).ToArgb().ToString("x8").Substring(2); + bgColor = _fill.GetPatternColor(_theme) ?? "#" + ColorConverter.GetThemeColor(_theme.ColorScheme.GetColorByEnum(Drawing.eThemeSchemeColor.Background1)).ToArgb().ToString("x8").Substring(2); + } + var svg = PatternFills.GetPatternSvgConvertedOnly(_fill.PatternType, patternColor, bgColor); AddDeclaration("background-repeat", "repeat"); //arguably some of the values should be its own declaration...Should still work though. AddDeclaration("background", $"url(data:image/svg+xml;base64,{svg})"); diff --git a/src/EPPlus/Export/HtmlExport/Translators/CssTextFormatTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssTextFormatTranslator.cs index 742c67ed26..a9cbefe456 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/CssTextFormatTranslator.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/CssTextFormatTranslator.cs @@ -73,11 +73,6 @@ internal override List GenerateDeclarationList(TranslatorContext co AddDeclaration("writing-mode", "vertical-lr"); AddDeclaration("text-orientation", "upright"); } - else - { - var rotationvalue = _textRotation > 90 ? _textRotation - 90 : 360 - _textRotation; - AddDeclaration("transform", $"rotate({rotationvalue}deg)"); - } } if (_indent > 0 && context.Exclude.Indent == false) { diff --git a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs index 4c8f323d17..98ac450ee3 100644 --- a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs @@ -1,14 +1,15 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using OfficeOpenXml; using OfficeOpenXml.Export.HtmlExport; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; +using OfficeOpenXml.Style; using OfficeOpenXml.Table; using System; using System.Collections.Generic; -using System.IO; using System.Drawing; -using OfficeOpenXml.Style; -using System.Text; using System.Globalization; +using System.IO; +using System.Text; using System.Threading.Tasks; namespace EPPlusTest.Export.HtmlExport @@ -19,7 +20,7 @@ public class RangeExporterTests : TestBase [TestMethod] public void ShouldExportHtmlWithHeadersNoAccessibilityAttributes() { - using(var package = new ExcelPackage()) + using (var package = new ExcelPackage()) { var sheet = package.Workbook.Worksheets.Add("Test"); sheet.Cells["A1"].Value = "Name"; @@ -27,12 +28,12 @@ public void ShouldExportHtmlWithHeadersNoAccessibilityAttributes() sheet.Cells["A2"].Value = "John Doe"; sheet.Cells["B2"].Value = 23; var range = sheet.Cells["A1:B2"]; - using(var ms = new MemoryStream()) + using (var ms = new MemoryStream()) { var exporter = range.CreateHtmlExporter(); - exporter.Settings.Accessibility.TableSettings.AddAccessibilityAttributes=false; + exporter.Settings.Accessibility.TableSettings.AddAccessibilityAttributes = false; exporter.Settings.Culture = new CultureInfo("us-en"); - exporter.RenderHtml(ms); + exporter.RenderHtml(ms); var sr = new StreamReader(ms); ms.Position = 0; var result = sr.ReadToEnd(); @@ -81,12 +82,12 @@ public async Task ShouldExportHtmlWithHeadersWithStyles() var range = sheet.Cells["A1:B2"]; sheet.Cells["A1:B1"].Style.Font.Bold = true; sheet.Cells["A1:B1"].Style.Font.Color.SetColor(Color.Blue); - sheet.Cells["A1:B1"].Style.Border.Bottom.Style=ExcelBorderStyle.Thin; + sheet.Cells["A1:B1"].Style.Border.Bottom.Style = ExcelBorderStyle.Thin; sheet.Cells["A1:B1"].Style.Border.Bottom.Color.SetColor(Color.Red); sheet.Cells["A1:B1"].Style.Fill.PatternType = ExcelFillStyle.LightGray; sheet.Cells["A1:B1"].Style.Fill.BackgroundColor.SetColor(Color.LightCoral); sheet.Cells["A1:B1"].Style.Fill.PatternColor.SetColor(Color.LightCyan); - sheet.Cells["A2:B2"].Style.Font.Italic=true; + sheet.Cells["A2:B2"].Style.Font.Italic = true; sheet.Cells["B1:B2"].Style.Font.Name = "Consolas"; var exporter = range.CreateHtmlExporter(); @@ -95,7 +96,7 @@ public async Task ShouldExportHtmlWithHeadersWithStyles() var result = exporter.GetSinglePage(); var expected = "
NameAge
John Doe23
"; - + Assert.AreEqual(expected, result); var resultAsync = await exporter.GetSinglePageAsync(); Assert.AreEqual(result, resultAsync); @@ -142,7 +143,7 @@ public async Task ShouldExportHtmlWithMergedCells() var resultAsync = await exporter.GetSinglePageAsync(); SaveAndCleanup(package); Assert.AreEqual(result, resultAsync); - + } } [TestMethod] @@ -170,7 +171,7 @@ public void WriteAllsvenskan() exporter.Settings.SetColumnWidth = true; exporter.Settings.SetRowHeight = true; exporter.Settings.Pictures.Include = ePictureInclude.Include; - var html =exporter.GetSinglePage(); + var html = exporter.GetSinglePage(); File.WriteAllText("c:\\temp\\" + sheet.Name + ".html", html); SaveAndCleanup(p); } @@ -180,16 +181,16 @@ public async Task WriteImagesAsync() { using (var p = OpenTemplatePackage("20-CreateAFileSystemReport.xlsx")) { - var sheet = p.Workbook.Worksheets[0]; + var sheet = p.Workbook.Worksheets[0]; var exporter = sheet.Cells["A1:E30"].CreateHtmlExporter(); - + exporter.Settings.SetColumnWidth = true; exporter.Settings.SetRowHeight = true; exporter.Settings.Pictures.Include = ePictureInclude.Include; exporter.Settings.Minify = false; - exporter.Settings.Encoding = Encoding.UTF8; + exporter.Settings.Encoding = Encoding.UTF8; var html = exporter.GetSinglePage(); - var htmlAsync = await exporter.GetSinglePageAsync(); + var htmlAsync = await exporter.GetSinglePageAsync(); File.WriteAllText("c:\\temp\\" + sheet.Name + ".html", html); File.WriteAllText("c:\\temp\\" + sheet.Name + "-async.html", htmlAsync); Assert.AreEqual(html, htmlAsync); @@ -223,7 +224,7 @@ public void ExportMultipleRanges() { var sheet1 = p.Workbook.Worksheets[0]; var sheet2 = p.Workbook.Worksheets[1]; - + var exporter = p.Workbook.CreateHtmlExporter( sheet2.Cells["A1:B13"], sheet2.Cells["A16:B26"], @@ -564,11 +565,11 @@ public void ExportRangeIssue() x.TableId = "asia-toll-free"; }); var css = exporter.GetCssString(); - File.WriteAllText("c:\\temp\\html.html",$"{html1}"); + File.WriteAllText("c:\\temp\\html.html", $"{html1}"); } } - private static void SaveRangeFile(ExcelPackage package, string ws, string address, int headerRows=1) + private static void SaveRangeFile(ExcelPackage package, string ws, string address, int headerRows = 1) { var sheet = package.Workbook.Worksheets[ws]; var range = sheet.Cells[address]; @@ -584,7 +585,7 @@ public void NumberFormatColorShouldCreateCssColor() using (var package = OpenPackage("html_numfRed_text.xlsx", true)) { var wb = package.Workbook; - var aNewWs = wb.Worksheets.Add("NewWs"); + var aNewWs = wb.Worksheets.Add("NewWs"); var range = aNewWs.Cells["A1:A5"]; range.Formula = "ROW()"; @@ -605,6 +606,39 @@ public void NumberFormatColorShouldCreateCssColor() SaveAndCleanup(package); } } + + [TestMethod] + public void S1053() + { + { + using var p = OpenTemplatePackage("R05.xlsx"); + var ws = p.Workbook.Worksheets[0]; + + var range = ws.Cells["A1:AA7"]; + var exporter = range.CreateHtmlExporter(); + var settings = exporter.Settings; + settings.SetRowHeight = true; + settings.SetColumnWidth = true; + settings.HeaderRows = 3; + settings.Encoding = Encoding.UTF8; + var page = exporter.GetSinglePage(); + var file = GetOutputFile("html", "R05" + ".html"); + File.WriteAllText(file.FullName, page, Encoding.UTF8); + } + { + using var p = OpenTemplatePackage("CR168.xlsx"); + var ws = p.Workbook.Worksheets[0]; + + var range = ws.Cells["A1:AA36"]; + var exporter = range.CreateHtmlExporter(); + var settings = exporter.Settings; + settings.SetRowHeight = true; + settings.SetColumnWidth = true; + var page = exporter.GetSinglePage(); + var file = GetOutputFile("html", "CR168" + ".html"); + File.WriteAllText(file.FullName, page, Encoding.UTF8); + } + } + } } - \ No newline at end of file From fc635bb0ad46a61cd30029b0cf89bfce7dbe9103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 16 Jun 2026 08:00:47 +0200 Subject: [PATCH 051/118] Fixed some issues on chart axis and legends. --- .../Chart/ErrorbarsTests.cs | 2 +- src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs | 5 +++-- .../Drawing/Renderer/Chart/ChartLegendRenderer.cs | 10 +++++----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs index db5a3b97bd..5b67b2c388 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs @@ -16,7 +16,7 @@ public void GenerateSvgForErrorbars_Sheet1() //var ix = 4; //var c = ws.Drawings[ix]; //var svg = c.ToSvg(); - //SaveTextFileToWorkbook($"svg\\Trendline_sheet1_ind{ix++}.svg", svg); + //SaveTextFileToWorkbook($"svg\\Error_sheet1_ind{ix++}.svg", svg); var ix = 0; foreach (ExcelChart c in ws.Drawings) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 8eefdf9cf8..a99a7c4495 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -548,16 +548,17 @@ private double GetAxisItemLeft(int i, OfficeOpenXml.Interfaces.Drawing.Text.Text { if (IsCatAx()) { - double majorWidth; + double majorWidth, majorHalf=0D; if (Axis.CrossingAxis == null || Axis.CrossingAxis.CrossBetween == eCrossBetween.Between) { majorWidth = Rectangle.Width / AxisValues.Count; + majorHalf = majorWidth / 2; } else { majorWidth = Rectangle.Width / (AxisValues.Count - 1); } - var majorTickStartingPosition = Rectangle.Left + majorWidth * i; + var majorTickStartingPosition = Rectangle.Left + majorWidth * i + majorHalf; return majorTickStartingPosition; } else diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index fd51c489a0..a0db0fd7bf 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -326,10 +326,10 @@ internal DrawingLegendSerie SetLegendSeries(double entryWidth, double entryHeigh if (Chart.Legend.Position == eLegendPosition.Top || Chart.Legend.Position == eLegendPosition.Bottom) { - //if (sls.Textbox.Bounds.Bottom > Rectangle.Bottom) - //{ - // break; - //} + if (sls.Textbox.Bounds.Bottom > Rectangle.Bottom) + { + break; + } } else { @@ -631,7 +631,7 @@ private double GetItemPosition(DrawingLegendSerie pSls, double entryWidth, doubl if (Chart.Legend.Position == eLegendPosition.Top || Chart.Legend.Position == eLegendPosition.Bottom) { - if (pSls != null && pSls.Textbox.Bounds.Right + entryWidth + RightMargin > _maxWidth) + if (pSls != null && iconLeft + entryWidth * 2 + _marginItemsWidth + RightMargin > _maxWidth) { topOffset += entryHeight * 1.25; x = LeftMargin; From ade4d16dd1b4b65f7b5fa98d8068186f1c20b843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 16 Jun 2026 09:36:49 +0200 Subject: [PATCH 052/118] Partial fix for datalabels --- .../Chart/DataLabels/SvgDataLabelPoint.cs | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index f8b11e96f3..f93b3766db 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -143,15 +143,15 @@ internal void ImportDataLabel(ExcelChartStandardSerie serie, ExcelChartDataLabel _txtBox = txtBox; - _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); - //if (dataLabel.Fill.IsEmpty == false) - //{ - // _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); - //} - //else - //{ - // _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); - //} + if (dataLabel.Fill.IsEmpty == false) + { + _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); + } + else + { + _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); + _txtBox.Rectangle.FillColor = "transparent"; + } if (dataLabel.Font.IsEmpty == false) { txtBox.TextBody.FontColorString = "#" + dataLabel.Font.Color.ToColorString(); @@ -172,12 +172,20 @@ internal void ImportDataLabel(ExcelChartStandardSerie serie, ExcelChartDataLabel { _hasManualLayout = true; var rect = GetRectFromManualLayout(ChartRenderer, individualLabel.Layout); - Rectangle = rect; - _manualLayoutOffset = new Coordinate(Rectangle.Left, Rectangle.Top); + Rectangle.Bounds.Left += rect.Left; + Rectangle.Bounds.Top += rect.Top; + + _manualLayoutOffset = new Coordinate(rect.Left, rect.Top); - Rectangle.Bounds.Left += _manualLayoutOffset.X; - Rectangle.Bounds.Top += _manualLayoutOffset.Y; + if (rect.Bounds.Width != 0) + { + Rectangle.Bounds.Width = rect.Bounds.Width; + } + if (rect.Bounds.Height != 0) + { + Rectangle.Bounds.Height = rect.Bounds.Height; + } if (dataLabel.ShowLeaderLines) { From f1e5ea091f0aa71101db95e8e7336f3d105f451c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 16 Jun 2026 10:10:44 +0200 Subject: [PATCH 053/118] EPPlus version 8.6.1 --- appveyor8.yml | 10 ++++---- docs/articles/fixedissues.md | 22 +++++++++++++++++ src/EPPlus/Drawing/ExcelDrawing.cs | 4 ---- src/EPPlus/EPPlus.csproj | 24 ++++++++++++------- .../Translators/CssFillTranslator.cs | 1 + 5 files changed, 43 insertions(+), 18 deletions(-) diff --git a/appveyor8.yml b/appveyor8.yml index d400dad105..164cb73a0b 100644 --- a/appveyor8.yml +++ b/appveyor8.yml @@ -1,4 +1,4 @@ -version: 8.6.0.{build} +version: 8.6.1.{build} branches: only: - develop8 @@ -10,15 +10,15 @@ install: & $env:temp\dotnet-install.ps1 -Architecture x64 -Version '10.0.100' -InstallDir "$env:ProgramFiles\dotnet" init: - ps: >- - Update-AppveyorBuild -Version "8.6.0.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" + Update-AppveyorBuild -Version "8.6.1.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" - Write-Host "8.6.0.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" + Write-Host "8.6.1.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" dotnet_csproj: patch: true file: '**\*.csproj' version: '{version}' - assembly_version: 8.6.0.{build} - file_version: 8.6.0.{build} + assembly_version: 8.6.1.{build} + file_version: 8.6.1.{build} nuget: project_feed: true before_build: diff --git a/docs/articles/fixedissues.md b/docs/articles/fixedissues.md index 7791e8728f..72b47d5c0b 100644 --- a/docs/articles/fixedissues.md +++ b/docs/articles/fixedissues.md @@ -1,5 +1,27 @@ # Features / Fixed issues - EPPlus 8 +## Version 8.6 1 +### Features +* 3 new functions: + * ´REGEXEXTRACT´ + * ´REGEXREPLACE´ + * ´REGEXTEST´ +### Fixed issues +* Fixed mismatch in conditional formatting when loading a workbook. +* Fixed several issues in the HTML export + * Text rotated vertically in cells now adjusts column widths more appropriately. + * Borders sometimes disappeared when having rotate the text. + * Fixed bug where the positions/visuals of images would sometimes not appear. + * Pattern fills with empty colors did not pick the correct theme color. + * Merged cells did not render border correctly on the right and bottom edges. +* Workbooks with group shapes in charts sometimes failed to load. +* Images in charts sometimes got stretched. +* ´ExcelRangeBase.ClearDataValidation´ threw an ´InvalidOperationException´ if when there were no data validations. +* Fixed case-sensitivity parsing on ´ExcelChartAxisStandard´ enums, ´MajorTickMark´ and ´MinorTickMark´ (Fix by Lieven De Foor). +* ´ExcelWorksheets.Delete´ threw ´NotSupportedException´ on chart worksheets. (Fix by Lieven De Foor). +* Using full row/column addresses in formulas referencing external workbooks caused bad performance. +* The ´VLOOKUP´ and ´HLOOKUP´ function sometimes returned the wrong value on unsorted data. + ## Version 8.6 0 ### Features * 9 new functions: diff --git a/src/EPPlus/Drawing/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index d4c4633e93..4578e3dab4 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -15,10 +15,7 @@ Date Author Change using OfficeOpenXml.Drawing.Controls; using OfficeOpenXml.Drawing.OleObject; using OfficeOpenXml.Drawing.Slicer; -using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; -using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; using OfficeOpenXml.Packaging; -using OfficeOpenXml.Utils; using OfficeOpenXml.Utils.Drawings; using OfficeOpenXml.Utils.EnumUtils; using OfficeOpenXml.Utils.FileUtils; @@ -27,7 +24,6 @@ Date Author Change using System.Globalization; using System.IO; using System.Linq; -using System.Security.Cryptography; using System.Text; using System.Xml; diff --git a/src/EPPlus/EPPlus.csproj b/src/EPPlus/EPPlus.csproj index 4d67946974..01a63f80f3 100644 --- a/src/EPPlus/EPPlus.csproj +++ b/src/EPPlus/EPPlus.csproj @@ -1,9 +1,9 @@  net8.0;net9.0;net10.0;netstandard2.1;netstandard2.0;net462;net35 - 8.6.0.0 - 8.6.0.0 - 8.6.0 + 8.6.1.0 + 8.6.1.0 + 8.6.1 true https://epplussoftware.com EPPlus Software AB @@ -18,7 +18,7 @@ readme.md EPPlus Software AB - EPPlus 8.6.0 + EPPlus 8.6.1 IMPORTANT NOTICE! From version 5 EPPlus changes the license model using a dual license, Polyform Non Commercial / Commercial license. @@ -26,11 +26,16 @@ Commercial licenses can be purchased from https://epplussoftware.com This applies to EPPlus version 5 and later. Earlier versions are still licensed LGPL. + ## Version 8.6.1 + * New functions: + * REGEXEXTRACT, REGEXREPLACE, REGEXTEST + * Minor bug fixes. See https://epplussoftware.com/Developers/MinorFeaturesAndIssues + ## Version 8.6.0 * New functions: * GROUPBY, PIVOTBY, TRIMRANGE, WRAPROWS, WRAPCOLS, USDOLLAR, CODE, ENCODEURL, ISFORMULA * Support for [Trim reference operator](https://github.com/EPPlusSoftware/EPPlus/wiki/Trim-reference-operator). - * Minor bug fixes. See https://epplussoftware.com/Developers/MinorFeaturesAndIssues + * Minor bug fixes. ## Version 8.5.4 * Minor bug fixes. @@ -566,16 +571,17 @@ * Pictures have changed the behavior as the oneCellAnchor tag is used instead of the twoCellAnchor tag with the editAs="oneCell". * Pivot tables will always refresh on load. * Adding and inserting table row will now copy styles from the row above. - * ...and more, see https://github.com/EPPlusSoftware/EPPlus/wiki/Breaking-Changes-in-EPPlus-7 + * ...and more, see https://github.com/EPPlusSoftware/EPPlus/wiki/Breaking-Changes-in-EPPlus-8 Worksheets collection behavior in .NET Framework The default behavior for the Worksheet collection base in .NET Framework has changed from 1 to 0. This is the same behavior as in .NET Core. For backward compatibility the property IsWorksheets1Based can be set to true, to have the same behavior as in previous version of EPPlus. This property can also be set in the app.config. - For more details see https://github.com/EPPlusSoftware/EPPlus/wiki/Breaking-Changes-in-EPPlus-7 - A list of fixed issues can be found here https://epplussoftware.com/docs/7.0/articles/fixedissues.html + For more details see https://github.com/EPPlusSoftware/EPPlus/wiki/Breaking-Changes-in-EPPlus-8 + A list of fixed issues can be found here https://epplussoftware.com/docs/8.6/articles/fixedissues.html Version history - 8.6.0 20260529 9 new functions. Support for trim Reference operator. See https://epplussoftware.com/Developers/MinorFeaturesAndIssues + 8.6.1 20260616 3 new functions. Minor bug fixes. See https://epplussoftware.com/Developers/MinorFeaturesAndIssues + 8.6.0 20260529 9 new functions. Support for trim Reference operator. 8.5.4 20260430 Minor bug fixes. 8.5.3 20260416 Updated .NET 8 references incorrectly update to 9.x to 8.x. 8.5.2 20260416 Minor bug fixes. diff --git a/src/EPPlus/Export/HtmlExport/Translators/CssFillTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssFillTranslator.cs index 7eb8b10a99..ddd6d19d25 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/CssFillTranslator.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/CssFillTranslator.cs @@ -64,6 +64,7 @@ internal override List GenerateDeclarationList(TranslatorContext co else { string bgColor, patternColor; + //EPPlus has background and pattern fills swiched for xfs styles, so we need separate handling for them. if (_fill is FillDxf) { bgColor = _fill.GetBackgroundColor(_theme) ?? "#" + ColorConverter.GetThemeColor(_theme.ColorScheme.GetColorByEnum(Drawing.eThemeSchemeColor.Background1)).ToArgb().ToString("x8").Substring(2); From da782617fd4f255a5277850c5619649f02bff72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 16 Jun 2026 10:23:45 +0200 Subject: [PATCH 054/118] Fixed background for datalabels --- .../Chart/LineChartToSvgTests.cs | 18 ++++++++--------- .../Svg/SvgGroupRenderer.cs | 9 ++++++++- .../DataLabels/ChartSerieDataLabelRenderer.cs | 5 ++++- .../Chart/DataLabels/SvgDataLabelPoint.cs | 20 ++++++++++--------- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index 44f5074ad0..17159bda53 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -174,16 +174,16 @@ public void GenerateSvgForCharts_SecondaryAxis_sheet3() using (var p = OpenTemplatePackage("ChartForSvg_SecondaryAxis.xlsx")) { var ws = p.Workbook.Worksheets[2]; - var ix = 1; - var c = ws.Drawings[ix]; - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\ChartForSvg_sheet3_{ix++}.svg", svg); //var ix = 1; - //foreach (ExcelChart c in ws.Drawings) - //{ - // var svg = c.ToSvg(); - // SaveTextFileToWorkbook($"svg\\ChartForSvg_SecAxis{ix++}.svg", svg); - //} + //var c = ws.Drawings[ix]; + //var svg = c.ToSvg(); + //SaveTextFileToWorkbook($"svg\\ChartForSvg_sheet3_{ix++}.svg", svg); + var ix = 1; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\ChartForSvg_Sheet3_SecAxis{ix++}.svg", svg); + } } } diff --git a/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs b/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs index d2216dd0f3..2ac6ac7a2a 100644 --- a/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs +++ b/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs @@ -20,7 +20,14 @@ public override void Render(GroupRenderItem item) { string combinedTransform = GetCombinedTransformString(item); - OutputStream.Append($""); + //Neccesary as fallback for e.g. DataLabels + string fillPropery = ""; + if (string.IsNullOrEmpty(item.FillColor) == false) + { + fillPropery = $" fill=\"{item.FillColor}\" "; + } + + OutputStream.Append($""); foreach (var childItem in item.RenderItems) { diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs index 7078647db1..b2ef35241e 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs @@ -35,7 +35,6 @@ public ChartSerieDataLabelRenderer(ChartRenderer chart, ExcelChartSerieDataLabel _defaultMargins = new BoundingBox(l, top, right, bottom); } - if (dlblSerie.DataLabels.Count == 0 && serie.NumberOfItems > 0) { for (int i = 0; i < serie.NumberOfItems; i++) @@ -142,6 +141,10 @@ public override void AppendRenderItems(List renderItems) if (_dlblSerie.Fill.IsEmpty == false) { + //Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); + //plotAreaGroup.AddChildItem(Rectangle); + Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); + plotAreaGroup.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); plotAreaGroup.GroupTransform += $" fill=\"{plotAreaGroup.FillColor}\" name=\"Plot area group\""; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index f93b3766db..ad69e6df3b 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -147,11 +147,12 @@ internal void ImportDataLabel(ExcelChartStandardSerie serie, ExcelChartDataLabel { _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); } - else - { - _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); - _txtBox.Rectangle.FillColor = "transparent"; - } + //else + //{ + // _txtBox.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, dataLabel.Fill, null); + // _txtBox.Rectangle.FillColor = "transparent"; + //} + if (dataLabel.Font.IsEmpty == false) { txtBox.TextBody.FontColorString = "#" + dataLabel.Font.Color.ToColorString(); @@ -476,16 +477,17 @@ public override void AppendRenderItems(List renderItems) parentPointGroup.Top = _parentPoint.Top; var titleItemOrigin = new TitleRenderItem("DataLabel originpoint"); - renderItems.Add(titleItemOrigin); + parentPointGroup.AddChildItem(titleItemOrigin); renderItems.Add(parentPointGroup); - var titleItem = new TitleRenderItem("DataLabel size adjustment"); - parentPointGroup.RenderItems.Add(titleItem); - var group = new GroupRenderItem(Rectangle.Bounds); group.Left = Rectangle.Bounds.Left; group.Top = Rectangle.Bounds.Top; + + var titleItem = new TitleRenderItem("DataLabel size adjustment"); + group.AddChildItem(titleItem); + parentPointGroup.RenderItems.Add(group); _txtBox.AppendRenderItems(group.RenderItems); From b2ac3908f3cce1070fae26d5528df640d97d02f1 Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:15:22 +0200 Subject: [PATCH 055/118] Removed some warnings, mostly due to usage of ExpectedException in UnitTests --- .../Text/RegexReplacementExpander.cs | 2 +- .../Core/Range/Delete/RangeDeleteTests.cs | 96 +++++++++++-------- .../Core/Range/Insert/RangeInsertTests.cs | 54 +++++------ src/EPPlusTest/Utils/GuardingTests.cs | 12 +-- 4 files changed, 87 insertions(+), 77 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplacementExpander.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplacementExpander.cs index 18363f6c0c..7528de0a35 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplacementExpander.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/RegexReplacementExpander.cs @@ -26,7 +26,7 @@ namespace OfficeOpenXml.FormulaParsing.Excel.Functions.Text /// Literals: /// $$ -> literal $ /// \\ -> literal \ - /// \ + non-letter -> the literal character (e.g. \, \- \. \$ \) \) + /// \ + non-letter -> the literal character (e.g. \, \- \. \$ \) \<space>) /// Character escapes (-> the corresponding character): /// \n \t \r \f \a \v \e \b, \cX (control), \xNN (hex) /// \u is NOT supported and is silently dropped (Excel quirk: \u0041 -> "0041") diff --git a/src/EPPlusTest/Core/Range/Delete/RangeDeleteTests.cs b/src/EPPlusTest/Core/Range/Delete/RangeDeleteTests.cs index 5b3d70d5e5..ba421d75cf 100644 --- a/src/EPPlusTest/Core/Range/Delete/RangeDeleteTests.cs +++ b/src/EPPlusTest/Core/Range/Delete/RangeDeleteTests.cs @@ -1,14 +1,40 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +/******************************************************************************* + * You may amend and distribute as you like, but don't remove this header! + * + * Required Notice: Copyright (C) EPPlus Software AB. + * https://epplussoftware.com + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Lesser General Public License for more details. + * + * The GNU Lesser General Public License can be viewed at http://www.opensource.org/licenses/lgpl-license.php + * If you unfamiliar with this license or have questions about it, here is an http://www.gnu.org/licenses/gpl-faq.html + * + * All code and executables are provided "" as is "" with no warranty either express or implied. + * The author accepts no liability for any damage or loss of business that this product may cause. + * + * Code change notes: + * + Date Author Change + ******************************************************************************* + 01/27/2020 EPPlus Software AB Initial release EPPlus 5 + *******************************************************************************/ +using System; +using System.Text; +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; using OfficeOpenXml; using OfficeOpenXml.ConditionalFormatting.Contracts; using OfficeOpenXml.Drawing; -using System; -using System.Collections.Generic; using System.Drawing; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; namespace EPPlusTest.Core.Range.Delete { @@ -321,7 +347,7 @@ public void ValidateValuesAfterDeleteRowInRangeShiftUp() { //Setup var ws = _pck.Workbook.Worksheets.Add("DeleteRangeDown"); - SetValues(ws,3); + SetValues(ws, 3); //Act ws.Cells["B2"].Delete(eShiftTypeDelete.Up); @@ -359,7 +385,7 @@ public void ValidateValuesAfterDeleteRowInRangeShiftLeft() //Act 2 ws.Cells["A1:B1"].Delete(eShiftTypeDelete.Left); - + //Assert 2 Assert.AreEqual("C1", ws.Cells["A1"].Value); Assert.IsNull(ws.Cells["B1"].Value); @@ -381,7 +407,7 @@ public void ValidateValuesAfterDeleteInRangeShiftUpTwoRows() AssertIsNull(ws.Cells["B3:C4"]); Assert.AreEqual("B3", ws.Cells["B1"].Value); - Assert.AreEqual("B4", ws.Cells["B2"].Value); + Assert.AreEqual("B4", ws.Cells["B2"].Value); Assert.AreEqual("C3", ws.Cells["C1"].Value); Assert.AreEqual("C4", ws.Cells["C2"].Value); } @@ -527,7 +553,7 @@ public void ValidateSharedFormulasDeleteShiftUp() //Assert Assert.AreEqual("A2", ws.Cells["B1"].Formula); - Assert.AreEqual("",ws.Cells["B2"].Formula); + Assert.AreEqual("", ws.Cells["B2"].Formula); Assert.AreEqual("#REF!", ws.Cells["C1"].Formula); Assert.AreEqual("C1", ws.Cells["D1"].Formula); Assert.AreEqual("A1", ws.Cells["C3"].Formula); @@ -589,25 +615,23 @@ public void ValidateDeleteMergedCellsLeft() } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateDeleteIntoMergedCellsPartialLeftThrowsException() { using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("MergedCells"); ws.Cells["B2:D3"].Merge = true; - ws.Cells["A2"].Delete(eShiftTypeDelete.Left); + Assert.ThrowsExactly(() => ws.Cells["A2"].Delete(eShiftTypeDelete.Left)); } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateDeleteIntoMergedCellsPartialUpThrowsException() { using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("MergedCells"); ws.Cells["B2:D3"].Merge = true; - ws.Cells["C1"].Delete(eShiftTypeDelete.Up); + Assert.ThrowsExactly(() => ws.Cells["C1"].Delete(eShiftTypeDelete.Up)); } } [TestMethod] @@ -680,25 +704,23 @@ public void ValidateDeleteMergedCellsShouldBeNull() } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateDeleteFromTablePartialLeftThrowsException() { using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("TableDelete"); ws.Tables.Add(ws.Cells["B2:D3"], "table1"); - ws.Cells["A2"].Delete(eShiftTypeDelete.Left); + Assert.ThrowsExactly(() => ws.Cells["A2"].Delete(eShiftTypeDelete.Left)); } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateDeleteFromTablePartialUpThrowsException() { using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("TableDelete"); ws.Tables.Add(ws.Cells["B2:D3"], "table1"); - ws.Cells["C1"].Delete(eShiftTypeDelete.Up); + Assert.ThrowsExactly(() => ws.Cells["C1"].Delete(eShiftTypeDelete.Up)); } } [TestMethod] @@ -722,7 +744,6 @@ public void ValidateDeleteFromTablePartialUpShouldNotThrowsException() } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateDeleteFromPivotTablePartialLeftThrowsException() { using (var p = new ExcelPackage()) @@ -731,11 +752,10 @@ public void ValidateDeleteFromPivotTablePartialLeftThrowsException() ws.Cells["E5"].Value = "E5"; ws.Cells["F5"].Value = "F5"; ws.PivotTables.Add(ws.Cells["B2:D3"], ws.Cells["E5:F6"], "table1"); - ws.Cells["A2"].Delete(eShiftTypeDelete.Left); + Assert.ThrowsExactly(() => ws.Cells["A2"].Delete(eShiftTypeDelete.Left)); } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateDeleteFromPivotTablePartialUpThrowsException() { using (var p = new ExcelPackage()) @@ -744,7 +764,7 @@ public void ValidateDeleteFromPivotTablePartialUpThrowsException() ws.Cells["E5"].Value = "E5"; ws.Cells["F5"].Value = "F5"; ws.PivotTables.Add(ws.Cells["B2:D3"], ws.Cells["E5:F6"], "table1"); - ws.Cells["C1"].Delete(eShiftTypeDelete.Up); + Assert.ThrowsExactly(() => ws.Cells["C1"].Delete(eShiftTypeDelete.Up)); } } [TestMethod] @@ -1157,7 +1177,7 @@ public void ValidateFilterShiftLeft() ws.AutoFilter.Address = new ExcelAddressBase("B1:E100"); ws.Cells["A1:A100"].Delete(eShiftTypeDelete.Left); Assert.AreEqual("A1:D100", ws.AutoFilter.Address.Address); - ws.Cells["C1:C100"].Delete(eShiftTypeDelete.Left); + ws.Cells["C1:C100"].Delete(eShiftTypeDelete.Left); Assert.AreEqual("A1:C100", ws.AutoFilter.Address.Address); } [TestMethod] @@ -1193,7 +1213,6 @@ public void ValidateFilterDeleteColumn() } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateFilterShiftUpPartial() { using (var p = new ExcelPackage()) @@ -1201,11 +1220,10 @@ public void ValidateFilterShiftUpPartial() var ws = p.Workbook.Worksheets.Add("AutoFilterShiftUpPart"); LoadTestdata(ws); ws.AutoFilter.Address = new ExcelAddressBase("A1:D100"); - ws.Cells["A1:C1"].Delete(eShiftTypeDelete.Up); + Assert.ThrowsExactly(() => ws.Cells["A1:C1"].Delete(eShiftTypeDelete.Up)); } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateFilterShiftLeftPartial() { using (var p = new ExcelPackage()) @@ -1213,7 +1231,7 @@ public void ValidateFilterShiftLeftPartial() var ws = p.Workbook.Worksheets.Add("AutoFilterShiftLeftPart"); LoadTestdata(ws); ws.AutoFilter.Address = new ExcelAddressBase("A1:D100"); - ws.Cells["A1:A99"].Delete(eShiftTypeDelete.Left); + Assert.ThrowsExactly(() => ws.Cells["A1:A99"].Delete(eShiftTypeDelete.Left)); } } [TestMethod] @@ -1355,12 +1373,12 @@ public void TestDeleteColumnsWithConditionalFormatting() [TestMethod] public void ValidateDeleteColumnFixedAddresses() { - using(var p=new ExcelPackage()) + using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("Sheet1"); ws.Names.Add("TestName1", ws.Cells["$A$1"]); - ws.Names.Add("TestName2", ws.Cells["$B$1"]); - ws.Names.Add("TestName3", ws.Cells["$C$1"]); + ws.Names.Add("TestName2", ws.Cells["$B$1"]); + ws.Names.Add("TestName3", ws.Cells["$C$1"]); ws.Names.Add("TestName4", ws.Cells["$B$3:$D$3"]); ws.Names.Add("TestName5", ws.Cells["$A$5:$C$5"]); ws.Names.Add("TestName6", ws.Cells["$B$7:$C$7"]); @@ -1521,17 +1539,17 @@ public void DeleteEntireRowAndShiftFormulas() Assert.AreEqual(2d, sheet.Cells["A2"].Value, "Row 3 was not correctly shifted to 2"); Assert.AreEqual(3d, sheet.Cells["A3"].Value, "Row 4 was not correctly shifted to 3"); } - [TestMethod] - public void DeleteRowValidateArrayFormula() - { - using var package = new ExcelPackage(); - var sheet = package.Workbook.Worksheets.Add("Sheet 1"); - sheet.Cells["A2"].Formula = "XLOOKUP($A$3,$B:$B,$C:$C)"; + [TestMethod] + public void DeleteRowValidateArrayFormula() + { + using var package = new ExcelPackage(); + var sheet = package.Workbook.Worksheets.Add("Sheet 1"); + sheet.Cells["A2"].Formula = "XLOOKUP($A$3,$B:$B,$C:$C)"; sheet.Calculate(); sheet.DeleteRow(1); Assert.AreEqual("XLOOKUP($A$2,$B:$B,$C:$C)", sheet.Cells["A1"].Formula); - } + } [TestMethod] //Part of s912 performance fix public void EnsureDeleteColumnsWorksWithColumnLookup() @@ -1589,4 +1607,4 @@ public void EnsureDeleteColumnsWorksWithColumnLookup() } } -} +} \ No newline at end of file diff --git a/src/EPPlusTest/Core/Range/Insert/RangeInsertTests.cs b/src/EPPlusTest/Core/Range/Insert/RangeInsertTests.cs index 0543b7395d..4001d0dcdf 100644 --- a/src/EPPlusTest/Core/Range/Insert/RangeInsertTests.cs +++ b/src/EPPlusTest/Core/Range/Insert/RangeInsertTests.cs @@ -39,10 +39,10 @@ public void ValidateFormulasAfterInsertRow() ws.Cells["B1:B2"].Formula = "Sum(C5:C10)"; ws2.Cells["A1"].Formula = "Sum(InsertRow_Sheet1!C5:C10)"; ws2.Cells["B1:B2"].Formula = "Sum(InsertRow_Sheet1!C5:C10)"; - ws2.Cells["A2"].Formula = "Sum(InsertRow_Sheet1!#REF!)"; + ws2.Cells["A2"].Formula = "Sum(InsertRow_Sheet1!#REF!)"; - //Act - ws.InsertRow(3, 1); + //Act + ws.InsertRow(3, 1); //Assert Assert.AreEqual(0, ws._sharedFormulas.Count); @@ -54,9 +54,9 @@ public void ValidateFormulasAfterInsertRow() Assert.AreEqual("Sum(InsertRow_Sheet1!C6:C11)", ws2.Cells["A1"].Formula); Assert.AreEqual("Sum(InsertRow_Sheet1!C6:C11)", ws2.Cells["B1"].Formula); Assert.AreEqual("Sum(InsertRow_Sheet1!C7:C12)", ws2.Cells["B2"].Formula); - Assert.AreEqual("Sum(InsertRow_Sheet1!#REF!)", ws2.Cells["A2"].Formula); - } - [TestMethod] + Assert.AreEqual("Sum(InsertRow_Sheet1!#REF!)", ws2.Cells["A2"].Formula); + } + [TestMethod] public void ValidateFormulasAfterInsert2Rows() { //Setup @@ -291,7 +291,7 @@ public void ValidateCommentsAfterInsertShiftRight() { //Setup var ws = _pck.Workbook.Worksheets.Add("InsertRangeCommentsRight"); - ws.Cells["A1"].AddComment("Comment A1", "EPPlus"); + ws.Cells["A1"].AddComment("Comment A1", "EPPlus"); ws.Cells["B1"].AddComment("Comment B1", "EPPlus"); ws.Cells["C1"].AddComment("Comment C1", "EPPlus"); @@ -450,25 +450,23 @@ public void ValidateInsertMergedCellsRight() } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateInsertIntoMergedCellsPartialRightThrowsException() { using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("MergedCells"); ws.Cells["B2:D3"].Merge = true; - ws.Cells["A2"].Insert(eShiftTypeInsert.Right); + Assert.ThrowsExactly(() => ws.Cells["A2"].Insert(eShiftTypeInsert.Right)); } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateInsertIntoMergedCellsPartialDownThrowsException() { using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("MergedCells"); ws.Cells["B2:D3"].Merge = true; - ws.Cells["C1"].Insert(eShiftTypeInsert.Down); + Assert.ThrowsExactly(() => ws.Cells["C1"].Insert(eShiftTypeInsert.Down)); } } [TestMethod] @@ -532,25 +530,23 @@ public void ValidateInsertIntoMergedCellsPartialDownShouldNotThrowsException() } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateInsertIntoTablePartialRightThrowsException() { using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("TableDelete"); ws.Tables.Add(ws.Cells["B2:D3"], "table1"); - ws.Cells["A2"].Insert(eShiftTypeInsert.Right); + Assert.ThrowsExactly(() => ws.Cells["A2"].Insert(eShiftTypeInsert.Right)); } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateInsertIntoTablePartialDownThrowsException() { using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("TableDelete"); ws.Tables.Add(ws.Cells["B2:D3"], "table1"); - ws.Cells["C1"].Insert(eShiftTypeInsert.Down); + Assert.ThrowsExactly(() => ws.Cells["C1"].Insert(eShiftTypeInsert.Down)); } } [TestMethod] @@ -574,7 +570,6 @@ public void ValidateInsertIntoTablePartialDownShouldNotThrowsException() } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateInsertIntoPivotTablePartialRightThrowsException() { using (var p = new ExcelPackage()) @@ -583,11 +578,10 @@ public void ValidateInsertIntoPivotTablePartialRightThrowsException() ws.Cells["E5"].Value = "E5"; ws.Cells["F5"].Value = "F5"; ws.PivotTables.Add(ws.Cells["B2:D3"], ws.Cells["E5:F6"], "table1"); - ws.Cells["A2"].Insert(eShiftTypeInsert.Right); + Assert.ThrowsExactly(() => ws.Cells["A2"].Insert(eShiftTypeInsert.Right)); } } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateInsertIntoPivotTablePartialDownThrowsException() { using (var p = new ExcelPackage()) @@ -596,7 +590,7 @@ public void ValidateInsertIntoPivotTablePartialDownThrowsException() ws.Cells["E5"].Value = "E5"; ws.Cells["F5"].Value = "F5"; ws.PivotTables.Add(ws.Cells["B2:D3"], ws.Cells["E5:F6"], "table1"); - ws.Cells["C1"].Insert(eShiftTypeInsert.Down); + Assert.ThrowsExactly(() => ws.Cells["C1"].Insert(eShiftTypeInsert.Down)); } } [TestMethod] @@ -629,7 +623,7 @@ public void ValidateInsertTableShouldShiftDown() using (var p = new ExcelPackage()) { var ws = p.Workbook.Worksheets.Add("TableInsertShiftDown"); - var tbl=ws.Tables.Add(ws.Cells["B2:D3"], "table1"); + var tbl = ws.Tables.Add(ws.Cells["B2:D3"], "table1"); ws.Cells["B2:D2"].Insert(eShiftTypeInsert.Down); Assert.AreEqual("B3:D4", tbl.Address.Address); @@ -674,8 +668,8 @@ public void ValidateInsertPivotTableShouldShiftDown() { var ws = p.Workbook.Worksheets.Add("PivotTableInsertShiftDown"); ws.Cells["E5"].Value = "E5"; - ws.Cells["F5"].Value = "F5"; - var pt=ws.PivotTables.Add(ws.Cells["B2:D3"], ws.Cells["E5:F6"], "pivottable1"); + ws.Cells["F5"].Value = "F5"; + var pt = ws.PivotTables.Add(ws.Cells["B2:D3"], ws.Cells["E5:F6"], "pivottable1"); ws.Cells["B2:D2"].Insert(eShiftTypeInsert.Down); Assert.AreEqual("B3:D4", pt.Address.Address); @@ -1101,7 +1095,7 @@ public void ValidateFilterInsertRow() var ws = _pck.Workbook.Worksheets.Add("AutoFilterInsertRow"); LoadTestdata(ws); ws.AutoFilter.Address = new ExcelAddressBase("A1:D100"); - ws.InsertRow(1,1); + ws.InsertRow(1, 1); Assert.AreEqual("A2:D101", ws.AutoFilter.Address.Address); ws.InsertRow(5, 2); Assert.AreEqual("A2:D103", ws.AutoFilter.Address.Address); @@ -1112,35 +1106,33 @@ public void ValidateFilterInsertColumn() var ws = _pck.Workbook.Worksheets.Add("AutoFilterInsertCol"); LoadTestdata(ws); ws.AutoFilter.Address = new ExcelAddressBase("A1:D100"); - ws.InsertColumn(1,1); + ws.InsertColumn(1, 1); Assert.AreEqual("B1:E100", ws.AutoFilter.Address.Address); - ws.InsertColumn(3,2); + ws.InsertColumn(3, 2); Assert.AreEqual("B1:G100", ws.AutoFilter.Address.Address); } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateFilterShiftDownPartial() { var ws = _pck.Workbook.Worksheets.Add("AutoFilterShiftDownPart"); LoadTestdata(ws); ws.AutoFilter.Address = new ExcelAddressBase("A1:D100"); - ws.Cells["A1:C1"].Insert(eShiftTypeInsert.Down); + Assert.ThrowsExactly(() => ws.Cells["A1:C1"].Insert(eShiftTypeInsert.Down)); } [TestMethod] - [ExpectedException(typeof(InvalidOperationException))] public void ValidateFilterShiftRightPartial() { var ws = _pck.Workbook.Worksheets.Add("AutoFilterShiftRightPart"); LoadTestdata(ws); ws.AutoFilter.Address = new ExcelAddressBase("A1:D100"); - ws.Cells["A1:A99"].Insert(eShiftTypeInsert.Right); + Assert.ThrowsExactly(() => ws.Cells["A1:A99"].Insert(eShiftTypeInsert.Right)); } [TestMethod] public void ValidateSparkLineShiftRight() { var ws = _pck.Workbook.Worksheets.Add("SparkLineShiftRight"); - LoadTestdata(ws,10); + LoadTestdata(ws, 10); ws.SparklineGroups.Add(OfficeOpenXml.Sparkline.eSparklineType.Column, ws.Cells["E2:E10"], ws.Cells["A2:D10"]); ws.Cells["E5"].Insert(eShiftTypeInsert.Right); Assert.AreEqual("F5", ws.SparklineGroups[0].Sparklines[3].Cell.Address); @@ -1214,7 +1206,7 @@ public void ValidateConditionalFormattingInsertColumnMultiRange() var cf = ws.ConditionalFormatting.AddAboveAverage(new ExcelAddress("B2:E5,D3:E5")); cf.Style.Fill.BackgroundColor.SetColor(eThemeSchemeColor.Accent1); - ws.InsertColumn(4,1); + ws.InsertColumn(4, 1); Assert.AreEqual("B2:F5,E3:F5", cf.Address.Address); } diff --git a/src/EPPlusTest/Utils/GuardingTests.cs b/src/EPPlusTest/Utils/GuardingTests.cs index 191e87c0b6..5bc213ddd0 100644 --- a/src/EPPlusTest/Utils/GuardingTests.cs +++ b/src/EPPlusTest/Utils/GuardingTests.cs @@ -43,11 +43,11 @@ private class TestClass } - [TestMethod, ExpectedException(typeof(ArgumentNullException))] + [TestMethod] public void Require_IsNotNull_ShouldThrowIfArgumentIsNull() { TestClass obj = null; - Require.Argument(obj).IsNotNull("test"); + Assert.ThrowsExactly(() => Require.Argument(obj).IsNotNull("test")); } [TestMethod] @@ -57,11 +57,11 @@ public void Require_IsNotNull_ShouldNotThrowIfArgumentIsAnInstance() Require.Argument(obj).IsNotNull("test"); } - [TestMethod, ExpectedException(typeof(ArgumentNullException))] + [TestMethod] public void Require_IsNotNullOrEmpty_ShouldThrowIfStringIsNull() { string arg = null; - Require.Argument(arg).IsNotNullOrEmpty("test"); + Assert.ThrowsExactly(() => Require.Argument(arg).IsNotNullOrEmpty("test")); } [TestMethod] @@ -71,11 +71,11 @@ public void Require_IsNotNullOrEmpty_ShouldNotThrowIfStringIsNotNullOrEmpty() Require.Argument(arg).IsNotNullOrEmpty("test"); } - [TestMethod, ExpectedException(typeof(ArgumentOutOfRangeException))] + [TestMethod] public void Require_IsInRange_ShouldThrowIfArgumentIsOutOfRange() { int arg = 3; - Require.Argument(arg).IsInRange(5, 7, "test"); + Assert.ThrowsExactly(() => Require.Argument(arg).IsInRange(5, 7, "test")); } [TestMethod] From dc88caa96cad9ed2d3deb06122cf4a3cef057977 Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:33:18 +0200 Subject: [PATCH 056/118] Replaced DataTestMethod->TestMethod in unit tests --- .../AutofitWithSerializedFontMetricsTests.cs | 10 ++++----- src/EPPlusTest/ExcelPackageTests.cs | 2 +- .../Finance/FinanceFunctionsTests.cs | 6 ++--- .../Functions/MathFunctions/CountaTests.cs | 2 +- .../Functions/RefAndLookup/VLookupTests.cs | 2 +- .../Functions/RefAndLookup/XLookupTests.cs | 22 +++++++++---------- .../Statistical/MiscStatisticalTests.cs | 4 ++-- .../Functions/TextFunctions/UsDollarTests.cs | 1 - .../IntegrationTests/FormulaParserTests.cs | 2 +- .../Issues/ConditionalFormattingIssues.cs | 2 -- 10 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/EPPlusTest/Core/Worksheet/AutofitWithSerializedFontMetricsTests.cs b/src/EPPlusTest/Core/Worksheet/AutofitWithSerializedFontMetricsTests.cs index 53134946a6..448d57d1f0 100644 --- a/src/EPPlusTest/Core/Worksheet/AutofitWithSerializedFontMetricsTests.cs +++ b/src/EPPlusTest/Core/Worksheet/AutofitWithSerializedFontMetricsTests.cs @@ -15,7 +15,7 @@ namespace EPPlusTest.Core.Worksheet [TestClass] public class AutofitWithSerializedFontMetricsTests : TestBase { - [DataTestMethod] + [TestMethod] [DataRow("Calibri")] [DataRow("Aptos Narrow")] [DataRow("Aptos Display")] @@ -93,7 +93,7 @@ public void AutofitWithSerializedFonts(string fontFamily) } } - [DataTestMethod, Ignore] + [TestMethod, Ignore] [DataRow("Calibri", 1)] //[DataRow("Calibri Light", 2)] //[DataRow("Arial", 3)] @@ -209,7 +209,7 @@ public void AutofitWithSerializedFonts2(string fontFamily, int run) } } - [DataTestMethod, Ignore] + [TestMethod, Ignore] [DataRow("Calibri", 1)] [DataRow("Arial", 2)] [DataRow("Arial Black", 3)] @@ -333,7 +333,7 @@ public void LoadFontSizeFromResource() } } - [DataTestMethod, Ignore] + [TestMethod, Ignore] [DataRow("Calibri")] [DataRow("Arial")] [DataRow("Times New Roman")] @@ -380,7 +380,7 @@ public void MeasureSpecificFont(string font) } - [DataTestMethod, Ignore] + [TestMethod, Ignore] [DataRow("Yu Gothic", 1)] [DataRow("Yu Mincho", 2)] [DataRow("Arial Rounded MT Bold", 3)] diff --git a/src/EPPlusTest/ExcelPackageTests.cs b/src/EPPlusTest/ExcelPackageTests.cs index c933c5cb90..2d26ba33cb 100644 --- a/src/EPPlusTest/ExcelPackageTests.cs +++ b/src/EPPlusTest/ExcelPackageTests.cs @@ -59,7 +59,7 @@ public void ConstructorWithStringPathAndPassword() } } - [DataTestMethod] + [TestMethod] [DataRow(EncryptionAlgorithm.AES128)] [DataRow(EncryptionAlgorithm.AES192)] [DataRow(EncryptionAlgorithm.AES256)] diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/Finance/FinanceFunctionsTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/Finance/FinanceFunctionsTests.cs index a9e4599e25..37f276a2a9 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/Finance/FinanceFunctionsTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/Finance/FinanceFunctionsTests.cs @@ -754,7 +754,7 @@ public void TbillYieldTest2() Assert.AreEqual(0.02061037, System.Math.Round((double)result, 8)); } - [DataTestMethod] + [TestMethod] [DataRow(2009, 1, 1, 2009, 12, 1, 2010, 4, 1, 75d, 4)] [DataRow(2009, 1, 1, 2009, 12, 1, 2010, 4, 18, 79.72222222d, 4)] [DataRow(2009, 1, 1, 2010, 4, 1, 2010, 4, 1, 25d, 4)] @@ -782,7 +782,7 @@ public void AccrintTestCalcFirstInterest(int iYear, int iMonth, int iDay, int fi Assert.AreEqual(expectedResult, System.Math.Round((double)result, 8)); } - [DataTestMethod] + [TestMethod] [DataRow(2009, 1, 1, 2009, 12, 1, 2010, 4, 1, 125d, 4)] [DataRow(2009, 2, 15, 2009, 12, 1, 2010, 4, 1, 112.77777778d, 4)] [DataRow(2009, 2, 15, 2009, 12, 1, 2009, 4, 2, 13.05555556d, 4)] @@ -805,7 +805,7 @@ public void AccrintIssueToSettlementTest(int iYear, int iMonth, int iDay, int fi Assert.AreEqual(expectedResult, System.Math.Round((double)result, 8)); } - [DataTestMethod] + [TestMethod] [DataRow(2009, 1, 1, 2009, 4, 1, 25d, 0)] [DataRow(2009, 1, 1, 2009, 4, 1, 24.657534d, 3)] [DataRow(2009, 1, 5, 2010, 6, 10, 144.722222d, 2)] diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/MathFunctions/CountaTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/MathFunctions/CountaTests.cs index 9c2461c012..0d94105d2c 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/MathFunctions/CountaTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/MathFunctions/CountaTests.cs @@ -28,7 +28,7 @@ private void SetValues(ExcelWorksheet worksheet) worksheet.Cells["C9"].Formula = "IF(B9,\"\",A9)"; } - [DataTestMethod] + [TestMethod] [DataRow(null, null, null, 0d)] [DataRow("a", null, null, 1)] [DataRow(null, "b", null, 1)] diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/VLookupTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/VLookupTests.cs index e3a9da1517..babd1c68f8 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/VLookupTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/VLookupTests.cs @@ -189,7 +189,7 @@ public void ApproximateShouldFindDate() } } - [DataTestMethod] + [TestMethod] [DataRow(1, "a")] [DataRow(5, "d")] public void ApproximateShouldFind(int find, string expected) diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/XLookupTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/XLookupTests.cs index fd4af716ec..80aa4ea81a 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/XLookupTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/XLookupTests.cs @@ -24,7 +24,7 @@ public void TestCleanup() _package.Dispose(); } - [DataTestMethod] + [TestMethod] [DataRow("Brazil", "+55")] [DataRow("Sweden", "#N/A")] [DataRow("Sweden", "Not found", "Not found")] @@ -64,7 +64,7 @@ public void BasicTest(string country, string expected, string notFoundText = "") Assert.AreEqual(expected, _sheet.Cells["F2"].Value.ToString()); } - [DataTestMethod] + [TestMethod] [DataRow("Brazil", "+55", "Not found", 0)] [DataRow("Brasil", "Not found", "Not found", 0)] [DataRow("Bazil", "#N/A", null, 0)] @@ -109,7 +109,7 @@ public void SearchModeTest(string country, string expected, string notFoundText Assert.AreEqual(expected, _sheet.Cells["F2"].Value.ToString()); } - [DataTestMethod] + [TestMethod] [DataRow("raz", "+55")] [DataRow("United", "+1")] [DataRow("desh", "+880")] @@ -146,7 +146,7 @@ public void WildcardTest(string lookupValue, string expected) Assert.AreEqual(expected, _sheet.Cells["F2"].Value.ToString()); } - [DataTestMethod] + [TestMethod] [DataRow("A", "1", 1)] [DataRow("A", "3", -1)] public void ReverseSearchTest1(string lookupValue, string expected, int searchMode) @@ -207,7 +207,7 @@ public void ShouldReturnHorizontalArray() Assert.AreEqual(6, _sheet.Cells["E4"].Value); } - [DataTestMethod] + [TestMethod] [DataRow("*A*", "1", 1)] [DataRow("*A*", "3", -1)] public void ReverseSearchTestWildcard(string lookupValue, string expected, int searchMode) @@ -228,7 +228,7 @@ public void ReverseSearchTestWildcard(string lookupValue, string expected, int s Assert.AreEqual(expected, _sheet.Cells["F2"].Value.ToString()); } - [DataTestMethod] + [TestMethod] [DataRow("*A*", "1", 1)] [DataRow("*A*", "3", -1)] public void ReverseSearchTestWildcardLargeRange(string lookupValue, string expected, int searchMode) @@ -249,7 +249,7 @@ public void ReverseSearchTestWildcardLargeRange(string lookupValue, string expec Assert.AreEqual(expected, _sheet.Cells["F2"].Value.ToString()); } - [DataTestMethod] + [TestMethod] [DataRow(0d, 1d, 0, 1)] [DataRow(11d, 2d, -1, 1)] [DataRow(11d, 3d, 1, 1)] @@ -271,7 +271,7 @@ public void HorizontalNumeric(double lookupValue, double expected, int matchMode Assert.AreEqual(expected, _sheet.Cells["F2"].Value); } - [DataTestMethod] + [TestMethod] [DataRow(0d, 1d, 0, 1)] [DataRow(11d, 2d, -1, 1)] [DataRow(11d, 3d, 1, 1)] @@ -293,7 +293,7 @@ public void HorizontalNumericLarge(double lookupValue, double expected, int matc Assert.AreEqual(expected, _sheet.Cells["F3"].Value); } - [DataTestMethod] + [TestMethod] [DataRow(11d, 2d, -1, 2)] [DataRow(21d, 3d, -1, 2)] [DataRow(0d, "Not found", -1, 2)] @@ -315,7 +315,7 @@ public void BinarySearchAscNextSmaller(double lookupValue, object expected, int Assert.AreEqual(expected, _sheet.Cells["F2"].Value); } - [DataTestMethod] + [TestMethod] [DataRow(11d, 3d, 1, 2)] [DataRow(21d, "Not found", 1, 2)] [DataRow(0d, 1d, 1, 2)] @@ -337,7 +337,7 @@ public void BinarySearchAscNextLarger(double lookupValue, object expected, int m Assert.AreEqual(expected, _sheet.Cells["F2"].Value); } - [DataTestMethod] + [TestMethod] [DataRow(0d, 1d, 0, -2)] [DataRow(11d, 3d, 1, -2)] [DataRow(21d, "Not found", 1, -2)] diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/Statistical/MiscStatisticalTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/Statistical/MiscStatisticalTests.cs index d32e3c6d31..9bf4bb4656 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/Statistical/MiscStatisticalTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/Statistical/MiscStatisticalTests.cs @@ -131,7 +131,7 @@ public void PearsonTest1() } } - [DataTestMethod] + [TestMethod] [DataRow(-1.5, 0.1295176)] [DataRow(0, 0.39894228)] [DataRow(0.1, 0.39695255)] @@ -250,7 +250,7 @@ public void SkewPTest2() } } - [DataTestMethod] + [TestMethod] [DataRow(2, 0.47725)] [DataRow(-1.5, -0.43319)] public void GaussTest1(double z, double expected) diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/UsDollarTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/UsDollarTests.cs index 1fe2cef9f1..8a472f2854 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/UsDollarTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/UsDollarTests.cs @@ -10,7 +10,6 @@ public class UsDollarTests : TestBase { private ExcelWorksheet _sheet; private ExcelPackage _package; - private CultureInfo _originalCulture; [TestInitialize] public void TestInitialize() diff --git a/src/EPPlusTest/FormulaParsing/IntegrationTests/FormulaParserTests.cs b/src/EPPlusTest/FormulaParsing/IntegrationTests/FormulaParserTests.cs index 068dec47ac..3964543a0a 100644 --- a/src/EPPlusTest/FormulaParsing/IntegrationTests/FormulaParserTests.cs +++ b/src/EPPlusTest/FormulaParsing/IntegrationTests/FormulaParserTests.cs @@ -8,7 +8,7 @@ namespace EPPlusTest.FormulaParsing.IntegrationTests [TestClass] public class FormulaParserTests : FormulaParserTestBase { - [DataTestMethod] + [TestMethod] [DataRow(true)] [DataRow(false)] public void ValidateFormulaParserWithIsWorksheets1Based(bool isWorksheets1Based) diff --git a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs index 7170eaff8d..b9ad62b69e 100644 --- a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs +++ b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs @@ -193,8 +193,6 @@ public void i2381() var style = ws.Workbook.Styles.Dxfs[128]; - var test = "why"; - var file = GetOutputFile("", "Cabinet_template_test_clean_OutputReRead.xlsx"); package.SaveAs(file); } From 602107fdb4bacf9cc3728b8e7604904ab1241c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 16 Jun 2026 11:37:29 +0200 Subject: [PATCH 057/118] Fixed small error in positioning of shapes in charts --- src/EPPlus/Drawing/ExcelDrawing.cs | 32 ++++++++++++++++++++++++++++-- src/EPPlusTest/TestBase.cs | 2 +- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/EPPlus/Drawing/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index 4578e3dab4..0c571a957f 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -924,7 +924,21 @@ internal void SetPixelTop(double pixels) _doNotAdjust = true; if (CellAnchor == eEditAs.Absolute) { - Position.Y = (int)(pixels * EMU_PER_PIXEL); + if (_drawings._collectionType == DrawingsCollectionType.Chart) + { + if (From == null) + { + Position.Y = (int)(pixels * EMU_PER_PIXEL); + } + else + { + From.Y = pixels / _drawings._screenHeight; + } + } + else + { + Position.Y = (int)(pixels * EMU_PER_PIXEL); + } } else { @@ -966,7 +980,21 @@ internal void SetPixelLeft(double pixels) _doNotAdjust = true; if (CellAnchor == eEditAs.Absolute) { - Position.X = (int)(pixels * EMU_PER_PIXEL); + if (_drawings._collectionType == DrawingsCollectionType.Chart) + { + if(From==null) + { + Position.X = (int)(pixels * EMU_PER_PIXEL); + } + else + { + From.X = pixels / _drawings._screenWidth; + } + } + else + { + Position.X = (int)(pixels * EMU_PER_PIXEL); + } } else { diff --git a/src/EPPlusTest/TestBase.cs b/src/EPPlusTest/TestBase.cs index f73c7f9193..2cd27f33c3 100644 --- a/src/EPPlusTest/TestBase.cs +++ b/src/EPPlusTest/TestBase.cs @@ -249,7 +249,7 @@ protected static void SaveWorkbook(string name, ExcelPackage pck) var fi = new FileInfo(_worksheetPath + name); if (fi.Exists) { - fi.Delete(); + fi.Delete(); } pck.SaveAs(fi); } From d54fccffeb92af0d1ee106457ea9fe3b412b5f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 16 Jun 2026 13:57:55 +0200 Subject: [PATCH 058/118] Transferred piechart legend special case --- .../Chart/PieChartTests.cs | 140 +++++++++- .../Svg/SvgGroupRenderer.cs | 2 +- .../Renderer/Chart/ChartLegendRenderer.cs | 242 +++++++++++++----- .../ChartTypeDrawers/PieChartTypeDrawer.cs | 6 + .../Chart/DataLabels/SvgDataLabelPoint.cs | 120 ++++----- .../Utils/Rendering/DrawingExtensions.cs | 113 ++++++++ 6 files changed, 475 insertions(+), 148 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs index 9b73ea8922..6f3e13eb75 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs @@ -19,15 +19,18 @@ public void ReadAndGenerateExcelPieChartSvgs() { var ws = p.Workbook.Worksheets[0]; - for(int i = 0; i < p.Workbook.Worksheets.Count; i++) - { - ws = p.Workbook.Worksheets[i]; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\PieChartSvgALL\\s{i}_{ws.Name}_{c.Name}.svg", svg); - } - } + var manySlices = ws.Drawings["ManySlices"]; + var test = manySlices.ToSvg(); + SaveTextFileToWorkbook($"svg\\PieChartSvgALL\\s{0}_{ws.Name}_{manySlices.Name}.svg", test); + //for (int i = 0; i < p.Workbook.Worksheets.Count; i++) + //{ + // ws = p.Workbook.Worksheets[i]; + // foreach (ExcelChart c in ws.Drawings) + // { + // var svg = c.ToSvg(); + // SaveTextFileToWorkbook($"svg\\PieChartSvgALL\\s{i}_{ws.Name}_{c.Name}.svg", svg); + // } + //} } } @@ -75,11 +78,16 @@ public void Datalabels() ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("PieChartDlblsOrig.xlsx")) { - var ws = p.Workbook.Worksheets[0]; + //var ws = p.Workbook.Worksheets[0]; + + //var drawing = ws.Drawings["InsideEnd"]; + + //var svg = drawing.ToSvg(); + //SaveTextFileToWorkbook($"svg\\PieChartDlbls\\s{0}_{ws.Name}_{drawing.Name}.svg", svg); for (int i = 0; i < p.Workbook.Worksheets.Count; i++) { - ws = p.Workbook.Worksheets[i]; + var ws = p.Workbook.Worksheets[i]; foreach (ExcelChart c in ws.Drawings) { var svg = c.ToSvg(); @@ -108,5 +116,115 @@ public void Datalabels2() } } } + + [TestMethod] + public void ReadLegendIssue() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("PieChartSvgLegendIssue.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + + for (int i = 0; i < p.Workbook.Worksheets.Count; i++) + { + ws = p.Workbook.Worksheets[i]; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\PieChartSvgLegendIssue\\s{i}_{ws.Name}_{c.Name}.svg", svg); + } + } + } + } + + //[TestMethod] + //public void BasicPieChart() + //{ + // ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + // using (var p = OpenTemplatePackage("BasicPieChart.xlsx")) + // { + // var ws = p.Workbook.Worksheets[0]; + // var renderer = new EPPlusImageRenderer.ImageRenderer(); + + // var pChart = ws.Drawings[0].As.Chart.PieChart; + + // var ser = pChart.Series[0]; + + // var legend = pChart.Legend; + // var entry = pChart.Legend.Entries; + // var pHeader = ser.Header; + // var pHeaderAddress = ser.HeaderAddress; + // var headerString = ser.GetHeaderString(); + + + // var ix = 0; + // foreach (ExcelChart c in ws.Drawings) + // { + // var svg = renderer.RenderDrawingToSvg(c); + // SaveTextFileToWorkbook($"svg\\BasicPieChart{ix++}.svg", svg); + // } + // } + //} + + //[TestMethod] + //public void Datalabels() + //{ + // ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + // using (var p = OpenTemplatePackage("PieChartDlblsOrig.xlsx")) + // { + // var ws = p.Workbook.Worksheets[0]; + // var renderer = new EPPlusImageRenderer.ImageRenderer(); + + // for (int i = 0; i < p.Workbook.Worksheets.Count; i++) + // { + // ws = p.Workbook.Worksheets[i]; + // foreach (ExcelChart c in ws.Drawings) + // { + // var svg = renderer.RenderDrawingToSvg(c); + // SaveTextFileToWorkbook($"svg\\PieChartDlbls\\s{i}_{ws.Name}_{c.Name}.svg", svg); + // } + // } + // } + //} + + [TestMethod] + public void Datalabels22() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("PieChartDlblsInside.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + + for (int i = 0; i < p.Workbook.Worksheets.Count; i++) + { + ws = p.Workbook.Worksheets[i]; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\PieChartDlbls22\\s{i}_{ws.Name}_{c.Name}.svg", svg); + } + } + } + } + + + [TestMethod] + public void Datalabels3() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("PieChartDlblsOutside.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + for (int i = 0; i < p.Workbook.Worksheets.Count; i++) + { + ws = p.Workbook.Worksheets[i]; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\PieChartDlbls3\\s{i}_{ws.Name}_{c.Name}.svg", svg); + } + } + } + } } } diff --git a/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs b/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs index 2ac6ac7a2a..c2a66a0f69 100644 --- a/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs +++ b/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs @@ -22,7 +22,7 @@ public override void Render(GroupRenderItem item) //Neccesary as fallback for e.g. DataLabels string fillPropery = ""; - if (string.IsNullOrEmpty(item.FillColor) == false) + if (string.IsNullOrEmpty(item.FillColor) == false && item.FillColor != "none") { fillPropery = $" fill=\"{item.FillColor}\" "; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index a0db0fd7bf..2ced5030fa 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -11,6 +11,7 @@ Date Author Change 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ using EPPlus.DrawingRenderer.RenderItems; +using EPPlus.Export.Utils; using EPPlus.Fonts.OpenType; using EPPlus.Fonts.OpenType.Integration; using EPPlusImageRenderer.RenderItems; @@ -18,6 +19,7 @@ Date Author Change using OfficeOpenXml.Drawing.Chart; using OfficeOpenXml.Drawing.Renderer.Chart; using OfficeOpenXml.Drawing.Renderer.TextBox; +using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Style; using System; @@ -101,11 +103,41 @@ private RectRenderItem GetLegendRectangleAndEntrySize(ExcelChartLegend l, out do //Find the widest and hightest legend entry, and calculate the total width and hight of the legend based on the orientation. foreach (var ct in Chart.PlotArea.ChartTypes) { - foreach (var s in ct.Series) + if (ct.GetType() == typeof(ExcelPieChart)) { - var text = s.GetHeaderText(index); - GetSerieSize(l, index, text, ref widest, ref highest); - index++; + //Pie chart cares only about first series + if (ct.Series[0].GetType() == typeof(ExcelPieChartSerie)) + { + var ps = (ExcelPieChartSerie)ct.Series[0]; + var catValues = DrawingExtensions.LoadSeriesValues(ct, ps.XSeries, ps.NumberLiteralsX, ps.StringLiteralsX); + + //Excel fallsback to index + 1 if no literals and no series + if (catValues == null) + { + catValues = new List(); + foreach (var dp in ps.DataPoints) + { + catValues.Add($"{dp.Index + 1}"); + } + } + for (int i = 0; i < catValues.Count; i++) + { + var text = catValues[i].ToString(); + GetSerieSize(l, index, text, ref widest, ref highest); + index++; + } + } + //Skip the rest + break; + } + else + { + foreach (var s in ct.Series) + { + var text = s.GetHeaderText(index); + GetSerieSize(l, index, text, ref widest, ref highest); + index++; + } } } @@ -126,7 +158,7 @@ private RectRenderItem GetLegendRectangleAndEntrySize(ExcelChartLegend l, out do index += trIndex; - var maxIconLength = GetMaxIconLenght(Chart, highest); + var maxIconLength = GetMaxIconLength(Chart, highest); entryWidth = maxIconLength + MarginIconText + widest; entryHeight = highest; @@ -255,12 +287,12 @@ private void GetSerieSize(ExcelChartLegend l, int index, string text, ref double } } - private double GetMaxIconLenght(ExcelChart ct, double heighestText) + private double GetMaxIconLength(ExcelChart ct, double heighestText) { var maxIconLength = 0D; foreach(var c in ct.PlotArea.ChartTypes) { - var il = GetIconLenght(c, heighestText); + var il = GetIconLength(c, heighestText); if (il > maxIconLength) { maxIconLength = il; @@ -268,24 +300,24 @@ private double GetMaxIconLenght(ExcelChart ct, double heighestText) } return maxIconLength; } - private double GetIconLenght(ExcelChart c, double heighestText) + private double GetIconLength(ExcelChart c, double highestText) { - return c.IsTypeLine() ? LineLength : Math.Max(MinBarLength, heighestText * 0.4); + return c.IsTypeLine() ? LineLength : Math.Max(MinBarLength, highestText * 0.4); } internal DrawingLegendSerie SetLegendSeries(double entryWidth, double entryHeight) { int index = 0; - DrawingLegendSerie pSls=null; + DrawingLegendSerie pSls = null; var pos = Chart.Legend.Position; - var maxIconLength = GetMaxIconLenght(Chart, entryHeight); + var maxIconLength = GetMaxIconLength(Chart, entryHeight); foreach (var ct in Chart.PlotArea.ChartTypes) { int ix, end; - if(ct.IsTypeBar()) + if (ct.IsTypeBar()) { - ix = ct.Series.Count-1; + ix = ct.Series.Count - 1; end = -1; } else @@ -294,7 +326,7 @@ internal DrawingLegendSerie SetLegendSeries(double entryWidth, double entryHeigh end = ct.Series.Count; } - while(ix != end) + while (ix != end) { var s = ct.Series[ix]; var sls = new DrawingLegendSerie(); @@ -318,7 +350,12 @@ internal DrawingLegendSerie SetLegendSeries(double entryWidth, double entryHeigh break; case eChartType.Pie: case eChartType.PieExploded: - SetPieLegend(ct, index, pSls, pos, s, sls, entryWidth, entryHeight, maxIconLength); + if (ix == 0) + { + SetPieLegend(ct, index, pSls, pos, s, sls, entryWidth, entryHeight, maxIconLength); + //pSls = null; + //sls = null; + } break; default: break; @@ -326,10 +363,10 @@ internal DrawingLegendSerie SetLegendSeries(double entryWidth, double entryHeigh if (Chart.Legend.Position == eLegendPosition.Top || Chart.Legend.Position == eLegendPosition.Bottom) { - if (sls.Textbox.Bounds.Bottom > Rectangle.Bottom) - { - break; - } + //if (sls.Textbox.Bounds.Bottom > Rectangle.Bottom) + //{ + // break; + //} } else { @@ -338,20 +375,57 @@ internal DrawingLegendSerie SetLegendSeries(double entryWidth, double entryHeigh break; } } + //if (sls != null) + //{ + // SeriesIcon.Add(sls); + //} SeriesIcon.Add(sls); pSls = sls; + //else + //{ + // pSls = null; + //} index++; - if(ix Rectangle.Bottom) + // { + // break; + // } + // } + // else + // { + // if (sls.Textbox.Bounds.Bottom > Rectangle.Height) + // { + // break; + // } + // } + // SeriesIcon.Add(sls); + // pSls = sls; + // index++; + // if(ix(); + foreach (var dp in ps.DataPoints) + { + catValues.Add($"{dp.Index + 1}"); + } + } - var si = GetLineSeriesIcon(ct, ps, pSls, entryWidth, entryHeight); - sls.SeriesIcon = si; + double lastWidth = entryWidth; + double totalWidth = entryWidth; - var tbLeft = si.X1 + maxIconLength + MarginIconText; - var tbTop = si.Y2 - entryHeight * 0.5; - var tbWidth = Rectangle.Bounds.Width - tbLeft; + for (int i = 0; i < catValues.Count; i++) + { + var tm = _seriesHeadersMeasure[index + i]; + //Step 1: Retrieve Icon - var tbHeight = entryHeight; - sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + var si = GetPieSeriesIcon(ct, ps, pSls, lastWidth, entryHeight, i); + sls = new DrawingLegendSerie(); + var tbLeft = si.Left + maxIconLength + MarginIconText; + var tbTop = si.Top - (entryHeight - si.Height) / 2; + double tbWidth; - //Cat values are the header text - //They create a rect marker for each slice + tbWidth = Rectangle.Bounds.Width; - //var entry = Chart.Legend.Entries.FirstOrDefault(x => x.Index == index); - //var catValues = LoadSeriesValues(serie.XSeries, serie.NumberLiteralsX, serie.StringLiteralsX); - //for (int i = 0; i< ps.NumberOfItems; i++) - //{ - - // var headerText = ps.XSeries - //} - //var headerText = s.GetHeaderText(index); - //if (entry == null || entry.Font.IsEmpty) - //{ - // sls.Textbox.ImportParagraph(sc.Chart.Legend.TextBody.Paragraphs.FirstOrDefault(), 0, headerText); - //} - //else - //{ - // //sls.Textbox.AddText(s.GetHeaderText(), entry.Font); - // sls.Textbox.ImportParagraph(entry.TextBody.Paragraphs.FirstOrDefault(), 0, headerText); - //} + var tbHeight = tm.Height; + sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); + //var para = sc.Chart.Legend.TextBody.Paragraphs.FirstOrDefault(); + sls.Textbox.ImportParagraph(Chart.Legend.TextBody.Paragraphs.FirstOrDefault(), 0, catValues[i].ToString()); + sls.SeriesIcon = si; + sls.Textbox.RecalculateParagraphs(); - //if (ps.DataPoints != null && ps.DataPoints.Count != null /*&& ps.Marker.Style != eMarkerStyle.None*/) - //{ - // var l = sls.SeriesIcon as SvgRenderLineItem; - // var x = l.X1 + (l.X2 - l.X1) / 2; - // var y = l.Y1; - - // //sls.MarkerIcon = LineMarkerHelper.GetMarkerItem(sc, ps, x, y, true); - // if ((ps.Marker.Style == eMarkerStyle.Plus || ps.Marker.Style == eMarkerStyle.X || ps.Marker.Style == eMarkerStyle.Star) && - // ps.Marker.Fill.IsEmpty == false) - // { - // sls.MarkerBackground = LineMarkerHelper.GetMarkerBackground(sc, ps, x, y, true); - // } - // else - // { - // sls.MarkerBackground = null; - // } - //} + tbWidth = sls.Textbox.Width; + + lastWidth = tbWidth + si.Width; + + totalWidth += tbWidth + si.Width; + + var dp = ps.DataPoints[i]; + + sls.SeriesIcon.SetDrawingPropertiesFill(ChartRenderer.Theme, dp.Fill, ct.As.Chart.PieChart.StyleManager.Style.DataPoint.FillReference.Color); + sls.SeriesIcon.SetDrawingPropertiesBorder(ChartRenderer.Theme, dp.Border, ct.As.Chart.PieChart.StyleManager.Style.DataPoint.BorderReference.Color, true); + sls.SeriesIcon.SetDrawingPropertiesEffects(ChartRenderer.Theme, dp.Effect); + + SeriesIcon.Add(sls); + pSls = sls; + } + pSls = null; + sls = null; } private void SetLineLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLegendPosition pos, ExcelChartSerie s, DrawingLegendSerie sls, double entryWidth, double entryHeight, double maxIconLength) @@ -595,10 +676,41 @@ private LineRenderItem GetTrendLineSeriesIcon(ExcelChart ct, ExcelChartTrendline return line; } + private RectRenderItem GetPieSeriesIcon(ExcelChart ct, ExcelPieChartSerie pcS, DrawingLegendSerie pSls, double entryWidth, double entryHeight, int i) + { + var item = new RectRenderItem(Rectangle.Bounds); + var pt = pcS.DataPoints[i]; + + var iconHeight = GetIconLength(ct, entryHeight); + var icon = pSls?.SeriesIcon as RectRenderItem; + + GetItemPosition(pSls, entryWidth, entryHeight, icon?.Left ?? 0D, icon?.Top ?? 0D, out double x, out double y); + + item.LineCap = LineCap.Round; + item.Left = x; + if (pSls != null && (Chart.Legend.Position == eLegendPosition.Left || Chart.Legend.Position == eLegendPosition.Right)) + { + item.Top = y + (entryHeight - iconHeight) / 2; + } + else + { + item.Top = y; + } + //item.Top = y; + item.Width = iconHeight; + item.Height = iconHeight; + + item.SetDrawingPropertiesFill(ChartRenderer.Theme, pcS.Fill, Chart.StyleManager.Style.SeriesLine.FillReference.Color); + item.SetDrawingPropertiesBorder(ChartRenderer.Theme, pcS.Border, Chart.StyleManager.Style.SeriesLine.BorderReference.Color, pcS.Border.Fill.Style != eFillStyle.NoFill, 0.75); + + return item; + } + + private RectRenderItem GetBarSeriesIcon(ExcelChart ct, ExcelChartStandardSerie cStandardSerie, DrawingLegendSerie pSls, double entryWidth, double entryHeight) { var item = new RectRenderItem(Rectangle.Bounds); - var iconHeight = GetIconLenght(ct, entryHeight); + var iconHeight = GetIconLength(ct, entryHeight); //var icon = pSls?.SeriesIcon as RectRenderItem; double iconTop = 0, iconLeft = 0; pSls?.GetIconTopLeft(out iconTop, out iconLeft); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs index bca35923c5..7511ca5f3e 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs @@ -267,6 +267,12 @@ internal override void DrawSeries() public override void AppendRenderItems(List renderItems) { ChartRenderer.Plotarea.Group.AddChildItem(_groupItem); + //ChartRenderer.Plotarea.Group.AddChildItem(SeriesRenderItems[0]); + if(SeriesRenderItems != null && SeriesRenderItems.Count > 0) + { + ChartRenderer.RenderItems.Add(SeriesRenderItems[0]); + } + //SeriesRenderItems.ForEach(x => ChartRenderer.Plotarea.Group.AddChildItem(x)); //renderItems.AddRange(ChartAreaRenderItems); //SeriesRenderItems.ForEach(x => _groupItem.AddChildItem(x)); } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index ad69e6df3b..d626579139 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -9,6 +9,7 @@ using OfficeOpenXml.Drawing; using OfficeOpenXml.Drawing.Chart; using OfficeOpenXml.Drawing.Renderer.TextBox; +using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.Utils.EnumUtils; using System; using System.Collections.Generic; @@ -121,6 +122,8 @@ internal void ImportDataLabel(ExcelChartStandardSerie serie, ExcelChartDataLabel txtBox.TextBody.Paragraphs.RemoveAt(0); } + txtBox.TextBody.RecalculateParagraphs(); + if(txtBox.LeftMargin == 0) { txtBox.LeftMargin = defaultMargins.Left; @@ -259,7 +262,7 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V Rectangle.Bounds.Parent = parentPoint; _parentPoint = parentPoint; _parentShapeBounds = parentShape; - + var dataLabelCenter = new Vector2(Rectangle.Bounds.Left, Rectangle.Bounds.Top); Vector2 startPointDirection = Vector2.Zero; @@ -269,34 +272,34 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V startPointDirection = startToEndDir / startToEndDir.Length; } - //if (_parentShapeBounds != null) - //{ - // //shapeCenter - // dataLabelCenter = new Graphics.Math.Vector2((_parentShapeBounds.Width / 2)+parentShape.Left, (_parentShapeBounds.Height / 2)+parentShape.Top); - - // //Get directional vector (in local coords but does not matter since we make it directional) - // startPointDirection = dataLabelCenter - parentPoint.LocalPosition; - // //Divide by length to only get direction - // var startPointDirectionOnly = startPointDirection / startPointDirection.Length; - - // //var lenX = Math.Abs() - // //////Pythagoran theorem - // var len = Math.Sqrt(Math.Pow(startPointDirection.X, 2) + Math.Pow(startPointDirection.Y, 2)); - // startPointDirection = startPointDirectionOnly * len; - //} - //else - //{ - // dataLabelCenter = new Graphics.Math.Vector2(Bounds.Left, Bounds.Top); - //} - - switch (_labelPosition) - { + //if (_parentShapeBounds != null) + //{ + // //shapeCenter + // dataLabelCenter = new Graphics.Math.Vector2((_parentShapeBounds.Width / 2)+parentShape.Left, (_parentShapeBounds.Height / 2)+parentShape.Top); + + // //Get directional vector (in local coords but does not matter since we make it directional) + // startPointDirection = dataLabelCenter - parentPoint.LocalPosition; + // //Divide by length to only get direction + // var startPointDirectionOnly = startPointDirection / startPointDirection.Length; + + // //var lenX = Math.Abs() + // //////Pythagoran theorem + // var len = Math.Sqrt(Math.Pow(startPointDirection.X, 2) + Math.Pow(startPointDirection.Y, 2)); + // startPointDirection = startPointDirectionOnly * len; + //} + //else + //{ + // dataLabelCenter = new Graphics.Math.Vector2(Bounds.Left, Bounds.Top); + //} + + switch (_labelPosition) + { case eLabelPosition.Center: if ((startToEndDir.X == 0 && startToEndDir.Y == 0) == false) { //Half and invert - dataLabelCenter = ((startToEndDir*0.5d) * -1d); + dataLabelCenter = ((startToEndDir * 0.5d) * -1d); } Rectangle.Bounds.Left += dataLabelCenter.X; Rectangle.Bounds.Top += dataLabelCenter.Y; @@ -315,57 +318,32 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V SetPositionBasic(parentPoint, _labelPosition); break; case eLabelPosition.InEnd: - //if (startPointDirection.X == 0 && startPointDirection.Y == 0) - //{ - // throw new InvalidOperationException("eLabelPosition.InEnd MUST have a direction." + - // "Cannot be within End if EndPoint is undefined."); - //} - ////if(parentShape == null) - ////{ - //// throw new InvalidOperationException("eLabelPosition.InEnd MUST have a parentShape"); - ////} - //startPointDirection = startPointDirection * -1; - //if (startPointDirection.X != 0) - //{ - // //If endPoint is to the right - // if (startPointDirection.X > 0) - // { - // //We must place to the left - // SetPositionBasic(parentPoint, eLabelPosition.Left); - // } - // //if endpoint is to the left - // else - // { - // //We must place to the right - // SetPositionBasic(parentPoint, eLabelPosition.Right); - // } - //} - - //if (startPointDirection.Y != 0) - //{ - // //If endpoint is below - // if (startPointDirection.Y > 0) - // { - // //We must place on Top - // SetPositionBasic(parentPoint, eLabelPosition.Top); - // } - // else - // { - // //We must place on Bottom - // SetPositionBasic(parentPoint, eLabelPosition.Bottom); - // } - //} + if (startPointDirection.X == 0 && startPointDirection.Y == 0) + { + throw new InvalidOperationException("eLabelPosition.InEnd MUST have a direction." + + "Cannot be within End if EndPoint is undefined."); + } + var insidePos = startToEndDir * 0.15 * -1; + Rectangle.Bounds.Left += insidePos.X; + Rectangle.Bounds.Top += insidePos.Y; break; case eLabelPosition.OutEnd: - //if (startPointDirection.X == 0 && startPointDirection.Y == 0) + if (startPointDirection.X == 0 && startPointDirection.Y == 0) + { + throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a direction." + + "Cannot be within End if EndPoint is undefined."); + } + if (startPointDirection.X == 0 && startPointDirection.Y == 0) + { + throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a direction." + + "Cannot be within End if EndPoint is undefined."); + } + Rectangle.Bounds.Left += startToEndDir.X * 0.15; + Rectangle.Bounds.Top += startToEndDir.Y * 0.15; + //if (parentShape == null) //{ - // throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a direction." + - // "Cannot be within End if EndPoint is undefined."); + // throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a parentShape"); //} - ////if (parentShape == null) - ////{ - //// throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a parentShape"); - ////} //if (startPointDirection.X != 0) //{ diff --git a/src/EPPlus/Utils/Rendering/DrawingExtensions.cs b/src/EPPlus/Utils/Rendering/DrawingExtensions.cs index 3da7ad5c4f..d9242c41cb 100644 --- a/src/EPPlus/Utils/Rendering/DrawingExtensions.cs +++ b/src/EPPlus/Utils/Rendering/DrawingExtensions.cs @@ -1,9 +1,14 @@ using EPPlus.DrawingRenderer.RenderItems; using EPPlus.Fonts.OpenType.Utils; using EPPlus.Graphics; +using OfficeOpenXml; using OfficeOpenXml.Drawing; +using OfficeOpenXml.Drawing.Chart; using OfficeOpenXml.Drawing.Style.Fill; using OfficeOpenXml.Drawing.Theme; +using OfficeOpenXml.ExternalReferences; +using System.Collections.Generic; +using System.Linq; namespace EPPlus.Export.Utils { @@ -19,6 +24,114 @@ internal static BoundingBox GetBoundingBox(this ExcelDrawing drawing) Height = drawing.GetPixelHeight().PixelToPoint() }; } + + internal static List LoadSeriesValues(ExcelChart chart, string serieAddressInput, double[] numLiterals, string[] strLiterals) + { + string serieAddress = serieAddressInput; + + //Some addresses are split and within parenthesis + if (serieAddressInput.StartsWith("(")) + { + serieAddress = serieAddressInput.Trim('(', ')'); + } + + List values = new List(); + if (numLiterals != null) + { + values.AddRange(numLiterals.Select(x => (object)x)); + } + else if (strLiterals != null) + { + values.AddRange(strLiterals.Select(x => (object)x)); + } + else + { + if (string.IsNullOrEmpty(serieAddress)) + { + return null; + } + var address = new ExcelAddressBase(serieAddress); + + if (address.Addresses != null && address.Addresses.Count > 1) + { + foreach (var splitAddress in address.Addresses) + { + FillValuesFromAddress(chart, splitAddress, ref values); + } + } + else + { + FillValuesFromAddress(chart, address, ref values); + } + } + return values; + } + + internal static void FillValuesFromAddress(ExcelChart Chart, ExcelAddressBase address, ref List values) + { + if (address.IsExternal) + { + var wb = Chart.WorkSheet.Workbook; + var extWb = wb.ExternalLinks[address.ExternalReferenceIndex - 1] as ExcelExternalWorkbook; + if (extWb != null) + { + var wsName = address.WorkSheetName; + if (extWb.Package == null) + { + var extWs = extWb.CachedWorksheets[wsName]; + FillExternalValues(extWs, address, ref values); + } + else + { + var ws = extWb.Package.Workbook.Worksheets[wsName]; + FillInternalValues(ws, address, ref values); + } + } + } + else + { + var wsName = address.WorkSheetName; + + if (string.IsNullOrEmpty(wsName)) + { + wsName = Chart.WorkSheet.Name; + } + + var ws = Chart.WorkSheet.Workbook.Worksheets[wsName]; + FillInternalValues(ws, address, ref values); + } + } + + internal static void FillExternalValues(ExcelExternalWorksheet extWs, ExcelAddressBase address, ref List values) + { + if (extWs != null) + { + for (int r = address.Start.Row; r <= address.End.Row; r++) + { + for (int c = address.Start.Column; c <= address.End.Column; c++) + { + values.Add(extWs.CellValues[r, c].Value); + } + } + } + } + + internal static void FillInternalValues(ExcelWorksheet ws, ExcelAddressBase address, ref List values) + { + + if (ws != null) + { + for (int r = address.Start.Row; r <= address.End.Row; r++) + { + for (int c = address.Start.Column; c <= address.End.Column; c++) + { + values.Add(ws.Cells[r, c].Value); + } + } + } + } + + internal static OffsetRectangle AsOffsetRectangle(this ExcelDrawingRectangle item) { return new OffsetRectangle From f40e61e848fb9b50bf319af4c020efcc480816dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 16 Jun 2026 14:54:17 +0200 Subject: [PATCH 059/118] Fixed label position on horizontal axis --- .../Chart/LineChartToSvgTests.cs | 24 ++++++++++++++++++- .../Renderer/Chart/ChartAxisRenderer.cs | 15 ++++++++++-- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 6 ++--- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index 17159bda53..02f48468df 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -24,7 +24,7 @@ public void GenerateSvgForLineCharts_sheet1() //var svg = c.ToSvg(); //SaveTextFileToWorkbook($"svg\\ChartForSvg_ind{ix++}.svg", svg); - for(int i = 5; i< ws.Drawings.Count; i++) + for(int i = 0; i< ws.Drawings.Count; i++) { var c = ws.Drawings[i]; var svg = c.ToSvg(); @@ -281,5 +281,27 @@ public void GenerateSimpleLineChart() SaveTextFileToWorkbook($"svg\\defChartLine3Points.svg", svg); } } + [TestMethod] + public void GenerateSvgForLineCharts_AxisAlign_sheet1() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("HorizontalAxisAlign.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + + var ix = 3; + var c = ws.Drawings[ix]; + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\HorizontalAxisChartForSvg{ix++}.svg", svg); + + //for (int i = 0; i < ws.Drawings.Count; i++) + //{ + // var c = ws.Drawings[i]; + // var svg = c.ToSvg(); + // SaveTextFileToWorkbook($"svg\\HorizontalAxisChartForSvg{i}.svg", svg); + //} + } + } + } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index a99a7c4495..178a00e8a5 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -492,7 +492,7 @@ private List GetAxisValueTextBoxes() else if(LabelOrientation==eTextOrientation.Horizontal && IsCatAx()) //Only apples when labels are horizontally aligned { //Align the axis labels according to the label alignment setting. This is only relevant for horizontal axis, vertical axis are always right aligned. - var lblAlignment = (Axis as ExcelChartAxisStandard)?.LabelAlignment??OfficeOpenXml.eAxisLabelAlignment.Center; + var lblAlignment = (Axis as ExcelChartAxisStandard)?.LabelAlignment ?? OfficeOpenXml.eAxisLabelAlignment.Center; var majorWidth = Rectangle.Width / AxisValues.Count; if (Axis.CrossingAxis == null || Axis.CrossingAxis.CrossBetween == eCrossBetween.MidCat) { @@ -529,6 +529,17 @@ private List GetAxisValueTextBoxes() } } } + else if(LabelOrientation == eTextOrientation.Diagonal) + { + if (!(Axis.CrossingAxis == null || Axis.CrossingAxis.CrossBetween == eCrossBetween.MidCat)) + { + var majorWidth = Rectangle.Width / AxisValues.Count; + foreach (var tb in ret) + { + tb.Left += majorWidth / 2; + } + } + } return ret; } @@ -552,7 +563,7 @@ private double GetAxisItemLeft(int i, OfficeOpenXml.Interfaces.Drawing.Text.Text if (Axis.CrossingAxis == null || Axis.CrossingAxis.CrossBetween == eCrossBetween.Between) { majorWidth = Rectangle.Width / AxisValues.Count; - majorHalf = majorWidth / 2; + //majorHalf = majorWidth / 2; } else { diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index 2416c5ea73..02bcd06172 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -99,7 +99,7 @@ private void SetAxisPositionsFromPlotarea() if (HorizontalAxis != null && HorizontalAxis.Rectangle != null) { - PlaceHorizontalAxis(HorizontalAxis, false); + PlaceHorizontalAxis(HorizontalAxis, false); //Make sure the horizontal axis is moved up if the vertical axis has a negative minimum value, so that the 0 value is at the correct position. if (HorizontalAxis.Axis.TickLabelPosition == eTickLabelPosition.NextTo && VerticalAxis.Axis.AxisType == eAxisType.Val && VerticalAxis.Min < 0D) @@ -306,8 +306,8 @@ private void SetChartArea() var item = new ChartAreaRenderer(this); item.Rectangle.Width = Bounds.Width; item.Rectangle.Height = Bounds.Height; - item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style.ChartArea.FillReference.Color); - item.Rectangle.SetDrawingPropertiesBorder(Theme, Chart.Border, Chart.StyleManager.Style.ChartArea.BorderReference.Color, Chart.Border.Width > 0); + item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style?.ChartArea.FillReference.Color); + item.Rectangle.SetDrawingPropertiesBorder(Theme, Chart.Border, Chart.StyleManager.Style?.ChartArea.BorderReference.Color, Chart.Border.Width > 0); item.AppendRenderItems(RenderItems); item.SetMargins(Chart.TextBody); ChartArea = item; From dd18c5a2574c36ca13766efc7f9f1b0b4b1276e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 16 Jun 2026 15:28:04 +0200 Subject: [PATCH 060/118] Fixes axis label alignment and label positioning --- .../Chart/LineChartToSvgTests.cs | 20 +++++++++---------- .../Renderer/Chart/ChartAxisRenderer.cs | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index 02f48468df..b0b3600277 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -289,17 +289,17 @@ public void GenerateSvgForLineCharts_AxisAlign_sheet1() { var ws = p.Workbook.Worksheets[0]; - var ix = 3; - var c = ws.Drawings[ix]; - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\HorizontalAxisChartForSvg{ix++}.svg", svg); + //var ix = 3; + //var c = ws.Drawings[ix]; + //var svg = c.ToSvg(); + //SaveTextFileToWorkbook($"svg\\HorizontalAxisChartForSvg{ix++}.svg", svg); - //for (int i = 0; i < ws.Drawings.Count; i++) - //{ - // var c = ws.Drawings[i]; - // var svg = c.ToSvg(); - // SaveTextFileToWorkbook($"svg\\HorizontalAxisChartForSvg{i}.svg", svg); - //} + for (int i = 0; i < ws.Drawings.Count; i++) + { + var c = ws.Drawings[i]; + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\HorizontalAxisChartForSvg{i}.svg", svg); + } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 178a00e8a5..7fa2ce3995 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -501,12 +501,13 @@ private List GetAxisValueTextBoxes() switch (lblAlignment) { case OfficeOpenXml.eAxisLabelAlignment.Left: - tb.Left -= tb.Width; + tb.Left -= (tb.Width + majorWidth) / 2; break; case OfficeOpenXml.eAxisLabelAlignment.Center: - tb.Left -= tb.Width / 2; + tb.Left -= tb.Width / 2; break; case OfficeOpenXml.eAxisLabelAlignment.Right: + tb.Left += (tb.Width + majorWidth) / 2; break; } } @@ -559,17 +560,16 @@ private double GetAxisItemLeft(int i, OfficeOpenXml.Interfaces.Drawing.Text.Text { if (IsCatAx()) { - double majorWidth, majorHalf=0D; + double majorWidth; if (Axis.CrossingAxis == null || Axis.CrossingAxis.CrossBetween == eCrossBetween.Between) { majorWidth = Rectangle.Width / AxisValues.Count; - //majorHalf = majorWidth / 2; } else { majorWidth = Rectangle.Width / (AxisValues.Count - 1); } - var majorTickStartingPosition = Rectangle.Left + majorWidth * i + majorHalf; + var majorTickStartingPosition = Rectangle.Left + majorWidth * i; return majorTickStartingPosition; } else From 1e0beae6aac91c49e5681135e940ea8256539874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 16 Jun 2026 15:40:16 +0200 Subject: [PATCH 061/118] Fixed centering of piechart legend --- .../Chart/PieChartTests.cs | 71 +++---------------- .../Renderer/Chart/ChartLegendRenderer.cs | 42 +++++++---- 2 files changed, 38 insertions(+), 75 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs index 6f3e13eb75..bfe3fd61c3 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs @@ -19,18 +19,15 @@ public void ReadAndGenerateExcelPieChartSvgs() { var ws = p.Workbook.Worksheets[0]; - var manySlices = ws.Drawings["ManySlices"]; - var test = manySlices.ToSvg(); - SaveTextFileToWorkbook($"svg\\PieChartSvgALL\\s{0}_{ws.Name}_{manySlices.Name}.svg", test); - //for (int i = 0; i < p.Workbook.Worksheets.Count; i++) - //{ - // ws = p.Workbook.Worksheets[i]; - // foreach (ExcelChart c in ws.Drawings) - // { - // var svg = c.ToSvg(); - // SaveTextFileToWorkbook($"svg\\PieChartSvgALL\\s{i}_{ws.Name}_{c.Name}.svg", svg); - // } - //} + for (int i = 0; i < p.Workbook.Worksheets.Count; i++) + { + ws = p.Workbook.Worksheets[i]; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\PieChartSvgALL\\s{i}_{ws.Name}_{c.Name}.svg", svg); + } + } } } @@ -137,56 +134,6 @@ public void ReadLegendIssue() } } - //[TestMethod] - //public void BasicPieChart() - //{ - // ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - // using (var p = OpenTemplatePackage("BasicPieChart.xlsx")) - // { - // var ws = p.Workbook.Worksheets[0]; - // var renderer = new EPPlusImageRenderer.ImageRenderer(); - - // var pChart = ws.Drawings[0].As.Chart.PieChart; - - // var ser = pChart.Series[0]; - - // var legend = pChart.Legend; - // var entry = pChart.Legend.Entries; - // var pHeader = ser.Header; - // var pHeaderAddress = ser.HeaderAddress; - // var headerString = ser.GetHeaderString(); - - - // var ix = 0; - // foreach (ExcelChart c in ws.Drawings) - // { - // var svg = renderer.RenderDrawingToSvg(c); - // SaveTextFileToWorkbook($"svg\\BasicPieChart{ix++}.svg", svg); - // } - // } - //} - - //[TestMethod] - //public void Datalabels() - //{ - // ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - // using (var p = OpenTemplatePackage("PieChartDlblsOrig.xlsx")) - // { - // var ws = p.Workbook.Worksheets[0]; - // var renderer = new EPPlusImageRenderer.ImageRenderer(); - - // for (int i = 0; i < p.Workbook.Worksheets.Count; i++) - // { - // ws = p.Workbook.Worksheets[i]; - // foreach (ExcelChart c in ws.Drawings) - // { - // var svg = renderer.RenderDrawingToSvg(c); - // SaveTextFileToWorkbook($"svg\\PieChartDlbls\\s{i}_{ws.Name}_{c.Name}.svg", svg); - // } - // } - // } - //} - [TestMethod] public void Datalabels22() { diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index 2ced5030fa..2b7b265941 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -281,7 +281,7 @@ private void GetSerieSize(ExcelChartLegend l, int index, string text, ref double widest = tm.Width; } - if (tm.Height > highest) + if (tm.Height > highest) { highest = tm.Height; } @@ -353,8 +353,8 @@ internal DrawingLegendSerie SetLegendSeries(double entryWidth, double entryHeigh if (ix == 0) { SetPieLegend(ct, index, pSls, pos, s, sls, entryWidth, entryHeight, maxIconLength); - //pSls = null; - //sls = null; + pSls = null; + sls = null; } break; default: @@ -375,11 +375,11 @@ internal DrawingLegendSerie SetLegendSeries(double entryWidth, double entryHeigh break; } } - //if (sls != null) - //{ - // SeriesIcon.Add(sls); - //} - SeriesIcon.Add(sls); + if (sls != null) + { + SeriesIcon.Add(sls); + } + //SeriesIcon.Add(sls); pSls = sls; //else //{ @@ -522,8 +522,8 @@ private void SetPieLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe } } - double lastWidth = entryWidth; - double totalWidth = entryWidth; + double lastWidth = 0; + double totalWidth = 0; for (int i = 0; i < catValues.Count; i++) { @@ -533,10 +533,18 @@ private void SetPieLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe var si = GetPieSeriesIcon(ct, ps, pSls, lastWidth, entryHeight, i); sls = new DrawingLegendSerie(); var tbLeft = si.Left + maxIconLength + MarginIconText; - var tbTop = si.Top - (entryHeight - si.Height) / 2; + var tbTop = si.Top - ((entryHeight) / 2); + double tbWidth; - tbWidth = Rectangle.Bounds.Width; + if(i != catValues.Count -1) + { + tbWidth = Rectangle.Bounds.Width - tbLeft; + } + else + { + tbWidth = Rectangle.Bounds.Width; + } var tbHeight = tm.Height; sls.Textbox = new DrawingTextBody(Chart, Rectangle.Bounds, tbLeft, tbTop, tbWidth, tbHeight, false, true); @@ -549,7 +557,7 @@ private void SetPieLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe lastWidth = tbWidth + si.Width; - totalWidth += tbWidth + si.Width; + totalWidth += tbWidth + si.Width + maxIconLength + MarginIconText; var dp = ps.DataPoints[i]; @@ -560,6 +568,14 @@ private void SetPieLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe SeriesIcon.Add(sls); pSls = sls; } + + foreach(var icon in SeriesIcon) + { + icon.SeriesIcon.Bounds.Top = icon.SeriesIcon.Bounds.Top - ((entryHeight) / 4); + } + + Rectangle.Bounds.Width = SeriesIcon.Last().Textbox.Bounds.GetGlobalBoundingbox().Right - SeriesIcon[0].SeriesIcon.Bounds.GlobalLeft + 4; + Rectangle.Bounds.Left = (ChartRenderer.Bounds.Width / 2) - (totalWidth/2); pSls = null; sls = null; } From 8df3dc4bcad676b56f0909c6432664d1ab64957b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 16 Jun 2026 17:26:59 +0200 Subject: [PATCH 062/118] Start of datalabels for barcharts --- .../Chart/BarChartTests.cs | 16 +++++ .../BarColumnChartTypeDrawer.cs | 63 +++++++++++++------ 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index 6620be6406..c1cff2f41d 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -42,5 +42,21 @@ public void GenerateSvgForBarCharts2() } } } + + [TestMethod] + public void DatalabelBarCharts() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("BarChartForSvg.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + + var chartWithDataLabels = ws.Drawings["Chart 4"]; + + var svg = chartWithDataLabels.ToSvg(); + + SaveTextFileToWorkbook($"svg\\DataLableBarChart_sheet1_{chartWithDataLabels.Name}.svg", svg); + } + } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 8c7b661dcf..fbe28c93e8 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -1,18 +1,16 @@ using EPPlus.DrawingRenderer.RenderItems; using EPPlus.Export.ImageRenderer.RenderItems.SvgItem; using EPPlus.Graphics; +using EPPlus.Graphics.Geometry; using EPPlusImageRenderer; using EPPlusImageRenderer.RenderItems; using EPPlusImageRenderer.Svg; -using OfficeOpenXml.DigitalSignatures; using OfficeOpenXml.Drawing.Chart; -using OfficeOpenXml.FormulaParsing.Excel.Functions.Finance; -using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; using OfficeOpenXml.Utils.TypeConversion; using System; using System.Collections.Generic; -using System.Linq; -using static OfficeOpenXml.ExcelErrorValue; + namespace EPPlus.Export.ImageRenderer.Svg.Chart { internal class BarColumnChartTypeDrawer : ChartTypeDrawer @@ -27,6 +25,8 @@ internal BarColumnChartTypeDrawer(ChartRenderer svgChart, ExcelBarChart chartTyp _catValues = new List>(); _valValues = new List>(); + int serCounter = 0; + foreach (ExcelBarChartSerie serie in chartType.Series) { List valValue,catValue; @@ -35,13 +35,6 @@ internal BarColumnChartTypeDrawer(ChartRenderer svgChart, ExcelBarChart chartTyp _catValues.Add(catValue); _valValues.Add(valValue); - - - //if (serie.HasDataLabel) - //{ - // var datalabel = new ChartSerieDataLabelRenderer(svgChart, serie.DataLabel, svgChart.Bounds, serie, catValue, valValue, serCounter); - // serieDataLabels.Add(datalabel); - //} } if(chartType.IsTypeStacked()) @@ -78,13 +71,42 @@ internal override void DrawSeries() dataPointsPerSerie.Add(dataPoints); - //if (serie.HasDataLabel) - //{ - // for (int j = 0; j < dataPoints.Count; j++) - // { - // serieDataLabels[i].SetParentPoint(dataPoints[j], j); - // } - //} + int serCounter = 0; + + if (serie.HasDataLabel) + { + + var datalabel = new ChartSerieDataLabelRenderer(ChartRenderer, serie.DataLabel, ChartRenderer.Bounds, serie, _catValues[i], _valValues[i], serCounter++); + serieDataLabels.Add(datalabel); + + for (int j = 0; j < dataPoints.Count; j++) + { + + var middleHeight = dataPoints[j].Top + (dataPoints[j].Height / 2); + var middleRight = dataPoints[j].Left + (dataPoints[j].Width / 2); + + var furthestPoint = new Vector2(dataPoints[j].Right, middleHeight); + var startingPoint = new Vector2(dataPoints[j].Left, middleHeight); + + //var vectorRight = furthestPoint - startingPoint; + var vectorRight = Vector2.One; + + BoundingBox startingPointBound = new BoundingBox(dataPoints[j].Width, dataPoints[j].Height); + startingPointBound.Parent = dataPoints[j]; + startingPointBound.Top = middleHeight; + startingPointBound.Left = dataPoints[j].Width; + //var furthestRight = dataPoints[j].Right - dataPoints[j].Left; + + //var endPoint = new Vector2(furthestRight, middleHeight); + //var startPoint = new Vector2(0, middleHeight); + //var vectorRight = endPoint - startPoint; + //dataPoints[j].Width; + //var vectorRight = new Vector2(dataPoints[j].Right - dataPoints[j].Left, middleHeight); + serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); + } + + serCounter++; + } } foreach (var tr in Trendlines) @@ -258,6 +280,9 @@ private void AddBar(ExcelBarChart chartType, ExcelBarChartSerie serie, List Date: Wed, 17 Jun 2026 10:14:10 +0200 Subject: [PATCH 063/118] Fixed out-end for bar-charts --- .../BarColumnChartTypeDrawer.cs | 8 +- .../Chart/DataLabels/SvgDataLabelPoint.cs | 113 +++++++++--------- 2 files changed, 65 insertions(+), 56 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index fbe28c93e8..13ae0a6af6 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -88,13 +88,16 @@ internal override void DrawSeries() var furthestPoint = new Vector2(dataPoints[j].Right, middleHeight); var startingPoint = new Vector2(dataPoints[j].Left, middleHeight); - //var vectorRight = furthestPoint - startingPoint; - var vectorRight = Vector2.One; + var vectorRight = furthestPoint - startingPoint; BoundingBox startingPointBound = new BoundingBox(dataPoints[j].Width, dataPoints[j].Height); startingPointBound.Parent = dataPoints[j]; startingPointBound.Top = middleHeight; startingPointBound.Left = dataPoints[j].Width; + + startingPointBound.Width = 0; + startingPointBound.Height = 0; + //var furthestRight = dataPoints[j].Right - dataPoints[j].Left; //var endPoint = new Vector2(furthestRight, middleHeight); @@ -102,6 +105,7 @@ internal override void DrawSeries() //var vectorRight = endPoint - startPoint; //dataPoints[j].Width; //var vectorRight = new Vector2(dataPoints[j].Right - dataPoints[j].Left, middleHeight); + //serieDataLabels[i].SetParentPoint(startingPointBound, j); serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index d626579139..6da9469998 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -240,17 +240,17 @@ private void SetPositionBasic(BoundingBox point, eLabelPosition basicPosition) // Bounds.Top = dataLabelCenter.Y; // break; case eLabelPosition.Left: - Rectangle.Bounds.Left -= _txtBox.Width + (point.Width / 2); + Rectangle.Bounds.Left -= _txtBox.Width + (point.Width / 2d); break; case eLabelPosition.Right: case eLabelPosition.BestFit: - Rectangle.Bounds.Left += _txtBox.Width / 2 + point.Width; + Rectangle.Bounds.Left += (_txtBox.Width / 2d) + point.Width; break; case eLabelPosition.Top: - Rectangle.Bounds.Top -= (point.Height + _txtBox.Height) / 2; + Rectangle.Bounds.Top -= (point.Height + _txtBox.Height) / 2d; break; case eLabelPosition.Bottom: - Rectangle.Bounds.Top += (point.Height + _txtBox.Height) / 2; + Rectangle.Bounds.Top += (point.Height + _txtBox.Height) / 2d; break; default: throw new InvalidOperationException($"The datalabel position entered in SetPositionBasic: '{basicPosition}' is not a basic position"); @@ -272,6 +272,8 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V startPointDirection = startToEndDir / startToEndDir.Length; } + //Rectangle.Bounds.Left += 20; + //if (_parentShapeBounds != null) //{ // //shapeCenter @@ -318,64 +320,67 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V SetPositionBasic(parentPoint, _labelPosition); break; case eLabelPosition.InEnd: - if (startPointDirection.X == 0 && startPointDirection.Y == 0) - { - throw new InvalidOperationException("eLabelPosition.InEnd MUST have a direction." + - "Cannot be within End if EndPoint is undefined."); - } - var insidePos = startToEndDir * 0.15 * -1; - Rectangle.Bounds.Left += insidePos.X; - Rectangle.Bounds.Top += insidePos.Y; + //if (startPointDirection.X == 0 && startPointDirection.Y == 0) + //{ + // throw new InvalidOperationException("eLabelPosition.InEnd MUST have a direction." + + // "Cannot be within End if EndPoint is undefined."); + //} + //var insidePos = startToEndDir * 0.15 * -1; + //Rectangle.Bounds.Left += insidePos.X; + //Rectangle.Bounds.Top += insidePos.Y; break; case eLabelPosition.OutEnd: - if (startPointDirection.X == 0 && startPointDirection.Y == 0) - { - throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a direction." + - "Cannot be within End if EndPoint is undefined."); - } - if (startPointDirection.X == 0 && startPointDirection.Y == 0) - { - throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a direction." + - "Cannot be within End if EndPoint is undefined."); - } - Rectangle.Bounds.Left += startToEndDir.X * 0.15; - Rectangle.Bounds.Top += startToEndDir.Y * 0.15; - //if (parentShape == null) + //if (startPointDirection.X == 0 && startPointDirection.Y == 0) //{ - // throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a parentShape"); + // throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a direction." + + // "Cannot be within End if EndPoint is undefined."); //} - - //if (startPointDirection.X != 0) + //if (startPointDirection.X == 0 && startPointDirection.Y == 0) //{ - // //If endPoint is to the left - // if (startPointDirection.X < 0) - // { - // //We must place to the left - // SetPositionBasic(parentPoint, eLabelPosition.Left); - // } - // //if endpoint is to the right - // else - // { - // //We must place to the right - // SetPositionBasic(parentPoint, eLabelPosition.Right); - // } + // throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a direction." + + // "Cannot be within End if EndPoint is undefined."); //} - - //if (startPointDirection.Y != 0) + //Rectangle.Bounds.Left += startToEndDir.X * 0.15; + //Rectangle.Bounds.Top += startToEndDir.Y * 0.15; + //if (parentShape == null) //{ - // //If endpoint is on Top - // if (startPointDirection.Y < 0) - // { - // //We must place on Top - // SetPositionBasic(parentPoint, eLabelPosition.Top); - // } - // //If endpoint is on bottom - // else - // { - // //We must place on Bottom - // SetPositionBasic(parentPoint, eLabelPosition.Bottom); - // } + // throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a parentShape"); //} + + if (startPointDirection.X != 0) + { + //If endPoint is to the left + if (startPointDirection.X < 0) + { + //We must place to the left + SetPositionBasic(parentPoint, eLabelPosition.Left); + } + //if endpoint is to the right + else + { + //We must place to the right + SetPositionBasic(parentPoint, eLabelPosition.Right); + } + } + + //Rectangle.Top = 0; + //Rectangle.Height = 0; + + if (startPointDirection.Y != 0) + { + //If endpoint is on Top + if (startPointDirection.Y < 0) + { + //We must place on Top + SetPositionBasic(parentPoint, eLabelPosition.Top); + } + //If endpoint is on bottom + else + { + //We must place on Bottom + SetPositionBasic(parentPoint, eLabelPosition.Bottom); + } + } break; default: throw new InvalidOperationException($"The datalabel position {_labelPosition} has not been implemented yet"); From 2b6ff324dc7f5b31b231b72118247edb64233dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 17 Jun 2026 10:38:15 +0200 Subject: [PATCH 064/118] Functional datalabels --- .../Chart/BarChartTests.cs | 2 +- .../Chart/DataLabels/SvgDataLabelPoint.cs | 62 +++++++++++++++++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index c1cff2f41d..aced5fdf3f 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -47,7 +47,7 @@ public void GenerateSvgForBarCharts2() public void DatalabelBarCharts() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("BarChartForSvg.xlsx")) + using (var p = OpenTemplatePackage("BarChartForSvgDatalabels.xlsx")) { var ws = p.Workbook.Worksheets[0]; diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 6da9469998..a8b79be284 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -240,7 +240,7 @@ private void SetPositionBasic(BoundingBox point, eLabelPosition basicPosition) // Bounds.Top = dataLabelCenter.Y; // break; case eLabelPosition.Left: - Rectangle.Bounds.Left -= _txtBox.Width + (point.Width / 2d); + Rectangle.Bounds.Left -= (_txtBox.Width/2) + (point.Width / 2d); break; case eLabelPosition.Right: case eLabelPosition.BestFit: @@ -319,7 +319,64 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V case eLabelPosition.Bottom: SetPositionBasic(parentPoint, _labelPosition); break; + case eLabelPosition.InBase: + var endToStartVector = startToEndDir * -1; + //Rectangle.Left *= endToStartVector.X; + //Rectangle.Top *= endToStartVector.Y; + if (startPointDirection.X != 0) + { + Rectangle.Left += endToStartVector.X; + //If basePoint is to the left + if (startPointDirection.X < 0) + { + //We must place to the left + SetPositionBasic(new BoundingBox(0,0), eLabelPosition.Left); + } + //if basePoint is to the right + else + { + //We must place to the right + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Right); + } + } + + if (startPointDirection.Y != 0) + { + Rectangle.Top += endToStartVector.Y; + } + break; case eLabelPosition.InEnd: + if (startPointDirection.X != 0) + { + //If endPoint is to the left + if (startPointDirection.X < 0) + { + //We must place to the right + SetPositionBasic(parentPoint, eLabelPosition.Right); + } + //if endpoint is to the right + else + { + //We must place to the left + SetPositionBasic(parentPoint, eLabelPosition.Left); + } + } + + if (startPointDirection.Y != 0) + { + //If endpoint is on Top + if (startPointDirection.Y < 0) + { + //We must place on bottom + SetPositionBasic(parentPoint, eLabelPosition.Bottom); + } + //If endpoint is on bottom + else + { + //We must place on top + SetPositionBasic(parentPoint, eLabelPosition.Top); + } + } //if (startPointDirection.X == 0 && startPointDirection.Y == 0) //{ // throw new InvalidOperationException("eLabelPosition.InEnd MUST have a direction." + @@ -363,9 +420,6 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V } } - //Rectangle.Top = 0; - //Rectangle.Height = 0; - if (startPointDirection.Y != 0) { //If endpoint is on Top From a84b1d5c9806b687689e81b8daa1245142c212a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 17 Jun 2026 11:02:34 +0200 Subject: [PATCH 065/118] Columnn and bar charts correct --- .../Chart/BarChartTests.cs | 11 ++-- .../BarColumnChartTypeDrawer.cs | 52 ++++++++++++------- .../Chart/DataLabels/SvgDataLabelPoint.cs | 12 +++++ 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index aced5fdf3f..2996fde16c 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -51,11 +51,12 @@ public void DatalabelBarCharts() { var ws = p.Workbook.Worksheets[0]; - var chartWithDataLabels = ws.Drawings["Chart 4"]; - - var svg = chartWithDataLabels.ToSvg(); - - SaveTextFileToWorkbook($"svg\\DataLableBarChart_sheet1_{chartWithDataLabels.Name}.svg", svg); + var ix = 0; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); + } } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 13ae0a6af6..d853be1a98 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -5,6 +5,7 @@ using EPPlusImageRenderer; using EPPlusImageRenderer.RenderItems; using EPPlusImageRenderer.Svg; +using OfficeOpenXml.DigitalSignatures; using OfficeOpenXml.Drawing.Chart; using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; using OfficeOpenXml.Utils.TypeConversion; @@ -73,6 +74,8 @@ internal override void DrawSeries() int serCounter = 0; + var isColumn = ((ExcelBarChart)_chartType).IsTypeColumn(); + if (serie.HasDataLabel) { @@ -81,32 +84,41 @@ internal override void DrawSeries() for (int j = 0; j < dataPoints.Count; j++) { - - var middleHeight = dataPoints[j].Top + (dataPoints[j].Height / 2); - var middleRight = dataPoints[j].Left + (dataPoints[j].Width / 2); - var furthestPoint = new Vector2(dataPoints[j].Right, middleHeight); - var startingPoint = new Vector2(dataPoints[j].Left, middleHeight); + if (isColumn == false) + { + var middleHeight = dataPoints[j].Top + (dataPoints[j].Height / 2); + + var furthestPoint = new Vector2(dataPoints[j].Right, middleHeight); + var startingPoint = new Vector2(dataPoints[j].Left, middleHeight); + + var vectorRight = furthestPoint - startingPoint; + + BoundingBox startingPointBound = new BoundingBox(0, 0); + startingPointBound.Parent = dataPoints[j]; + startingPointBound.Top = middleHeight; + startingPointBound.Left = dataPoints[j].Width; - var vectorRight = furthestPoint - startingPoint; + serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); + } + else + { + var middleRight = dataPoints[j].Left + (dataPoints[j].Width / 2); - BoundingBox startingPointBound = new BoundingBox(dataPoints[j].Width, dataPoints[j].Height); - startingPointBound.Parent = dataPoints[j]; - startingPointBound.Top = middleHeight; - startingPointBound.Left = dataPoints[j].Width; + var furthestPoint = new Vector2(middleRight, dataPoints[j].Top); + var startingPoint = new Vector2(middleRight, dataPoints[j].Bottom); - startingPointBound.Width = 0; - startingPointBound.Height = 0; + var vectorRight = furthestPoint - startingPoint; - //var furthestRight = dataPoints[j].Right - dataPoints[j].Left; + BoundingBox startingPointBound = new BoundingBox(0, 0); + startingPointBound.Parent = dataPoints[j]; + startingPointBound.Top = dataPoints[j].Top; + startingPointBound.Left = middleRight; - //var endPoint = new Vector2(furthestRight, middleHeight); - //var startPoint = new Vector2(0, middleHeight); - //var vectorRight = endPoint - startPoint; - //dataPoints[j].Width; - //var vectorRight = new Vector2(dataPoints[j].Right - dataPoints[j].Left, middleHeight); - //serieDataLabels[i].SetParentPoint(startingPointBound, j); - serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); + serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); + + //serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); + } } serCounter++; diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index a8b79be284..c139168d56 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -343,6 +343,18 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V if (startPointDirection.Y != 0) { Rectangle.Top += endToStartVector.Y; + //If endpoint is on Top + if (startPointDirection.Y < 0) + { + //We must place on bottom + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Top); + } + //If endpoint is on bottom + else + { + //We must place on top + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Bottom); + } } break; case eLabelPosition.InEnd: From f1ab07ffdd6a81bfe80219ea16d3caf7a71dd9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 17 Jun 2026 13:37:27 +0200 Subject: [PATCH 066/118] fixed stacked column pos datalabels --- .../Chart/BarChartTests.cs | 8 +++-- .../BarColumnChartTypeDrawer.cs | 31 +++++++++---------- .../DataLabels/ChartSerieDataLabelRenderer.cs | 5 +-- .../Chart/DataLabels/SvgDataLabelPoint.cs | 15 ++++++++- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index 2996fde16c..3b72bcb1dc 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -50,11 +50,13 @@ public void DatalabelBarCharts() using (var p = OpenTemplatePackage("BarChartForSvgDatalabels.xlsx")) { var ws = p.Workbook.Worksheets[0]; - + var drawings = ws.Drawings; var ix = 0; - foreach (ExcelChart c in ws.Drawings) + //var svg = drawings[ix].ToSvg(); + //SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); + for (int i = ix; i < drawings.Count; i++) { - var svg = c.ToSvg(); + var svg = drawings[i].ToSvg(); SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index d853be1a98..6a4945e4bd 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -78,46 +78,45 @@ internal override void DrawSeries() if (serie.HasDataLabel) { - var datalabel = new ChartSerieDataLabelRenderer(ChartRenderer, serie.DataLabel, ChartRenderer.Bounds, serie, _catValues[i], _valValues[i], serCounter++); serieDataLabels.Add(datalabel); for (int j = 0; j < dataPoints.Count; j++) { - if (isColumn == false) + if (isColumn == true) { - var middleHeight = dataPoints[j].Top + (dataPoints[j].Height / 2); + var middleRight = dataPoints[j].Left + (dataPoints[j].Width / 2); - var furthestPoint = new Vector2(dataPoints[j].Right, middleHeight); - var startingPoint = new Vector2(dataPoints[j].Left, middleHeight); + var furthestPoint = new Vector2(middleRight, dataPoints[j].Top); + var startingPoint = new Vector2(middleRight, dataPoints[j].Bottom); - var vectorRight = furthestPoint - startingPoint; + var vectorTop = furthestPoint - startingPoint; + + vectorTop = new Vector2(0, vectorTop.Y); BoundingBox startingPointBound = new BoundingBox(0, 0); startingPointBound.Parent = dataPoints[j]; - startingPointBound.Top = middleHeight; - startingPointBound.Left = dataPoints[j].Width; + startingPointBound.Top = dataPoints[j].Top; + startingPointBound.Left = middleRight; - serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); + serieDataLabels[i].SetParentVector(startingPointBound, j, vectorTop); } else { - var middleRight = dataPoints[j].Left + (dataPoints[j].Width / 2); + var middleHeight = dataPoints[j].Top + (dataPoints[j].Height / 2); - var furthestPoint = new Vector2(middleRight, dataPoints[j].Top); - var startingPoint = new Vector2(middleRight, dataPoints[j].Bottom); + var furthestPoint = new Vector2(dataPoints[j].Right, middleHeight); + var startingPoint = new Vector2(dataPoints[j].Left, middleHeight); var vectorRight = furthestPoint - startingPoint; BoundingBox startingPointBound = new BoundingBox(0, 0); startingPointBound.Parent = dataPoints[j]; - startingPointBound.Top = dataPoints[j].Top; - startingPointBound.Left = middleRight; + startingPointBound.Top = middleHeight; + startingPointBound.Left = dataPoints[j].Width; serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); - - //serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs index b2ef35241e..7c1d2d3219 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs @@ -31,10 +31,11 @@ public ChartSerieDataLabelRenderer(ChartRenderer chart, ExcelChartSerieDataLabel if (dlblSerie.TextBody.Paragraphs.Count != 0) { defaultParagraph = dlblSerie.TextBody.Paragraphs[0]; - dlblSerie.TextBody.GetInsetsInPoints(out double l, out double top, out double right, out double bottom); - _defaultMargins = new BoundingBox(l, top, right, bottom); } + dlblSerie.TextBody.GetInsetsInPoints(out double l, out double top, out double right, out double bottom); + _defaultMargins = new BoundingBox(l, top, right, bottom); + if (dlblSerie.DataLabels.Count == 0 && serie.NumberOfItems > 0) { for (int i = 0; i < serie.NumberOfItems; i++) diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index c139168d56..99f4171a06 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -112,7 +112,14 @@ internal void ImportDataLabel(ExcelChartStandardSerie serie, ExcelChartDataLabel if (txtBox.TextBody.Paragraphs.Count == 0) { - txtBox.ImportParagraph(defaultParagraph, 0, finalString); + if(defaultParagraph == null) + { + txtBox.TextBody.AddParagraph(finalString); + } + else + { + txtBox.ImportParagraph(defaultParagraph, 0, finalString); + } //txtBox.TextBody.AddParagraph(0, finalString); } else if (txtBox.TextBody.Paragraphs.Count == 1) @@ -303,6 +310,12 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V //Half and invert dataLabelCenter = ((startToEndDir * 0.5d) * -1d); } + else if (startToEndDir.Y != 0) + { + //Half and invert + dataLabelCenter = ((startToEndDir * 0.5d) * -1d); + } + Rectangle.Bounds.Left += dataLabelCenter.X; Rectangle.Bounds.Top += dataLabelCenter.Y; break; From c5769423e013e717a2e2dee2bed76b22b0219bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 17 Jun 2026 16:09:26 +0200 Subject: [PATCH 067/118] Start of better datalabel system+debug --- .../Chart/BarChartTests.cs | 2 +- .../BarColumnChartTypeDrawer.cs | 19 ++- .../DataLabels/ChartSerieDataLabelRenderer.cs | 8 ++ .../Chart/DataLabels/SvgDataLabelPoint.cs | 122 +++++++++++++++++- 4 files changed, 144 insertions(+), 7 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index 3b72bcb1dc..5503370393 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -51,7 +51,7 @@ public void DatalabelBarCharts() { var ws = p.Workbook.Worksheets[0]; var drawings = ws.Drawings; - var ix = 0; + var ix = 2; //var svg = drawings[ix].ToSvg(); //SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); for (int i = ix; i < drawings.Count; i++) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 6a4945e4bd..59adfc5398 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -95,12 +95,21 @@ internal override void DrawSeries() vectorTop = new Vector2(0, vectorTop.Y); - BoundingBox startingPointBound = new BoundingBox(0, 0); - startingPointBound.Parent = dataPoints[j]; - startingPointBound.Top = dataPoints[j].Top; - startingPointBound.Left = middleRight; + Transform basePoint = new Transform(); + Transform endPoint = new Transform(); + + basePoint.Parent = dataPoints[j]; + endPoint.Parent = dataPoints[j]; + + endPoint.Position = new Vector2(middleRight, endPoint.Position.Y); + basePoint.Position = new Vector2(middleRight, endPoint.Position.Y + dataPoints[j].Height); + //BoundingBox startingPointBound = new BoundingBox(0, 0); + //startingPointBound.Parent = dataPoints[j]; + //startingPointBound.Top = dataPoints[j].Top; + //startingPointBound.Left = middleRight; - serieDataLabels[i].SetParentVector(startingPointBound, j, vectorTop); + serieDataLabels[i].SetDimensions(j, basePoint, endPoint); + //serieDataLabels[i].SetParentVector(startingPointBound, j, vectorTop); } else { diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs index 7c1d2d3219..196d0465c0 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs @@ -118,6 +118,14 @@ internal void SetParentShape(BoundingBox parentBounds, BoundingBox shapeEndPoint SetParentPoint(shapeEndPoint, index); } + internal void SetDimensions(int index, Transform basePoint, Transform endPoint) + { + if (dataLabels.Count > index) + { + dataLabels[index].SetShapeDimensions(basePoint, endPoint); + } + } + internal void SetParentVector(BoundingBox parentPoint, int index, Vector2 startToEndDir) { _startToEndDir = startToEndDir; diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 99f4171a06..9ac37e01ad 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -13,6 +13,7 @@ using OfficeOpenXml.Utils.EnumUtils; using System; using System.Collections.Generic; +using System.Net; namespace EPPlus.Export.ImageRenderer.RenderItems.SvgItem { @@ -264,13 +265,115 @@ private void SetPositionBasic(BoundingBox point, eLabelPosition basicPosition) } } + + RectRenderItem originPointRect; + RectRenderItem basePositionRect; + RectRenderItem endPositionRect; + RectRenderItem centerPositionRect; + + + private RectRenderItem GenerateDebugRenderItem(BoundingBox parent, string fillColor) + { + var pointRect = new RectRenderItem(parent); + pointRect.Width = 10d; + pointRect.Height = 10d; + pointRect.FillColor = fillColor; + pointRect.Left = -5d; + pointRect.Top = -5d; + return pointRect; + } + + + private void CreateDebugPoints(Transform basePoint, Transform endPoint) + { + originPointRect = GenerateDebugRenderItem(_parentPoint, "darkRed"); + basePositionRect = GenerateDebugRenderItem(_parentPoint, "darkGreen"); + basePositionRect.Left += basePoint.LocalPosition.X; + basePositionRect.Top += basePoint.LocalPosition.Y; + endPositionRect = GenerateDebugRenderItem(_parentPoint, "darkBlue"); + endPositionRect.Left += endPoint.LocalPosition.X; + endPositionRect.Top += endPoint.LocalPosition.Y; + endPositionRect.BorderWidth = 2d; + endPositionRect.BorderColor = "cyan"; + + } + + /// + /// + /// + internal void SetShapeDimensions(Transform basePoint, Transform endPoint) + { + if(basePoint.Parent != endPoint.Parent) + { + throw new InvalidOperationException("basePoint and endPoint have different parents. " + + "Please ensure that they share the same parent"); + } + + + //--- Set parent point --- + _parentPoint = new BoundingBox(); + _parentPoint.Parent = basePoint.Parent.Parent; + _parentPoint.Left = basePoint.Parent.LocalPosition.X; + _parentPoint.Top = basePoint.Parent.LocalPosition.Y; + Rectangle.Bounds.Parent = _parentPoint; + //--- + + //--- Calculate vectors and center point --- + var endVector = endPoint.LocalPosition; + var baseVector = basePoint.LocalPosition; + + var endToBaseVector = baseVector - endVector; + var centerVector = endToBaseVector * 0.5d; + + //Translate from end point towards base point by 50% to find the center point + Transform centerPoint = new Transform(endPoint.LocalPosition + centerVector, endPoint.LocalPosition + centerVector); + centerPoint.Parent = basePoint.Parent; + //--- + + //At this point our rectangle globally is centered on the top-left of the object. + //And endVector is the top center position. + + //--- Visualize positions for debugging purposes + CreateDebugPoints(basePoint, endPoint); + centerPositionRect = GenerateDebugRenderItem(_parentPoint, "Purple"); + centerPositionRect.Left += centerPoint.LocalPosition.X; + centerPositionRect.Top += centerPoint.LocalPosition.Y; + //--- + + switch (_labelPosition) + { + case eLabelPosition.Center: + Rectangle.Bounds.Left += centerPoint.LocalPosition.X; + Rectangle.Bounds.Top += centerPoint.LocalPosition.Y; + break; + case eLabelPosition.Left: + break; + case eLabelPosition.Right: + case eLabelPosition.BestFit: + break; + case eLabelPosition.Top: + break; + case eLabelPosition.Bottom: + break; + case eLabelPosition.InBase: + break; + case eLabelPosition.InEnd: + break; + case eLabelPosition.OutEnd: + + + break; + default: + throw new InvalidOperationException($"The datalabel position {_labelPosition} has not been implemented yet"); + } + } + internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, Vector2 startToEndDir) { Rectangle.Bounds.Parent = parentPoint; _parentPoint = parentPoint; _parentShapeBounds = parentShape; - var dataLabelCenter = new Vector2(Rectangle.Bounds.Left, Rectangle.Bounds.Top); Vector2 startPointDirection = Vector2.Zero; @@ -541,6 +644,23 @@ public override void AppendRenderItems(List renderItems) var titleItemOrigin = new TitleRenderItem("DataLabel originpoint"); parentPointGroup.AddChildItem(titleItemOrigin); + if(originPointRect != null) + { + parentPointGroup.AddChildItem(originPointRect); + } + if(basePositionRect != null) + { + parentPointGroup.AddChildItem(basePositionRect); + } + if(endPositionRect != null) + { + parentPointGroup.AddChildItem(endPositionRect); + } + if(centerPositionRect != null) + { + parentPointGroup.AddChildItem(centerPositionRect); + } + renderItems.Add(parentPointGroup); var group = new GroupRenderItem(Rectangle.Bounds); From f8afb27fd85575d690fd1db66b3964df532dec54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Wed, 17 Jun 2026 16:16:07 +0200 Subject: [PATCH 068/118] Started Work on error bars --- .../Constants/PatternArrays.cs | 1 - .../Drawing/Chart/ExcelChartNumericSource.cs | 59 +++++++- .../Renderer/Chart/ChartAxisRenderer.cs | 16 +- .../Renderer/Chart/ChartDrawingObject.cs | 23 +++ .../Renderer/Chart/ChartLegendRenderer.cs | 24 +-- .../Renderer/Chart/ChartPlotareaRenderer.cs | 4 +- .../Renderer/Chart/ChartTitleRenderer.cs | 13 +- .../BarColumnChartTypeDrawer.cs | 14 +- .../ChartTypeDrawers/ChartErrorBarRenderer.cs | 139 ++++++++++++++++++ .../Chart/ChartTypeDrawers/ChartTypeDrawer.cs | 19 +++ .../ChartTypeDrawers/LineChartTypeDrawer.cs | 14 +- .../Trendlines/ChartTrendlineRenderer.cs | 24 +-- .../DrawingRenderItemExtentions.cs | 14 +- .../Export/HtmlExport/RangeExporterTests.cs | 1 - 14 files changed, 292 insertions(+), 73 deletions(-) create mode 100644 src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartErrorBarRenderer.cs diff --git a/src/EPPlus.DrawingRenderer/Constants/PatternArrays.cs b/src/EPPlus.DrawingRenderer/Constants/PatternArrays.cs index a8ccf368b7..0992f78af8 100644 --- a/src/EPPlus.DrawingRenderer/Constants/PatternArrays.cs +++ b/src/EPPlus.DrawingRenderer/Constants/PatternArrays.cs @@ -15,7 +15,6 @@ namespace DrawingRenderer.Constants { internal static class PatternArrays { - internal static readonly short[][] Pct30 = new short[][] { new short[] { 0, 0, 0, 1 }, diff --git a/src/EPPlus/Drawing/Chart/ExcelChartNumericSource.cs b/src/EPPlus/Drawing/Chart/ExcelChartNumericSource.cs index 07bfe3a79c..ba0a57cac9 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartNumericSource.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartNumericSource.cs @@ -13,6 +13,8 @@ Date Author Change using System.Xml; using System.Globalization; using System; +using System.Collections.Generic; +using OfficeOpenXml.Utils.TypeConversion; namespace OfficeOpenXml.Drawing.Chart { @@ -40,7 +42,7 @@ internal ExcelChartNumericSource(XmlNamespaceManager nameSpaceManager, XmlNode t case "numRef": _formatCode = GetXmlNodeString(_path + "/c:numRef/c:numCache/c:formatCode"); break; - } + } } } /// @@ -164,6 +166,61 @@ public string FormatCode _formatCode=value; } } + internal List GetValuesList(ExcelWorkbook wb) + { + var list = new List(); + var vs = ValuesSource; + if (_sourceElement?.LocalName=="numLit") + { + if (ValuesSource?.Length > 2) + { + var source = ValuesSource.Substring(1, ValuesSource.Length - 2).Split(','); + foreach(var s in source) + { + if(double.TryParse(s.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out double d)) + { + list.Add(d); + } + else + { + list.Add(0); + } + } + } + } + else + { + var adr = GetXmlNodeString($"{_path}/c:numRef/c:f"); + if (!string.IsNullOrEmpty(adr)) + { + if (ExcelCellBase.IsValidAddress(adr)) + { + var address = new ExcelAddressBase(adr); + var ws = wb.Worksheets[address.WorkSheetName]; + if (ws != null) + { + var range = ws.Cells[address.Address]; + for (var r = 0; r < range.Start.Row; r++) + { + for (var c = 0; c < range.Start.Row; c++) + { + var v = range.Offset(r, c); + if (ConvertUtil.IsExcelNumeric(v.Value)) + { + list.Add(ConvertUtil.GetValueDouble(v.Value, false)); + } + else + { + list.Add(0D); + } + } + } + } + } + } + } + return list; + } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 7fa2ce3995..d665ab1fef 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -140,7 +140,7 @@ internal ChartAxisRenderer(ChartRenderer sc, ExcelChartAxisStandard ax) : base(s Rectangle.FillColor = "none"; Line = new LineRenderItem(Rectangle.Bounds); - Line.SetDrawingPropertiesBorder(ChartRenderer.Theme, ax.Border, sc.Chart.StyleManager.Style.Title.BorderReference.Color, ax.Border.Fill.Style != eFillStyle.NoFill, 1); + Line.SetDrawingPropertiesBorder(ChartRenderer.Theme, ax.Border, sc.Chart.StyleManager.Style?.Title.BorderReference.Color, ax.Border.Fill.Style != eFillStyle.NoFill, 1); if(Line.BorderWidth < 1) { Line.BorderWidth = 1; @@ -311,7 +311,7 @@ internal void AddTickmarksAndValues(List DefItems) if(Axis.HasMajorGridlines) { - MajorGridlinePositions = AddGridlines(MajorUnit, double.NaN, Axis.MajorGridlines, Chart.StyleManager.Style.GridlineMajor); + MajorGridlinePositions = AddGridlines(MajorUnit, double.NaN, Axis.MajorGridlines, Chart.StyleManager.Style?.GridlineMajor); } if ((Axis.HasMinorGridlines)) @@ -462,7 +462,7 @@ private List GetAxisValueTextBoxes() tb.ImportParagraph(p, 0, v); //tb.TextBody.Paragraphs[0].AddText(v, Axis.Font); - tb.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, Axis.Fill, axisStyle.FillReference.Color); + tb.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, Axis.Fill, axisStyle?.FillReference.Color); if(widest < tb.Width) { @@ -734,7 +734,7 @@ private List AddTickmarks(double units, eTimeUnit? dateUnit, dou tm.Y1 = y1; tm.X2 = x2; tm.Y2 = y2; - tm.SetDrawingPropertiesBorder(ChartRenderer.Theme, Axis.Border, axisStyle.BorderReference.Color, true); + tm.SetDrawingPropertiesBorder(ChartRenderer.Theme, Axis.Border, axisStyle?.BorderReference.Color, true); if(tm.BorderWidth<1) //Excel seems to have this as minimum width for tick marks, so we enforce it here to make sure they are visible. { tm.BorderWidth = 1; @@ -837,7 +837,7 @@ private List AddGridlines(double units, double parentUnit, ExcelDraw tm.Y1 = y1; tm.X2 = x2; tm.Y2 = y2; - tm.SetDrawingPropertiesBorder(ChartRenderer.Theme, lineItem, styleEntry.BorderReference.Color, true, lineItem.Width); + tm.SetDrawingPropertiesBorder(ChartRenderer.Theme, lineItem, styleEntry?.BorderReference.Color, true, lineItem.Width); tm.DefId = id; @@ -875,13 +875,13 @@ private ExcelChartStyleEntry GetAxisStyleEntry() switch (Axis.AxisType) { case eAxisType.Cat: - axisStyle = Chart.StyleManager.Style.CategoryAxis; + axisStyle = Chart.StyleManager.Style?.CategoryAxis; break; case eAxisType.Serie: - axisStyle = Chart.StyleManager.Style.SeriesAxis; + axisStyle = Chart.StyleManager.Style?.SeriesAxis; break; default: - axisStyle = Chart.StyleManager.Style.ValueAxis; + axisStyle = Chart.StyleManager.Style?.ValueAxis; break; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs index 6b38e74c7a..bf5cec5c4c 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs @@ -18,7 +18,9 @@ Date Author Change using OfficeOpenXml.Drawing; using OfficeOpenXml.Drawing.Chart; using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; +using OfficeOpenXml.Utils.TypeConversion; using System.Collections.Generic; +using System.Linq; namespace EPPlusImageRenderer.Svg { @@ -88,5 +90,26 @@ protected static RectRenderItem GetRectFromManualLayout(ChartRenderer sc, ExcelL rect.Height = bounds.Height * ml.GetHeight() / 100; return rect; } + /// + /// Get the X serie values. If the values are not numeric, return a serie with the index values (1,2,3,...). Trendline calculation requires numeric X values, but Excel allows non-numeric X values for trendlines, in which case it uses the index values as X for calculation. + /// + /// Input values + /// Output doubles + internal List GetXSerie(List xSerie) + { + var l = new List(); + for (int i = 0; i < xSerie.Count; i++) + { + if (ConvertUtil.IsExcelNumeric(xSerie[i])) + { + l.Add(ConvertUtil.GetValueDouble(xSerie[i])); + } + else + { + return xSerie.Select((x, index) => (double)(index + 1)).ToList(); + } + } + return l; + } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index 2ced5030fa..42a955ba6d 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -84,8 +84,8 @@ internal ChartLegendRenderer(ChartRenderer sc, bool isDataLabelLegend = false) : //Bounds.Height = Rectangle.Height; //Rectangle.Bounds.Left = Rectangle.Bounds.Top = 0; - Rectangle.SetDrawingPropertiesFill(sc.Theme, l.Fill, sc.Chart.StyleManager.Style.Title.FillReference.Color); - Rectangle.SetDrawingPropertiesBorder(sc.Theme, l.Border, sc.Chart.StyleManager.Style.Title.BorderReference.Color, l.Border.Fill.Style != eFillStyle.NoFill, 0.75); + Rectangle.SetDrawingPropertiesFill(sc.Theme, l.Fill, sc.Chart.StyleManager.Style?.Title.FillReference.Color); + Rectangle.SetDrawingPropertiesBorder(sc.Theme, l.Border, sc.Chart.StyleManager.Style?.Title.BorderReference.Color, l.Border.Fill.Style != eFillStyle.NoFill, 0.75); var pSls = SetLegendSeries(entryWidth, entryHeight); SetLegendTrendlines(entryWidth, entryHeight, pSls); @@ -553,8 +553,8 @@ private void SetPieLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe var dp = ps.DataPoints[i]; - sls.SeriesIcon.SetDrawingPropertiesFill(ChartRenderer.Theme, dp.Fill, ct.As.Chart.PieChart.StyleManager.Style.DataPoint.FillReference.Color); - sls.SeriesIcon.SetDrawingPropertiesBorder(ChartRenderer.Theme, dp.Border, ct.As.Chart.PieChart.StyleManager.Style.DataPoint.BorderReference.Color, true); + sls.SeriesIcon.SetDrawingPropertiesFill(ChartRenderer.Theme, dp.Fill, ct.As.Chart.PieChart.StyleManager.Style?.DataPoint.FillReference.Color); + sls.SeriesIcon.SetDrawingPropertiesBorder(ChartRenderer.Theme, dp.Border, ct.As.Chart.PieChart.StyleManager.Style?.DataPoint.BorderReference.Color, true); sls.SeriesIcon.SetDrawingPropertiesEffects(ChartRenderer.Theme, dp.Effect); SeriesIcon.Add(sls); @@ -642,8 +642,8 @@ private void SetBarLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLe private LineRenderItem GetLineSeriesIcon(ExcelChart ct, ExcelChartStandardSerie cStandardSerie, DrawingLegendSerie pSls, double entryWidth, double entryHeight) { var line = new LineRenderItem(Rectangle.Bounds); - line.SetDrawingPropertiesFill(ChartRenderer.Theme, cStandardSerie.Fill, Chart.StyleManager.Style.SeriesLine.FillReference.Color); - line.SetDrawingPropertiesBorder(ChartRenderer.Theme, cStandardSerie.Border, Chart.StyleManager.Style.SeriesLine.BorderReference.Color, cStandardSerie.Border.Fill.Style != eFillStyle.NoFill, 0.75); + line.SetDrawingPropertiesFill(ChartRenderer.Theme, cStandardSerie.Fill, Chart.StyleManager.Style?.SeriesLine.FillReference.Color); + line.SetDrawingPropertiesBorder(ChartRenderer.Theme, cStandardSerie.Border, Chart.StyleManager.Style?.SeriesLine.BorderReference.Color, cStandardSerie.Border.Fill.Style != eFillStyle.NoFill, 0.75); double iconTop = 0, iconLeft = 0; pSls?.GetIconTopLeft(out iconTop, out iconLeft); @@ -660,8 +660,8 @@ private LineRenderItem GetLineSeriesIcon(ExcelChart ct, ExcelChartStandardSerie private LineRenderItem GetTrendLineSeriesIcon(ExcelChart ct, ExcelChartTrendline tl, DrawingLegendSerie pSls, double entryWidth, double entryHeight) { var line = new LineRenderItem(Rectangle.Bounds); - line.SetDrawingPropertiesFill(ChartRenderer.Theme, tl.Fill, Chart.StyleManager.Style.Trendline.FillReference.Color); - line.SetDrawingPropertiesBorder(ChartRenderer.Theme, tl.Border, Chart.StyleManager.Style.Trendline.BorderReference.Color, tl.Border.Fill.Style != eFillStyle.NoFill, 0.75); + line.SetDrawingPropertiesFill(ChartRenderer.Theme, tl.Fill, Chart.StyleManager.Style?.Trendline.FillReference.Color); + line.SetDrawingPropertiesBorder(ChartRenderer.Theme, tl.Border, Chart.StyleManager.Style?.Trendline.BorderReference.Color, tl.Border.Fill.Style != eFillStyle.NoFill, 0.75); double iconTop = 0, iconLeft = 0; pSls?.GetIconTopLeft(out iconTop, out iconLeft); @@ -700,8 +700,8 @@ private RectRenderItem GetPieSeriesIcon(ExcelChart ct, ExcelPieChartSerie pcS, D item.Width = iconHeight; item.Height = iconHeight; - item.SetDrawingPropertiesFill(ChartRenderer.Theme, pcS.Fill, Chart.StyleManager.Style.SeriesLine.FillReference.Color); - item.SetDrawingPropertiesBorder(ChartRenderer.Theme, pcS.Border, Chart.StyleManager.Style.SeriesLine.BorderReference.Color, pcS.Border.Fill.Style != eFillStyle.NoFill, 0.75); + item.SetDrawingPropertiesFill(ChartRenderer.Theme, pcS.Fill, Chart.StyleManager.Style?.SeriesLine.FillReference.Color); + item.SetDrawingPropertiesBorder(ChartRenderer.Theme, pcS.Border, Chart.StyleManager.Style?.SeriesLine.BorderReference.Color, pcS.Border.Fill.Style != eFillStyle.NoFill, 0.75); return item; } @@ -731,8 +731,8 @@ private RectRenderItem GetBarSeriesIcon(ExcelChart ct, ExcelChartStandardSerie c item.Width = iconHeight; item.Height = iconHeight; - item.SetDrawingPropertiesFill(ChartRenderer.Theme, cStandardSerie.Fill, Chart.StyleManager.Style.SeriesLine.FillReference.Color); - item.SetDrawingPropertiesBorder(ChartRenderer.Theme, cStandardSerie.Border, Chart.StyleManager.Style.SeriesLine.BorderReference.Color, cStandardSerie.Border.Fill.Style != eFillStyle.NoFill, 0.75); + item.SetDrawingPropertiesFill(ChartRenderer.Theme, cStandardSerie.Fill, Chart.StyleManager.Style?.SeriesLine.FillReference.Color); + item.SetDrawingPropertiesBorder(ChartRenderer.Theme, cStandardSerie.Border, Chart.StyleManager.Style?.SeriesLine.BorderReference.Color, cStandardSerie.Border.Fill.Style != eFillStyle.NoFill, 0.75); return item; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs index 22082fb41f..e1abb1c709 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs @@ -59,8 +59,8 @@ internal void SetPlotAreaRectangle() ChartRenderer.Legend.Rectangle.Top = Group.Top + rect.Height / 2 - ChartRenderer.Legend.Rectangle.Height / 2; } - rect.SetDrawingPropertiesFill(ChartRenderer.Theme, pa.Fill, ChartRenderer.Chart.StyleManager.Style.PlotArea.FillReference.Color); - rect.SetDrawingPropertiesBorder(ChartRenderer.Theme, pa.Border, ChartRenderer.Chart.StyleManager.Style.PlotArea.BorderReference.Color, pa.Border.Fill.Style != eFillStyle.NoFill, 0.75); + rect.SetDrawingPropertiesFill(ChartRenderer.Theme, pa.Fill, ChartRenderer.Chart.StyleManager.Style?.PlotArea.FillReference.Color); + rect.SetDrawingPropertiesBorder(ChartRenderer.Theme, pa.Border, ChartRenderer.Chart.StyleManager.Style?.PlotArea.BorderReference.Color, pa.Border.Fill.Style != eFillStyle.NoFill, 0.75); Rectangle = rect; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs index d71eb3c3cd..cd93d52462 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTitleRenderer.cs @@ -102,8 +102,8 @@ internal ChartTitleRenderer(ChartRenderer sc, ExcelChartTitleStandard t, string } } - Rectangle.SetDrawingPropertiesFill(sc.Theme, t.Fill, sc.Chart.StyleManager.Style.Title.FillReference.Color); - Rectangle.SetDrawingPropertiesBorder(sc.Theme, t.Border, sc.Chart.StyleManager.Style.Title.BorderReference.Color, t.Border.Fill.Style != eFillStyle.NoFill, 0.75); + Rectangle.SetDrawingPropertiesFill(sc.Theme, t.Fill, sc.Chart.StyleManager.Style?.Title.FillReference.Color); + Rectangle.SetDrawingPropertiesBorder(sc.Theme, t.Border, sc.Chart.StyleManager.Style?.Title.BorderReference.Color, t.Border.Fill.Style != eFillStyle.NoFill, 0.75); } private void SetAxisTitleRect(ChartRenderer sc, ChartAxisRenderer axis) @@ -221,9 +221,12 @@ public DrawingTextBox TextBox public override void AppendRenderItems(List renderItems) { var p = _title.DefaultTextBody.Paragraphs.FirstOrDefault(); - TextBox.TextBody.FontColorString = "#" + p.DefaultRunProperties.Fill.Color.ToColorString(); - TextBox.Rectangle.SetDrawingPropertiesFill(_svgChart.Theme,_title.Fill, _svgChart.Chart.StyleManager.Style.Title.FillReference.Color); - TextBox.Rectangle.SetDrawingPropertiesBorder(_svgChart.Theme, _title.Border, _svgChart.Chart.StyleManager.Style.Title.BorderReference.Color, _title.Border.Fill.Style != eFillStyle.NoFill, 0.75); + if (p != null) + { + TextBox.TextBody.FontColorString = "#" + p.DefaultRunProperties.Fill.Color.ToColorString(); + TextBox.Rectangle.SetDrawingPropertiesFill(_svgChart.Theme, _title.Fill, _svgChart.Chart.StyleManager.Style?.Title.FillReference.Color); + TextBox.Rectangle.SetDrawingPropertiesBorder(_svgChart.Theme, _title.Border, _svgChart.Chart.StyleManager.Style?.Title.BorderReference.Color, _title.Border.Fill.Style != eFillStyle.NoFill, 0.75); + } TextBox.AppendRenderItems(renderItems); } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 8c7b661dcf..2281f2558f 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -53,14 +53,8 @@ internal BarColumnChartTypeDrawer(ChartRenderer svgChart, ExcelBarChart chartTyp ExcelChartAxisStandard.CalculateStacked100(_valValues); } - //if(chartType.IsTypeBar()) - //{ - // CreateTrendlines(chartType, _valValues, _catValues); - //} - //else - //{ - CreateTrendlines(chartType, _catValues, _valValues); - //} + CreateTrendlines(chartType, _catValues, _valValues); + CreateErrorBars(chartType, _catValues, _valValues); } internal override void DrawSeries() @@ -255,8 +249,8 @@ private void AddBar(ExcelBarChart chartType, ExcelBarChartSerie serie, List _xSerie; + private ExcelChart _chartType; + + internal List Values { get; set; } = new List(); + public ChartErrorBarRenderer(ChartRenderer svgChart, ExcelChartErrorBars errorbars, List xSerie, List ySerie, ExcelChart chartType, int seriePos) : base(svgChart) + { + _chartType = chartType; + _errorbars = errorbars; + _xSerie = GetXSerie(xSerie); + _ySerie = ySerie.Select(y => ConvertUtil.GetValueDouble(y)).ToArray(); + //_useSecondaryAxis = chartType.UseSecondaryAxis; + //_serieCount = _chartType.Series.Count; + //_seriePos = seriePos; + int n = xSerie.Count; + + switch (errorbars.ValueType) + { + case eErrorValueType.StandardError: + case eErrorValueType.StandardDeviation: + if (n > 1) + { + double avg = _ySerie.Average(); + double sumSquaredDeviations = _ySerie.Sum(v => (v - avg) * (v - avg)); + double sampleStdDev = Math.Sqrt(sumSquaredDeviations / (n - 1)); // sample std dev (n-1) + + for(int i=0;i < _xSerie.Count;i++) + { + if (i >= _ySerie.Length) break; + + if(errorbars.ValueType == eErrorValueType.StandardError) + { + double y = _ySerie[i]; + Values.Add(new double[] { y - sampleStdDev, y + sampleStdDev }); + } + else + { + var mult = errorbars.Value ?? 1D; + sampleStdDev*= mult; + Values.Add(new double[] { avg - sampleStdDev, avg + sampleStdDev }); + } + } + } + break; + case eErrorValueType.Percentage: + var percent = (errorbars.Value ?? 0D) / 100D; + for (int i = 0; i < _xSerie.Count; i++) + { + double y = _ySerie[i]; + Values.Add(new double[] { y * (1 - percent), y + (1 + percent) }); + } + break; + case eErrorValueType.FixedValue: + var fixedValue = errorbars.Value ?? 0D; + for (int i = 0; i < _xSerie.Count; i++) + { + double y = _ySerie[i]; + Values.Add(new double[] { y - fixedValue, y + fixedValue }); + } + + break; + case eErrorValueType.Custom: + var minusList = errorbars.Minus.GetValuesList(_chartType.WorkSheet.Workbook); + var plusList = errorbars.Plus.GetValuesList(_chartType.WorkSheet.Workbook); + for (int i = 0; i < _xSerie.Count; i++) + { + double y = _ySerie[i]; + double minus = GetCustomValue(minusList, i); + double plus = GetCustomValue(plusList, i); + Values.Add(new double[] { y - minus, y + plus }); + } + + break; + } + } + public double GetCustomValue(List l, int i) + { + if(l.Count==0) + { + return l[0]; + } + else if(i renderItems) + { + throw new NotImplementedException(); + } + + internal RenderItem GetErrorBarRenderItem(int index, ChartAxisRenderer xAxis, ChartAxisRenderer yAxis, double x, double y) + { + var path = new PathRenderItem(ChartRenderer.Bounds); + double addTop=0, addBottom=0; + if (_errorbars.BarType == eErrorBarType.Plus || _errorbars.BarType == eErrorBarType.Both) + { + addTop = Values[index][1]; + } + if(_errorbars.BarType == eErrorBarType.Minus || _errorbars.BarType == eErrorBarType.Both) + { + addBottom = Values[index][0]; + } + + if(_errorbars.Direction == eErrorBarDirection.X) + { + path.Commands.Add(new PathCommands(PathCommandType.Move, xAxis.GetPositionInPlotarea(x - addBottom), yAxis.GetPositionInPlotarea(y))); + path.Commands.Add(new PathCommands(PathCommandType.Line, xAxis.GetPositionInPlotarea(x + addTop), yAxis.GetPositionInPlotarea(y))); + } + else + { + path.Commands.Add(new PathCommands(PathCommandType.Move, xAxis.GetPositionInPlotarea(x), yAxis.GetPositionInPlotarea(y - addBottom))); + path.Commands.Add(new PathCommands(PathCommandType.Line, xAxis.GetPositionInPlotarea(x), yAxis.GetPositionInPlotarea(y + addTop))); + } + return path; + } + } +} diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs index 4911da7fef..7c454c747b 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs @@ -7,6 +7,7 @@ using OfficeOpenXml; using OfficeOpenXml.Drawing.Chart; using OfficeOpenXml.Drawing.Chart.ChartEx; +using OfficeOpenXml.Drawing.Renderer.Chart.ChartTypeDrawers; using OfficeOpenXml.ExternalReferences; using OfficeOpenXml.FormulaParsing.Excel.Functions.Finance; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; @@ -21,6 +22,7 @@ internal abstract class ChartTypeDrawer : ChartDrawingObject { internal protected ExcelChart _chartType; internal List Trendlines { get; } = new List(); + internal ChartErrorBarRenderer ErrorBars { get; private set; } internal virtual bool SupportsTrendlines { get { return false; } } internal virtual bool SupportsErrorBars { get { return false; } } internal virtual bool SupportsUpDownBars { get { return false; } } @@ -201,6 +203,23 @@ protected void CreateTrendlines(ExcelChart chartType, List> xValues serieIndex++; } } + protected void CreateErrorBars(ExcelChart chartType, List> xValues, List> yValues) + { + var serieIndex = 0; + if (!chartType.IsTypeLine()) return; + foreach (ExcelLineChartSerie serie in chartType.Series) + { + if (serie.HasErrorBars()) + { + var xSerie = xValues[serieIndex]; + var ySerie = yValues[serieIndex]; + ErrorBars = new ChartErrorBarRenderer(ChartRenderer, serie.ErrorBars, xSerie, ySerie, _chartType, serieIndex); + + } + serieIndex++; + } + } + internal bool IsOnAxis(ExcelChartAxisStandard ax) { return _chartType.YAxis==ax || _chartType.XAxis==ax; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs index 89925263fe..d0f7a42771 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs @@ -49,6 +49,7 @@ internal LineChartTypeDrawer(ChartRenderer svgChart, ExcelLineChart chartType) : } CreateTrendlines(chartType, _xValues, _yValues); + CreateErrorBars(chartType, _xValues, _yValues); } internal override void DrawSeries() { @@ -135,7 +136,10 @@ private void AddLine(ExcelChart chartType, ExcelLineChartSerie serie, List(); var markerItems = new List(); + var errorBars = new List(); + var hasMarker = serie.HasMarker() && serie.Marker.Style != eMarkerStyle.None; + var hasErrorBars = serie.HasErrorBars(); for (var i = 0; i < yValues.Count; i++) { double x; @@ -163,8 +167,11 @@ private void AddLine(ExcelChart chartType, ExcelLineChartSerie serie, List renderItems) { renderItems.AddRange(ChartAreaRenderItems); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index 9ef938bbc5..e3f400a22a 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -94,28 +94,6 @@ public ChartTrendlineRenderer(ChartRenderer svgChart, ExcelChartTrendline trendl } } - /// - /// Get the X serie values. If the values are not numeric, return a serie with the index values (1,2,3,...). Trendline calculation requires numeric X values, but Excel allows non-numeric X values for trendlines, in which case it uses the index values as X for calculation. - /// - /// Input values - /// Output doubles - private List GetXSerie(List xSerie) - { - var l=new List(); - for(int i=0;i (double)(index + 1)).ToList(); - } - } - return l; - } - private void CreateDatalabel() { if(_trendline.DisplayEquation==false && _trendline.DisplayRSquaredValue==false) @@ -685,7 +663,7 @@ public override void AppendRenderItems(List renderItems) var pathItem = new PathRenderItem(ChartRenderer.Plotarea.Rectangle.Bounds); pathItem.Commands.Add(new EPPlusImageRenderer.PathCommands(PathCommandType.Move, RenderCoordinates)); pathItem.FillColor = "none"; - pathItem.SetDrawingPropertiesBorder(ChartRenderer.Theme, _trendline.Border, Chart.StyleManager.Style.Trendline.BorderReference.Color, true, _trendline.Border.Width); + pathItem.SetDrawingPropertiesBorder(ChartRenderer.Theme, _trendline.Border, Chart.StyleManager.Style?.Trendline.BorderReference.Color, true, _trendline.Border.Width); pathItem.SetDrawingPropertiesEffects(ChartRenderer.Theme, _trendline.Effect); renderItems.Add(pathItem); } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs index b99f7e277d..b03d3dcc4f 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs @@ -53,14 +53,14 @@ internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTh switch (fill.Style) { case eFillStyle.NoFill: - if (fill.IsEmpty) - { - item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity); - } - else - { + //if (fill.IsEmpty) //Removed for now. + //{ + // item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity); + //} + //else + //{ item.FillColor = "none"; - } + //} break; case eFillStyle.SolidFill: item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity); diff --git a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs index 8efd80f6f1..6ddc5b8515 100644 --- a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs @@ -144,7 +144,6 @@ public async Task ShouldExportHtmlWithMergedCells() var resultAsync = await exporter.GetSinglePageAsync(); SaveAndCleanup(package); Assert.AreEqual(result, resultAsync); - } } [TestMethod] From 8d11438f7c2df4d90ae6ee2645d3dcadc088696c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 17 Jun 2026 16:26:02 +0200 Subject: [PATCH 069/118] New position functional for center and in-base --- .../Chart/DataLabels/SvgDataLabelPoint.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 9ac37e01ad..4e819ef42a 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -356,6 +356,44 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) case eLabelPosition.Bottom: break; case eLabelPosition.InBase: + Rectangle.Left += basePoint.LocalPosition.X; + //Translate to the base point + Rectangle.Left += endToBaseVector.X; + Rectangle.Top += endToBaseVector.Y; + + //Move the textbox margins inside on left + if (endToBaseVector.X != 0) + { + //If basePoint is to the left + if (endToBaseVector.X > 0) + { + //We must place to the left + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Left); + } + //if basePoint is to the right + else + { + //We must place to the right + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Right); + } + } + + //Move the textbox margins inside on top + if (endToBaseVector.Y != 0) + { + //If endpoint is on Top + if (endToBaseVector.Y > 0) + { + //We must place on bottom + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Top); + } + //If endpoint is on bottom + else + { + //We must place on top + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Bottom); + } + } break; case eLabelPosition.InEnd: break; From 48bac1d3d945e7ecdbba52424522cd4f60761a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 17 Jun 2026 16:34:25 +0200 Subject: [PATCH 070/118] Made labels work for inEnd and inBase again --- .../Chart/DataLabels/SvgDataLabelPoint.cs | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 4e819ef42a..9c66df0592 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -333,6 +333,17 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) //At this point our rectangle globally is centered on the top-left of the object. //And endVector is the top center position. + //Centering is handled by the enum options below but if they are the same no change is made + if(basePoint.LocalPosition.X == endPoint.LocalPosition.X) + { + Rectangle.Left += endPoint.LocalPosition.X; + } + //We should not have to add this as we start at top left anyway + //if (basePoint.LocalPosition.Y == endPoint.LocalPosition.Y) + //{ + // Rectangle.Left += endPoint.LocalPosition.Y; + //} + //--- Visualize positions for debugging purposes CreateDebugPoints(basePoint, endPoint); centerPositionRect = GenerateDebugRenderItem(_parentPoint, "Purple"); @@ -343,7 +354,7 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) switch (_labelPosition) { case eLabelPosition.Center: - Rectangle.Bounds.Left += centerPoint.LocalPosition.X; + //Rectangle.Bounds.Left += centerPoint.LocalPosition.X; Rectangle.Bounds.Top += centerPoint.LocalPosition.Y; break; case eLabelPosition.Left: @@ -356,7 +367,6 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) case eLabelPosition.Bottom: break; case eLabelPosition.InBase: - Rectangle.Left += basePoint.LocalPosition.X; //Translate to the base point Rectangle.Left += endToBaseVector.X; Rectangle.Top += endToBaseVector.Y; @@ -396,6 +406,41 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) } break; case eLabelPosition.InEnd: + //Our base assumption is that we start at the endpoint. No change neccesary + + //Move the textbox margins inside on left + if (endToBaseVector.X != 0) + { + //If basePoint is to the left + if (endToBaseVector.X < 0) + { + //We must place to the left + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Left); + } + //if basePoint is to the right + else + { + //We must place to the right + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Right); + } + } + + //Move the textbox margins inside on top + if (endToBaseVector.Y != 0) + { + //If endpoint is on Top + if (endToBaseVector.Y < 0) + { + //We must place on bottom + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Top); + } + //If endpoint is on bottom + else + { + //We must place on top + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Bottom); + } + } break; case eLabelPosition.OutEnd: From 62b16c954b5fc27ddc8f3647b8e25f4592be52ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 17 Jun 2026 16:38:46 +0200 Subject: [PATCH 071/118] Added outEnd --- .../Chart/BarChartTests.cs | 2 +- .../Chart/DataLabels/SvgDataLabelPoint.cs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index 5503370393..3b72bcb1dc 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -51,7 +51,7 @@ public void DatalabelBarCharts() { var ws = p.Workbook.Worksheets[0]; var drawings = ws.Drawings; - var ix = 2; + var ix = 0; //var svg = drawings[ix].ToSvg(); //SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); for (int i = ix; i < drawings.Count; i++) diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 9c66df0592..8dedb7b06a 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -443,7 +443,37 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) } break; case eLabelPosition.OutEnd: + if (endToBaseVector.X != 0) + { + //If endPoint is to the left + if (endToBaseVector.X > 0) + { + //We must place to the left + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Left); + } + //if endpoint is to the right + else + { + //We must place to the right + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Right); + } + } + if (endToBaseVector.Y != 0) + { + //If endpoint is on Top + if (endToBaseVector.Y > 0) + { + //We must place on Top + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Top); + } + //If endpoint is on bottom + else + { + //We must place on Bottom + SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Bottom); + } + } break; default: From 33374869d7c1c7af9ac541e4b1625d8b82c2bed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 17 Jun 2026 16:45:19 +0200 Subject: [PATCH 072/118] Simplified initilization in barChart --- .../BarColumnChartTypeDrawer.cs | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 5d247675cd..286fab7936 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -77,33 +77,20 @@ internal override void DrawSeries() for (int j = 0; j < dataPoints.Count; j++) { + //Initialize transforms + Transform basePoint = new Transform(); + Transform endPoint = new Transform(); + basePoint.Parent = dataPoints[j]; + endPoint.Parent = dataPoints[j]; if (isColumn == true) { var middleRight = dataPoints[j].Left + (dataPoints[j].Width / 2); - var furthestPoint = new Vector2(middleRight, dataPoints[j].Top); - var startingPoint = new Vector2(middleRight, dataPoints[j].Bottom); - - var vectorTop = furthestPoint - startingPoint; - - vectorTop = new Vector2(0, vectorTop.Y); - - Transform basePoint = new Transform(); - Transform endPoint = new Transform(); - - basePoint.Parent = dataPoints[j]; - endPoint.Parent = dataPoints[j]; - endPoint.Position = new Vector2(middleRight, endPoint.Position.Y); basePoint.Position = new Vector2(middleRight, endPoint.Position.Y + dataPoints[j].Height); - //BoundingBox startingPointBound = new BoundingBox(0, 0); - //startingPointBound.Parent = dataPoints[j]; - //startingPointBound.Top = dataPoints[j].Top; - //startingPointBound.Left = middleRight; serieDataLabels[i].SetDimensions(j, basePoint, endPoint); - //serieDataLabels[i].SetParentVector(startingPointBound, j, vectorTop); } else { From 7b47db43f2c3533f9aadab2d13b8681d7094c525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 17 Jun 2026 17:15:58 +0200 Subject: [PATCH 073/118] Made new system functional for bars AND columns --- .../BarColumnChartTypeDrawer.cs | 15 ++++--------- .../Chart/DataLabels/SvgDataLabelPoint.cs | 21 ++++++------------- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 286fab7936..00f69d8c21 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -95,18 +95,11 @@ internal override void DrawSeries() else { var middleHeight = dataPoints[j].Top + (dataPoints[j].Height / 2); + + basePoint.Position = new Vector2(basePoint.Position.X, middleHeight); + endPoint.Position = new Vector2(basePoint.Position.X + dataPoints[j].Width, middleHeight); - var furthestPoint = new Vector2(dataPoints[j].Right, middleHeight); - var startingPoint = new Vector2(dataPoints[j].Left, middleHeight); - - var vectorRight = furthestPoint - startingPoint; - - BoundingBox startingPointBound = new BoundingBox(0, 0); - startingPointBound.Parent = dataPoints[j]; - startingPointBound.Top = middleHeight; - startingPointBound.Left = dataPoints[j].Width; - - serieDataLabels[i].SetParentVector(startingPointBound, j, vectorRight); + serieDataLabels[i].SetDimensions(j, basePoint, endPoint); } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 8dedb7b06a..52b6e79476 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -333,17 +333,6 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) //At this point our rectangle globally is centered on the top-left of the object. //And endVector is the top center position. - //Centering is handled by the enum options below but if they are the same no change is made - if(basePoint.LocalPosition.X == endPoint.LocalPosition.X) - { - Rectangle.Left += endPoint.LocalPosition.X; - } - //We should not have to add this as we start at top left anyway - //if (basePoint.LocalPosition.Y == endPoint.LocalPosition.Y) - //{ - // Rectangle.Left += endPoint.LocalPosition.Y; - //} - //--- Visualize positions for debugging purposes CreateDebugPoints(basePoint, endPoint); centerPositionRect = GenerateDebugRenderItem(_parentPoint, "Purple"); @@ -354,7 +343,7 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) switch (_labelPosition) { case eLabelPosition.Center: - //Rectangle.Bounds.Left += centerPoint.LocalPosition.X; + Rectangle.Bounds.Left += centerPoint.LocalPosition.X; Rectangle.Bounds.Top += centerPoint.LocalPosition.Y; break; case eLabelPosition.Left: @@ -368,8 +357,7 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) break; case eLabelPosition.InBase: //Translate to the base point - Rectangle.Left += endToBaseVector.X; - Rectangle.Top += endToBaseVector.Y; + Rectangle.Bounds.Position += basePoint.LocalPosition; //Move the textbox margins inside on left if (endToBaseVector.X != 0) @@ -406,7 +394,8 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) } break; case eLabelPosition.InEnd: - //Our base assumption is that we start at the endpoint. No change neccesary + //Move to end point + Rectangle.Bounds.Position += endPoint.LocalPosition; //Move the textbox margins inside on left if (endToBaseVector.X != 0) @@ -443,6 +432,8 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) } break; case eLabelPosition.OutEnd: + //Move to end point + Rectangle.Bounds.Position += endPoint.LocalPosition; if (endToBaseVector.X != 0) { //If endPoint is to the left From c6ef97748745a624f2ce7c9157e1ce2f567b07cf Mon Sep 17 00:00:00 2001 From: Lieven De Foor <8639525+lievendf@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:36:57 +0200 Subject: [PATCH 074/118] Fix KeyNotFoundException/ArgumentException on renaming or removing standard/grouped drawings (#2379) Co-authored-by: Lieven De Foor --- src/EPPlus/Drawing/ExcelDrawing.cs | 54 +++++++++++++++++++++++++ src/EPPlus/Drawing/ExcelGroupShape.cs | 18 ++++++++- src/EPPlusTest/Drawing/DrawingTest.cs | 57 +++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/EPPlus/Drawing/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index 0c571a957f..226fa723db 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -21,6 +21,7 @@ Date Author Change using OfficeOpenXml.Utils.FileUtils; using OfficeOpenXml.Utils.XML; using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; @@ -332,6 +333,18 @@ public virtual string Name try { if (_nvPrPath == "") throw new NotImplementedException(); + string oldName = GetXmlNodeString(_nvPrPath + "/@name"); + // Only perform dictionary synchronization if the name has actually changed + if (!string.IsNullOrEmpty(oldName) && !oldName.Equals(value, StringComparison.Ordinal)) + { + // Validate name uniqueness in both worksheet and parent-group drawing collections. + // All checks must pass before any dictionaries are updated, preventing partial desynchronization. + ValidateNameUniquenessInCollections(value); + + // All uniqueness checks successfully passed! Safe to update name-lookup dictionaries. + UpdateNameInDictionaries(oldName, value); + } + SetXmlNodeString(_nvPrPath + "/@name", value); if (this is ExcelSlicer ts) { @@ -344,6 +357,10 @@ public virtual string Name pts.SlicerName = value; } } + catch (ArgumentException) + { + throw; + } catch { throw new NotImplementedException(); @@ -2498,6 +2515,43 @@ private XmlNode CopyShape(ExcelWorksheet worksheet, bool isGroupShape = false, X return drawNode; } + private void ValidateNameUniqueness(Dictionary drawingNames, IEnumerable collection, string name) + { + if (drawingNames?.ContainsKey(name) == true) + { + int index = drawingNames[name]; + var drawing = collection?.ElementAtOrDefault(index); + + if (drawing != null && drawing != this) + { + string collectionDescription = collection is ExcelDrawingsGroup ? "the group drawings collection" : "drawings collection"; + throw new ArgumentException($"Name '{name}' already exists in {collectionDescription}."); + } + } + } + + private void ValidateNameUniquenessInCollections(string name) + { + ValidateNameUniqueness(_drawings?._drawingNames, _drawings?._drawingsList, name); + ValidateNameUniqueness(_parent?.Drawings?._drawingNames, _parent?.Drawings, name); + } + + private void UpdateNameInDictionary(Dictionary drawingNames, string oldName, string newName) + { + if (drawingNames?.ContainsKey(oldName) == true) + { + int index = drawingNames[oldName]; + drawingNames.Remove(oldName); + drawingNames[newName] = index; + } + } + + private void UpdateNameInDictionaries(string oldName, string newName) + { + UpdateNameInDictionary(_drawings?._drawingNames, oldName, newName); + UpdateNameInDictionary(_parent?.Drawings?._drawingNames, oldName, newName); + } + internal ExcelAddressBase GetAddress() { GetFromBounds(out int fromRow, out _, out int fromCol, out _); diff --git a/src/EPPlus/Drawing/ExcelGroupShape.cs b/src/EPPlus/Drawing/ExcelGroupShape.cs index b969dfbef8..7083900510 100644 --- a/src/EPPlus/Drawing/ExcelGroupShape.cs +++ b/src/EPPlus/Drawing/ExcelGroupShape.cs @@ -38,7 +38,7 @@ internal ExcelDrawingsGroup(ExcelGroupShape parent, XmlNamespaceManager nsm, Xml _parent = parent; _nsm = nsm; _topNode = topNode; - _drawingNames = new Dictionary(); + _drawingNames = new Dictionary(StringComparer.OrdinalIgnoreCase); _drawingsCollectionType = drawingsCollectionType; AddDrawings(); } @@ -298,7 +298,21 @@ IEnumerator IEnumerable.GetEnumerator() public void Remove(ExcelDrawing drawing) { CheckNotDisposed(); - _groupDrawings.Remove(drawing); + // Find the 0-based list index of the drawing within this group + int index = _groupDrawings.IndexOf(drawing); + if (index < 0) return; + + // Remove the drawing from the list and its entry from the lookup dictionary + _groupDrawings.RemoveAt(index); + _drawingNames.Remove(drawing.Name); + + // Re-index all remaining drawings after the removed index in the group's name-to-index lookup. + // When an element is removed, all subsequent elements shift down, so we update their mapped indices accordingly. + for (int i = index; i < _groupDrawings.Count; i++) + { + _drawingNames[_groupDrawings[i].Name] = i; + } + AdjustXmlAndMoveFromGroup(drawing); var ix = _parent._drawings._drawingsList.IndexOf(_parent); _parent._drawings._drawingsList.Insert(ix, drawing); diff --git a/src/EPPlusTest/Drawing/DrawingTest.cs b/src/EPPlusTest/Drawing/DrawingTest.cs index ffed5c12f2..6d955de729 100644 --- a/src/EPPlusTest/Drawing/DrawingTest.cs +++ b/src/EPPlusTest/Drawing/DrawingTest.cs @@ -1218,5 +1218,62 @@ public void AddTextBox() SaveAndCleanup(package); } } + [TestMethod] + public void DrawingNameChangeAndRemovalTest() + { + using (var package = new ExcelPackage()) + { + var ws = package.Workbook.Worksheets.Add("RenameTest"); + + // Add a drawing (shape) + var shape = ws.Drawings.AddShape("InitialName", eShapeStyle.Rect); + Assert.AreEqual("InitialName", shape.Name); + Assert.IsTrue(ws.Drawings._drawingNames.ContainsKey("InitialName")); + + // Rename drawing + shape.Name = "RenamedName"; + Assert.AreEqual("RenamedName", shape.Name); + + // Verify the internal dictionary was updated + Assert.IsFalse(ws.Drawings._drawingNames.ContainsKey("InitialName"), "Old name key should be removed from dictionary"); + Assert.IsTrue(ws.Drawings._drawingNames.ContainsKey("RenamedName"), "New name key should be added to dictionary"); + + // Test removing by shape object reference + ws.Drawings.Remove(shape); + Assert.AreEqual(0, ws.Drawings.Count, "Drawing should be removed successfully"); + Assert.IsFalse(ws.Drawings._drawingNames.ContainsKey("RenamedName"), "Dictionary should no longer contain renamed name"); + } + } + + [TestMethod] + public void GroupedDrawingNameChangeAndRemovalTest() + { + using (var package = new ExcelPackage()) + { + var ws = package.Workbook.Worksheets.Add("GroupRenameTest"); + + var shape1 = ws.Drawings.AddShape("Shape1", eShapeStyle.Rect); + var shape2 = ws.Drawings.AddShape("Shape2", eShapeStyle.Rect); + + // Group shapes using public EPPlus API + var group = shape1.Group(shape2); + + Assert.AreEqual("Shape1", shape1.Name); + Assert.IsTrue(group.Drawings._drawingNames.ContainsKey("Shape1")); + + // Rename grouped shape + shape1.Name = "GroupedShape1Renamed"; + Assert.AreEqual("GroupedShape1Renamed", shape1.Name); + + // Verify the internal group dictionary was updated + Assert.IsFalse(group.Drawings._drawingNames.ContainsKey("Shape1"), "Old name key should be removed from group dictionary"); + Assert.IsTrue(group.Drawings._drawingNames.ContainsKey("GroupedShape1Renamed"), "New name key should be added to group dictionary"); + + // Test removing grouped shape + group.Drawings.Remove(shape1); + Assert.AreEqual(1, group.Drawings.Count, "One shape should be left in group"); + Assert.IsFalse(group.Drawings._drawingNames.ContainsKey("GroupedShape1Renamed"), "Group dictionary should no longer contain removed name"); + } + } } } From a10870242daba5b102915ff1bf6d3b51e7bdb64d Mon Sep 17 00:00:00 2001 From: Lieven De Foor <8639525+lievendf@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:12:06 +0200 Subject: [PATCH 075/118] Fix IndexOutOfRangeException in LoadFromText with trailing columns (#2388) Co-authored-by: Lieven De Foor --- src/EPPlus/LoadFunctions/LoadFromTextBase.cs | 18 ++++++++++-------- .../LoadFunctions/LoadFromTextTests.cs | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/EPPlus/LoadFunctions/LoadFromTextBase.cs b/src/EPPlus/LoadFunctions/LoadFromTextBase.cs index a61614e06d..2a1d2e719b 100644 --- a/src/EPPlus/LoadFunctions/LoadFromTextBase.cs +++ b/src/EPPlus/LoadFunctions/LoadFromTextBase.cs @@ -62,16 +62,18 @@ 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)) - { - return string.IsNullOrEmpty(v) ? null : v; - } - else + bool isOutOfBounds = dataType == null || col >= dataType.Length; + + if (isOutOfBounds) { - 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/LoadFunctions/LoadFromTextTests.cs b/src/EPPlusTest/LoadFunctions/LoadFromTextTests.cs index ce444fed02..c49ab6abe7 100644 --- a/src/EPPlusTest/LoadFunctions/LoadFromTextTests.cs +++ b/src/EPPlusTest/LoadFunctions/LoadFromTextTests.cs @@ -467,5 +467,21 @@ 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); + } + } } From c581ab7491b7943499cc0a36ef6e6c8d4e9aa85f Mon Sep 17 00:00:00 2001 From: Lieven De Foor <8639525+lievendf@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:12:39 +0200 Subject: [PATCH 076/118] Fix url of license information in Readme.md (#2389) --- Readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index 641586dbee..bf69ba6f79 100644 --- a/Readme.md +++ b/Readme.md @@ -6,7 +6,7 @@ # EPPlus 8 ## License -EPPlus 8 has a dual license model with a community license for noncommercial use: [Polyform Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/). +EPPlus 8 has a dual license model with a community license for noncommercial use: [Polyform Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0). With this license EPPlus is free to use for personal/noncommercial use, but will require a commercial license to be used in a commercial business. From 6bf43e0942c96731f26d5c8830e9f10ec595dca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 18 Jun 2026 10:13:06 +0200 Subject: [PATCH 077/118] Ensured mix of series and custom datalabels get read --- .../Chart/BarChartTests.cs | 22 ++++++++++++++++- .../Chart/Axis/CategoryAxisScaleCalculator.cs | 1 + .../DataLabels/ChartSerieDataLabelRenderer.cs | 24 +++++++++++++++---- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index 3b72bcb1dc..d6f41ec689 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -47,7 +47,7 @@ public void GenerateSvgForBarCharts2() public void DatalabelBarCharts() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("BarChartForSvgDatalabels.xlsx")) + using (var p = OpenTemplatePackage("BarChartForSvgDatalabels2.xlsx")) { var ws = p.Workbook.Worksheets[0]; var drawings = ws.Drawings; @@ -61,5 +61,25 @@ public void DatalabelBarCharts() } } } + + + [TestMethod] + public void NegativeDatalabelBarCharts() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("negativeDatalabels.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + var drawings = ws.Drawings; + var ix = 0; + //var svg = drawings[ix].ToSvg(); + //SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); + for (int i = ix; i < drawings.Count; i++) + { + var svg = drawings[i].ToSvg(); + SaveTextFileToWorkbook($"svg\\NegativeLabels{ix++}.svg", svg); + } + } + } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/Axis/CategoryAxisScaleCalculator.cs b/src/EPPlus/Drawing/Renderer/Chart/Axis/CategoryAxisScaleCalculator.cs index e0ca5c1358..bed313ba57 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/Axis/CategoryAxisScaleCalculator.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/Axis/CategoryAxisScaleCalculator.cs @@ -24,6 +24,7 @@ internal static AxisScale CalculateHorizontalAxisByWidth(ref List values var mf = ax.Font.GetMeasureFont(); List displayValues = GetUniqueValues(values).Select(x=>(object)x.ToString()).ToList(); var uniqeItems = displayValues.Count; + var res = tm.MeasureText(displayValues[0].ToString(), mf); //Get interval for maximum width with vertical text. diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs index 196d0465c0..46c26ee6e7 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs @@ -47,16 +47,30 @@ public ChartSerieDataLabelRenderer(ChartRenderer chart, ExcelChartSerieDataLabel } else { - //if (xValues != null) - //{ - for (int i = 0; i < dlblSerie.DataLabels.Count; i++) + int nextIndex = dlblSerie.DataLabels[0].Index; + var customIndex = 0; + for (int i = 0; i < serie.NumberOfItems; i++) + { + if (i == nextIndex) { - var dataLabel = dlblSerie.DataLabels[i]; + var dataLabel = dlblSerie.DataLabels[customIndex++]; + var individualIndex = dataLabel.Index; var yVal = yValues == null ? null : yValues[i]; var xVal = xValues == null ? null : xValues[i]; AddDatalabel(serie, dataLabel, xVal, yVal, maxBounds); + + if (customIndex < dlblSerie.DataLabels.Count) + { + nextIndex = dlblSerie.DataLabels[customIndex].Index; + } + } + else + { + var yVal = yValues == null ? null : yValues[i]; + var xVal = xValues == null ? null : xValues[i]; + AddDatalabel(serie, dlblSerie, xVal, yValues[i], maxBounds); } - //} + } } } From 51440dc47b6860b69955e2ae7cf103d440ae48a2 Mon Sep 17 00:00:00 2001 From: Lieven De Foor <8639525+lievendf@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:31:58 +0200 Subject: [PATCH 078/118] Fix: drawing hyperlink reassignment crashes and inert tooltip setting (#2378) Co-authored-by: Lieven De Foor --- src/EPPlus/Drawing/ExcelDrawing.cs | 4 +-- src/EPPlusTest/HyperlinkTest.cs | 44 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) 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/EPPlusTest/HyperlinkTest.cs b/src/EPPlusTest/HyperlinkTest.cs index 65d119e60c..3bfb8c0cab 100644 --- a/src/EPPlusTest/HyperlinkTest.cs +++ b/src/EPPlusTest/HyperlinkTest.cs @@ -102,5 +102,49 @@ 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); + } + } + } + } } From d25f9db99870327b2c080a98850c8e37e7ae9164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 18 Jun 2026 10:36:39 +0200 Subject: [PATCH 079/118] Added support for negative columns + margin --- .../BarColumnChartTypeDrawer.cs | 35 ++++++++++++------- .../Chart/DataLabels/SvgDataLabelPoint.cs | 23 ++++++------ 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 00f69d8c21..2953ea65ca 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -86,16 +86,24 @@ internal override void DrawSeries() if (isColumn == true) { var middleRight = dataPoints[j].Left + (dataPoints[j].Width / 2); + basePoint.Position = new Vector2(middleRight, chartBaseY); - endPoint.Position = new Vector2(middleRight, endPoint.Position.Y); - basePoint.Position = new Vector2(middleRight, endPoint.Position.Y + dataPoints[j].Height); + if (chartBaseY > dataPoints[j].Top) + { + endPoint.Position = new Vector2(middleRight, chartBaseY - dataPoints[j].Height); + } + else + { + endPoint.Position = new Vector2(middleRight, chartBaseY + dataPoints[j].Height); + } serieDataLabels[i].SetDimensions(j, basePoint, endPoint); } else { var middleHeight = dataPoints[j].Top + (dataPoints[j].Height / 2); - + + basePoint.Position = new Vector2(basePoint.Position.X, middleHeight); endPoint.Position = new Vector2(basePoint.Position.X + dataPoints[j].Width, middleHeight); @@ -128,6 +136,8 @@ internal override void DrawSeries() } } + double chartBaseY = double.NaN; + private void AddBar(ExcelBarChart chartType, ExcelBarChartSerie serie, List> catSeries, List> valSeries, List dataPoints, int seriesCount, int position) { GetAxis(chartType, out var yAxis, out var xAxis); @@ -160,18 +170,17 @@ private void AddBar(ExcelBarChart chartType, ExcelBarChartSerie serie, List @@ -334,10 +336,7 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) //And endVector is the top center position. //--- Visualize positions for debugging purposes - CreateDebugPoints(basePoint, endPoint); - centerPositionRect = GenerateDebugRenderItem(_parentPoint, "Purple"); - centerPositionRect.Left += centerPoint.LocalPosition.X; - centerPositionRect.Top += centerPoint.LocalPosition.Y; + //CreateDebugPoints(basePoint, endPoint, centerPoint); //--- switch (_labelPosition) @@ -382,14 +381,14 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) //If endpoint is on Top if (endToBaseVector.Y > 0) { - //We must place on bottom - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Top); + //We must place on bottom and apply margin to height + SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Top); } //If endpoint is on bottom else { - //We must place on top - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Bottom); + //We must place on top and apply margin to height + SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Bottom); } } break; @@ -421,13 +420,13 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) if (endToBaseVector.Y < 0) { //We must place on bottom - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Top); + SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Top); } //If endpoint is on bottom else { //We must place on top - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Bottom); + SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Bottom); } } break; From 6b38834a2a25e60e502054466a511a64068fb9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 18 Jun 2026 13:02:48 +0200 Subject: [PATCH 080/118] Ensured works for bar charts as well --- .../Chart/BarChartTests.cs | 2 +- .../ChartTypeDrawers/BarColumnChartTypeDrawer.cs | 14 ++++++++++---- .../Chart/DataLabels/SvgDataLabelPoint.cs | 16 ++++++++-------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index d6f41ec689..f09e6c0318 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -71,7 +71,7 @@ public void NegativeDatalabelBarCharts() { var ws = p.Workbook.Worksheets[0]; var drawings = ws.Drawings; - var ix = 0; + var ix = 1; //var svg = drawings[ix].ToSvg(); //SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); for (int i = ix; i < drawings.Count; i++) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 2953ea65ca..93ef8c2017 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -102,10 +102,16 @@ internal override void DrawSeries() else { var middleHeight = dataPoints[j].Top + (dataPoints[j].Height / 2); - - - basePoint.Position = new Vector2(basePoint.Position.X, middleHeight); - endPoint.Position = new Vector2(basePoint.Position.X + dataPoints[j].Width, middleHeight); + basePoint.Position = new Vector2(chartBaseY, middleHeight); + if (chartBaseY > dataPoints[j].Left) + { + endPoint.Position = new Vector2(chartBaseY - dataPoints[j].Width, middleHeight); + } + else + { + endPoint.Position = new Vector2(dataPoints[j].Left + dataPoints[j].Width, middleHeight); + } + //endPoint.Position = new Vector2(basePoint.Position.X + dataPoints[j].Width, middleHeight); serieDataLabels[i].SetDimensions(j, basePoint, endPoint); } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 6c83d3a61a..1493c0882e 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -365,13 +365,13 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) if (endToBaseVector.X > 0) { //We must place to the left - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Left); + SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Left); } //if basePoint is to the right else { //We must place to the right - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Right); + SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Right); } } @@ -403,13 +403,13 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) if (endToBaseVector.X < 0) { //We must place to the left - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Left); + SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Left); } //if basePoint is to the right else { //We must place to the right - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Right); + SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Right); } } @@ -439,13 +439,13 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) if (endToBaseVector.X > 0) { //We must place to the left - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Left); + SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Left); } //if endpoint is to the right else { //We must place to the right - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Right); + SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Right); } } @@ -455,13 +455,13 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) if (endToBaseVector.Y > 0) { //We must place on Top - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Top); + SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Top); } //If endpoint is on bottom else { //We must place on Bottom - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Bottom); + SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Bottom); } } From 9e3ce65908cabcad9525e3b9ad505fb1d7e40ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 18 Jun 2026 13:34:25 +0200 Subject: [PATCH 081/118] Added simpler but somewhat unclear method --- .../Chart/DataLabels/SvgDataLabelPoint.cs | 157 +++++++----------- 1 file changed, 60 insertions(+), 97 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 1493c0882e..9eea73aa37 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -14,6 +14,7 @@ using System; using System.Collections.Generic; using System.Net; +using static OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions.RoundingHelper; namespace EPPlus.Export.ImageRenderer.RenderItems.SvgItem { @@ -300,6 +301,53 @@ private void CreateDebugPoints(Transform basePoint, Transform endPoint, Transfor centerPositionRect.Top += centerPoint.LocalPosition.Y; } + private void SetBasicPositionBasedOnInput(double pointOnAxis, BoundingBox axisMargin, eLabelPosition positivePosition, eLabelPosition negativePosition) + { + if(pointOnAxis != 0) + { + //If point is positive + if (pointOnAxis > 0) + { + //We must place to the left + SetPositionBasic(axisMargin, positivePosition); + } + //if basePoint is to the left + else + { + //We must place to the right + SetPositionBasic(axisMargin, negativePosition); + } + } + } + + /// + /// Set the basic label position based on the direction of a vector + /// + /// Shows what direction is in or out + /// Set the label in the same direction as the vector (set to false for opposite) + private void SetBasicPositionForInOrOut(Vector2 direction, bool inDirection) + { + //Could be simplified by pos Array defined by isIn + if(inDirection) + { + //If direction is to the right (positive) + //We must set the label to the Right to stay outside the shape. and vice versa + SetBasicPositionBasedOnInput(direction.X, new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Right, eLabelPosition.Left); + //If direction is to the bottom (positive) + //We must set the label to the bottom to stay within the shape. and vice versa + SetBasicPositionBasedOnInput(direction.Y, new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Bottom, eLabelPosition.Top); + } + else + { + //If direction is to the right (positive) + //We must set the label to the left to stay within the shape. and vice versa + SetBasicPositionBasedOnInput(direction.X, new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Left, eLabelPosition.Right); + //If direction is to the downwards (positive) + //We must set the label to the top to stay within the shape. and vice versa + SetBasicPositionBasedOnInput(direction.Y, new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Top, eLabelPosition.Bottom); + } + } + /// /// /// @@ -346,124 +394,39 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) Rectangle.Bounds.Top += centerPoint.LocalPosition.Y; break; case eLabelPosition.Left: + SetPositionBasic(_parentPoint, _labelPosition); break; case eLabelPosition.Right: case eLabelPosition.BestFit: + SetPositionBasic(_parentPoint, _labelPosition); break; case eLabelPosition.Top: + SetPositionBasic(_parentPoint, _labelPosition); break; case eLabelPosition.Bottom: + SetPositionBasic(_parentPoint, _labelPosition); break; case eLabelPosition.InBase: //Translate to the base point Rectangle.Bounds.Position += basePoint.LocalPosition; - - //Move the textbox margins inside on left - if (endToBaseVector.X != 0) - { - //If basePoint is to the left - if (endToBaseVector.X > 0) - { - //We must place to the left - SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Left); - } - //if basePoint is to the right - else - { - //We must place to the right - SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Right); - } - } - - //Move the textbox margins inside on top - if (endToBaseVector.Y != 0) - { - //If endpoint is on Top - if (endToBaseVector.Y > 0) - { - //We must place on bottom and apply margin to height - SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Top); - } - //If endpoint is on bottom - else - { - //We must place on top and apply margin to height - SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Bottom); - } - } + //We want to stay inside the object so if the direction to end is Right we want to set the label to the Left + SetBasicPositionForInOrOut(endToBaseVector, false); break; case eLabelPosition.InEnd: //Move to end point Rectangle.Bounds.Position += endPoint.LocalPosition; - //Move the textbox margins inside on left - if (endToBaseVector.X != 0) - { - //If basePoint is to the left - if (endToBaseVector.X < 0) - { - //We must place to the left - SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Left); - } - //if basePoint is to the right - else - { - //We must place to the right - SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Right); - } - } - - //Move the textbox margins inside on top - if (endToBaseVector.Y != 0) - { - //If endpoint is on Top - if (endToBaseVector.Y < 0) - { - //We must place on bottom - SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Top); - } - //If endpoint is on bottom - else - { - //We must place on top - SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Bottom); - } - } + //We want to stay inside the object so if the direction to end is Right we want to set the label to the Right + //Since the only time endToBaseVector is Positive is when the end is further positive than the base + SetBasicPositionForInOrOut(endToBaseVector, true); break; case eLabelPosition.OutEnd: //Move to end point Rectangle.Bounds.Position += endPoint.LocalPosition; - if (endToBaseVector.X != 0) - { - //If endPoint is to the left - if (endToBaseVector.X > 0) - { - //We must place to the left - SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Left); - } - //if endpoint is to the right - else - { - //We must place to the right - SetPositionBasic(new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Right); - } - } - if (endToBaseVector.Y != 0) - { - //If endpoint is on Top - if (endToBaseVector.Y > 0) - { - //We must place on Top - SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Top); - } - //If endpoint is on bottom - else - { - //We must place on Bottom - SetPositionBasic(new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Bottom); - } - } + //We want to set the label outside the object + //So we do the opposite of InEnd + SetBasicPositionForInOrOut(endToBaseVector, false); break; default: From d5ea8ab5719be6fe21822915c2c5974e73d38b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 18 Jun 2026 14:26:01 +0200 Subject: [PATCH 082/118] Attempting to use functionality in pie item --- .../ChartTypeDrawers/PieChartTypeDrawer.cs | 17 ++++++++-- .../Renderer/Chart/PieSliceRenderItem.cs | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs index 7511ca5f3e..29a2b3d3e1 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs @@ -244,11 +244,22 @@ internal override void DrawSeries() if (serie.HasDataLabel) { - var innerGroup = Slices[j].GetInnerGroupTransformOriginTranslated(); + var innerGroup = Slices[j].GetInnerGroupWithTransformOriginTranslated(); //Get the global position of the inner items (innerGroup the parent of itemGroup has already had its position set correctly) - var dlblBounds = new BoundingBox(innerGroup.X, innerGroup.Y, Rectangle.Bounds.Width, Rectangle.Bounds.Height); + var dlblBounds = new BoundingBox(innerGroup.LocalPosition.X, innerGroup.LocalPosition.Y, Rectangle.Bounds.Width, Rectangle.Bounds.Height); - serieDataLabels[i].SetParentVector(dlblBounds, j, Slices[j].GetWholeVectorCenterToMid()); + var ctrToMid = Slices[j].GetWholeVectorCenterToMid(); + var startPt = new Transform(); + startPt.Parent = innerGroup.Parent; + startPt.Position = innerGroup.Position + (ctrToMid * -1); + + serieDataLabels[i].SetDimensions(j, startPt, innerGroup); + + //var endPoint = Slices[j].CopyOuterMidPoint(); + //var startPoint = _circleCenter.Position + //serieDataLabels[i].SetDimensions(j, startPoint, endPoint); + + //serieDataLabels[i].SetParentVector(dlblBounds, j, Slices[j].GetWholeVectorCenterToMid()); } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs b/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs index 037532da46..fb58373378 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs @@ -159,6 +159,7 @@ void CalculateWidthHeight(double prevSliceDegrees) private double _sliceScaleFactor = 1d; private double _scaledRadius { get { return _radius * _sliceScaleFactor; } } + private void CalculateExplosionDir() { var transformOriginLocal = new Vector2(_innerGroup.TransformOrigin.X, _innerGroup.TransformOrigin.Y); @@ -525,6 +526,24 @@ internal Coordinate GetOuterMidpointInGlobalCoords() return new Coordinate(_midPoint.Position.X, _midPoint.Position.Y); } + + internal Transform CopyOuterMidPoint() + { + var transform = new Transform(); + transform.Parent = _midPoint.Parent; + transform.Position = transform.Position + _midPoint.LocalPosition; + return transform; + } + + internal Transform CopyStartPoint() + { + var translationVector = GetLocalTranslationVector(100); + var transform = new Transform(); + transform.Parent = _midPoint.Parent; + transform.Position = transform.Position + _midPoint.LocalPosition; + return transform; + } + internal GroupRenderItem GetInnerItemGroup() { return _innerGroup; @@ -539,6 +558,18 @@ internal Coordinate GetInnerGroupTransformOriginTranslated() { return new Coordinate(_innerGroup.TransformOrigin.X + _innerGroup.TranslationOffset.Left, _innerGroup.TransformOrigin.Y + _innerGroup.TranslationOffset.Top); } + /// + /// Transform origin in local coordinates + /// Translated + /// + /// + internal Transform GetInnerGroupWithTransformOriginTranslated() + { + Transform transform = new Transform(); + transform.Parent = _innerGroup.Bounds.Parent; + transform.LocalPosition += new Vector2(_innerGroup.TransformOrigin.X + _innerGroup.TranslationOffset.Left, _innerGroup.TransformOrigin.Y + _innerGroup.TranslationOffset.Top); + return transform; + } public override void AppendRenderItems(List renderItems) { From f6db9124c5d9eeb528960716f8cdff04db34424b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 18 Jun 2026 17:08:48 +0200 Subject: [PATCH 083/118] Fixed several pie chart datalabel bugs --- .../Chart/PieChartTests.cs | 4 +- .../Svg/SvgGroupRenderer.cs | 4 +- .../ChartTypeDrawers/PieChartTypeDrawer.cs | 18 ++- .../DataLabels/ChartSerieDataLabelRenderer.cs | 26 ++++- .../Chart/DataLabels/SvgDataLabelPoint.cs | 106 +++++++++++++++--- 5 files changed, 132 insertions(+), 26 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs index bfe3fd61c3..196ee89afc 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs @@ -12,12 +12,12 @@ public class PieChartTests : TestBase { [TestMethod] - public void ReadAndGenerateExcelPieChartSvgs() + public void ReadAndGenerateAll() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("PieChartSvgALL.xlsx")) { - var ws = p.Workbook.Worksheets[0]; + var ws = p.Workbook.Worksheets[4]; for (int i = 0; i < p.Workbook.Worksheets.Count; i++) { diff --git a/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs b/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs index c2a66a0f69..8f416409d6 100644 --- a/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs +++ b/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs @@ -22,7 +22,9 @@ public override void Render(GroupRenderItem item) //Neccesary as fallback for e.g. DataLabels string fillPropery = ""; - if (string.IsNullOrEmpty(item.FillColor) == false && item.FillColor != "none") + + //We may need special handling for fill-color 'none' as it is sometimes transparent and sometimes un-applied. + if (string.IsNullOrEmpty(item.FillColor) == false) { fillPropery = $" fill=\"{item.FillColor}\" "; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs index 29a2b3d3e1..bc24cc1d55 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs @@ -192,6 +192,12 @@ internal override void DrawSeries() LoadSeriesValues(chartType); CalculateLocalCenterAndRadius(); + if (serieDataLabels.Count > 0) + { + serieDataLabels[0].rotation = angleOffset; + serieDataLabels[0].rotationPoint = _groupItem.RotationPoint; + } + InitializeSlices(); //How much to scale each slice due to pie explosion @@ -207,23 +213,23 @@ internal override void DrawSeries() _sliceScaleFactor += 0.02d; } - int count = 0; + int dataPointCount = 0; if (catValues != null) { if (valValues != null) { - count = Math.Min(catValues.Count, valValues.Count); + dataPointCount = Math.Max(catValues.Count, valValues.Count); } else { - count = catValues.Count; + dataPointCount = catValues.Count; } } else { if (valValues != null) { - count = valValues.Count; + dataPointCount = valValues.Count; } } @@ -237,10 +243,10 @@ internal override void DrawSeries() //Excel ignores series beyond the first for pie chart visualization if (i == 0) { - for (var j = 0; j < count; j++) + for (var j = 0; j < Slices.Count; j++) { //Update the initialized slice with path, style and group data - UpdateSlice(chartType, serie, count, j); + UpdateSlice(chartType, serie, dataPointCount, j); if (serie.HasDataLabel) { diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs index 46c26ee6e7..58f34a3a41 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs @@ -6,7 +6,9 @@ using EPPlusImageRenderer.Svg; using OfficeOpenXml.Drawing; using OfficeOpenXml.Drawing.Chart; +using OfficeOpenXml.Utils.TypeConversion; using System.Collections.Generic; +using System.Drawing; namespace EPPlus.Export.ImageRenderer.RenderItems.SvgItem { @@ -22,6 +24,9 @@ internal class ChartSerieDataLabelRenderer : ChartDrawingObject BoundingBox _defaultMargins; ExcelChartSerieDataLabel _dlblSerie; + internal double rotation = double.NaN; + internal Graphics.Point rotationPoint = null; + public ChartSerieDataLabelRenderer(ChartRenderer chart, ExcelChartSerieDataLabel dlblSerie, BoundingBox maxBounds, ExcelChartStandardSerie serie, List xValues, List yValues, int index) : base(chart) { _serieIndex = index; @@ -162,19 +167,36 @@ public override void AppendRenderItems(List renderItems) plotAreaGroup.Left = plotAreaBounds.Position.X; plotAreaGroup.Top = plotAreaBounds.Position.Y; + if(rotation != double.NaN) + { + if(rotationPoint != null) + { + plotAreaGroup.RotationPoint = rotationPoint; + } + plotAreaGroup.Rotation = rotation; + } + if (_dlblSerie.Fill.IsEmpty == false) { //Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); //plotAreaGroup.AddChildItem(Rectangle); Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); - plotAreaGroup.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); - plotAreaGroup.GroupTransform += $" fill=\"{plotAreaGroup.FillColor}\" name=\"Plot area group\""; + //if (_dlblSerie.Fill.Color.ToArgb() == Color.Transparent.ToArgb()) + //{ + // plotAreaGroup.FillColor = "transparent"; + //} + + //plotAreaGroup.GroupTransform += $" fill=\"{plotAreaGroup.FillColor}\" name=\"Plot area group\""; } renderItems.Add(plotAreaGroup); for(int i = 0; i< dataLabels.Count; i++) { + if(rotation != double.NaN) + { + dataLabels[i].CounterRotation = -rotation; + } dataLabels[i].AppendRenderItems(plotAreaGroup.RenderItems); } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 9eea73aa37..6a4232f7b0 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -13,6 +13,7 @@ using OfficeOpenXml.Utils.EnumUtils; using System; using System.Collections.Generic; +using System.Drawing; using System.Net; using static OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions.RoundingHelper; @@ -31,6 +32,7 @@ internal class SvgDataLabelPoint : ChartDrawingObject Coordinate _manualLayoutOffset = new Coordinate (0, 0); PointLines _connectionPointLines; eLabelPosition _labelPosition; + internal double CounterRotation = double.NaN; //public SvgChartDataLabelStandard(DrawingChart chart, string dataLabelText) : base(chart) //{ @@ -348,6 +350,29 @@ private void SetBasicPositionForInOrOut(Vector2 direction, bool inDirection) } } + private void SetAdjustedTextBoxPosition(Vector2 direction, bool reverseDirection) + { + if(reverseDirection) + { + direction *= -1; + } + + var txtBoxAdjustVector = new Vector2(_txtBox.Width / 2d, _txtBox.Height / 2d); + var percentualLength = txtBoxAdjustVector.Length / direction.Length; + Rectangle.Bounds.Position += direction * percentualLength; + } + + private void SetInOut(Vector2 direction, Vector2 translation, bool reverseDirection) + { + //Translate to the base point + Rectangle.Bounds.Position += translation; + SetAdjustedTextBoxPosition(direction, reverseDirection); + } + + //private void SetInEnd(Vector2 direction, Vector2 translation) + + //private void SetLeft(Transform centerPoint) + /// /// /// @@ -397,7 +422,6 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) SetPositionBasic(_parentPoint, _labelPosition); break; case eLabelPosition.Right: - case eLabelPosition.BestFit: SetPositionBasic(_parentPoint, _labelPosition); break; case eLabelPosition.Top: @@ -407,27 +431,76 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) SetPositionBasic(_parentPoint, _labelPosition); break; case eLabelPosition.InBase: - //Translate to the base point - Rectangle.Bounds.Position += basePoint.LocalPosition; - //We want to stay inside the object so if the direction to end is Right we want to set the label to the Left - SetBasicPositionForInOrOut(endToBaseVector, false); + SetInOut(endToBaseVector, basePoint.LocalPosition, true); + //SetInBase(endToBaseVector, basePoint.LocalPosition); + ////Translate to the base point + //Rectangle.Bounds.Position += basePoint.LocalPosition; + //SetAdjustedTextBoxPosition(endToBaseVector, true); + //var textPartToPoint = new Vector2(_txtBox.Width + 5, _txtBox.Height + 5) * -1; + //Rectangle.Bounds.Position += textPartToPoint; + //var reverseDir = new Vector2(direction.X + _txtBox.Width + 5, direction.Y + _txtBox.Height + 5) * -1; + + ////We want to stay inside the object so if the direction to end is Right we want to set the label to the Left + //SetBasicPositionForInOrOut(endToBaseVector, false); break; case eLabelPosition.InEnd: - //Move to end point - Rectangle.Bounds.Position += endPoint.LocalPosition; - - //We want to stay inside the object so if the direction to end is Right we want to set the label to the Right - //Since the only time endToBaseVector is Positive is when the end is further positive than the base - SetBasicPositionForInOrOut(endToBaseVector, true); + SetInOut(endToBaseVector, endPoint.LocalPosition, false); + ////Move to end point + //Rectangle.Bounds.Position += endPoint.LocalPosition; + + //SetAdjustedTextBoxPosition(endToBaseVector, false); + //var txtBoxAdjustVector = new Vector2(_txtBox.Width / 2d, _txtBox.Height / 2d); + //var percentualLength = txtBoxAdjustVector.Length / endToBaseVector.Length; + //Rectangle.Bounds.Position += endToBaseVector * percentualLength; + + //var textPartToPoint2 = new Vector2(1 - ((_txtBox.Width/2) / endPoint.LocalPosition.X), 1 - ((_txtBox.Height/2) / endPoint.LocalPosition.Y)); + //Rectangle.Bounds.Position *= textPartToPoint2; + ////We want to stay inside the object so if the direction to end is Right we want to set the label to the Right + ////Since the only time endToBaseVector is Positive is when the end is further positive than the base + //SetBasicPositionForInOrOut(endToBaseVector, true); break; case eLabelPosition.OutEnd: //Move to end point - Rectangle.Bounds.Position += endPoint.LocalPosition; + SetInOut(endToBaseVector, endPoint.LocalPosition, true); + //Rectangle.Bounds.Position += endPoint.LocalPosition; + //SetAdjustedTextBoxPosition(endToBaseVector, true); + //var txtBoxAdjustVector = new Vector2(_txtBox.Width / 2d, _txtBox.Height / 2d); + //var percentualLength = txtBoxAdjustVector.Length / endToBaseVector.Length; + //Rectangle.Bounds.Position += endToBaseVector * percentualLength; + //var textPartToPoint3 = new Vector2(_txtBox.Width + 5, _txtBox.Height + 5) * -1; + //Rectangle.Bounds.Position += textPartToPoint3; + ////We want to set the label outside the object + ////So we do the opposite of InEnd + //SetBasicPositionForInOrOut(endToBaseVector, false); - //We want to set the label outside the object - //So we do the opposite of InEnd - SetBasicPositionForInOrOut(endToBaseVector, false); + break; + //Only available in charts that include pie chart + case eLabelPosition.BestFit: + //Try to fit within object if possible. If not then as close to it as possible? + //var labelVector = new Vector2(_txtBox.Width, _txtBox.Height); + bool CanFitWidth = _txtBox.Width < Math.Abs(endToBaseVector.X); + bool CanFitHeight = _txtBox.Height < Math.Abs(endToBaseVector.Y); + if (CanFitWidth && CanFitHeight) + { + //TODO: make input parameter in pie chart + bool canFitInCenter = false; + if (canFitInCenter) + { + Rectangle.Bounds.Left += centerPoint.LocalPosition.X; + Rectangle.Bounds.Top += centerPoint.LocalPosition.Y; + } + else + { + //Set inside End + SetInOut(endToBaseVector, endPoint.LocalPosition, false); + } + } + else + { + //Set outside end + SetInOut(endToBaseVector, endPoint.LocalPosition, true); + } break; default: throw new InvalidOperationException($"The datalabel position {_labelPosition} has not been implemented yet"); @@ -738,6 +811,9 @@ public override void AppendRenderItems(List renderItems) parentPointGroup.RenderItems.Add(group); + group.RotationPoint = new Graphics.Point(_txtBox.Left + (_txtBox.Width / 2), _txtBox.Top + (_txtBox.Height / 2)); + group.Rotation = CounterRotation; + _txtBox.AppendRenderItems(group.RenderItems); if(_renderConnectionPointLines) From c0770cd5ea41c90d646e7abc0ce877f7ec9bb5e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 22 Jun 2026 10:03:40 +0200 Subject: [PATCH 084/118] Fixed bugs and cleaned up datalabels --- .../Chart/BarChartTests.cs | 10 +- .../BarColumnChartTypeDrawer.cs | 17 +- .../DataLabels/ChartSerieDataLabelRenderer.cs | 33 +- .../Chart/DataLabels/SvgDataLabelPoint.cs | 283 +----------------- 4 files changed, 28 insertions(+), 315 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index f09e6c0318..25c529bcfb 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -47,13 +47,12 @@ public void GenerateSvgForBarCharts2() public void DatalabelBarCharts() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("BarChartForSvgDatalabels2.xlsx")) + using (var p = OpenTemplatePackage("BarChartForSvgDatalabelsBasic.xlsx")) { var ws = p.Workbook.Worksheets[0]; var drawings = ws.Drawings; var ix = 0; - //var svg = drawings[ix].ToSvg(); - //SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); + for (int i = ix; i < drawings.Count; i++) { var svg = drawings[i].ToSvg(); @@ -71,9 +70,8 @@ public void NegativeDatalabelBarCharts() { var ws = p.Workbook.Worksheets[0]; var drawings = ws.Drawings; - var ix = 1; - //var svg = drawings[ix].ToSvg(); - //SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); + var ix = 0; + for (int i = ix; i < drawings.Count; i++) { var svg = drawings[i].ToSvg(); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 93ef8c2017..bc3fbe8a61 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -86,15 +86,23 @@ internal override void DrawSeries() if (isColumn == true) { var middleRight = dataPoints[j].Left + (dataPoints[j].Width / 2); - basePoint.Position = new Vector2(middleRight, chartBaseY); - if (chartBaseY > dataPoints[j].Top) + if (chartBaseY >= dataPoints[j].Top) { - endPoint.Position = new Vector2(middleRight, chartBaseY - dataPoints[j].Height); + //We are a negative column + // ----- Base-Axis + // |_| Col + basePoint.Position = new Vector2(middleRight, dataPoints[j].Top); + endPoint.Position = new Vector2(middleRight, dataPoints[j].Bottom); } else { - endPoint.Position = new Vector2(middleRight, chartBaseY + dataPoints[j].Height); + //We are a positive column + // _ + // | | Col + // ----- Base-Axis + basePoint.Position = new Vector2(middleRight, dataPoints[j].Top); + endPoint.Position = new Vector2(middleRight, dataPoints[j].Bottom); } serieDataLabels[i].SetDimensions(j, basePoint, endPoint); @@ -111,7 +119,6 @@ internal override void DrawSeries() { endPoint.Position = new Vector2(dataPoints[j].Left + dataPoints[j].Width, middleHeight); } - //endPoint.Position = new Vector2(basePoint.Position.X + dataPoints[j].Width, middleHeight); serieDataLabels[i].SetDimensions(j, basePoint, endPoint); } diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs index 58f34a3a41..8b44fceca3 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs @@ -121,22 +121,6 @@ private void AddDatalabel(ExcelChartStandardSerie serie, ExcelChartDataLabelStan dataLabels.Add(newDataLabel); } - BoundingBox _parentShapeBounds = null; - Vector2 _startToEndDir = Vector2.Zero; - /// - /// Datapoints can have different shapes. - /// Which gives different meaning to positions like'Center' and 'Inside' and 'Outside' - /// Therefore you have the option to provide the bounds of a shape and its endpoint - /// - /// - /// - /// - internal void SetParentShape(BoundingBox parentBounds, BoundingBox shapeEndPoint, int index) - { - _parentShapeBounds = parentBounds; - SetParentPoint(shapeEndPoint, index); - } - internal void SetDimensions(int index, Transform basePoint, Transform endPoint) { if (dataLabels.Count > index) @@ -145,19 +129,12 @@ internal void SetDimensions(int index, Transform basePoint, Transform endPoint) } } - internal void SetParentVector(BoundingBox parentPoint, int index, Vector2 startToEndDir) - { - _startToEndDir = startToEndDir; - SetParentPoint(parentPoint, index); - } - internal void SetParentPoint(BoundingBox parent, int index) { if (dataLabels.Count > index) { - dataLabels[index].SetParentPoint(parent, _parentShapeBounds, _startToEndDir); + dataLabels[index].SetParentPoint(parent); } - //dataLabels[index].SetParentPoint(parent); } public override void AppendRenderItems(List renderItems) @@ -178,16 +155,8 @@ public override void AppendRenderItems(List renderItems) if (_dlblSerie.Fill.IsEmpty == false) { - //Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); - //plotAreaGroup.AddChildItem(Rectangle); Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); plotAreaGroup.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); - //if (_dlblSerie.Fill.Color.ToArgb() == Color.Transparent.ToArgb()) - //{ - // plotAreaGroup.FillColor = "transparent"; - //} - - //plotAreaGroup.GroupTransform += $" fill=\"{plotAreaGroup.FillColor}\" name=\"Plot area group\""; } renderItems.Add(plotAreaGroup); diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 6a4232f7b0..2222af6c02 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -1,6 +1,5 @@ using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; -using EPPlus.DrawingRenderer.ShapeDefinitions; using EPPlus.Graphics; using EPPlus.Graphics.Geometry; using EPPlusImageRenderer; @@ -9,13 +8,9 @@ using OfficeOpenXml.Drawing; using OfficeOpenXml.Drawing.Chart; using OfficeOpenXml.Drawing.Renderer.TextBox; -using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.Utils.EnumUtils; using System; using System.Collections.Generic; -using System.Drawing; -using System.Net; -using static OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions.RoundingHelper; namespace EPPlus.Export.ImageRenderer.RenderItems.SvgItem { @@ -238,10 +233,6 @@ private int GetClosestConnectionPointCoordinateIndex(Coordinate originPoint) return smallestIndex; } - BoundingBox _parentShapeBounds = null; - - - private void SetPositionBasic(BoundingBox point, eLabelPosition basicPosition) { switch (basicPosition) @@ -302,54 +293,6 @@ private void CreateDebugPoints(Transform basePoint, Transform endPoint, Transfor centerPositionRect.Left += centerPoint.LocalPosition.X; centerPositionRect.Top += centerPoint.LocalPosition.Y; } - - private void SetBasicPositionBasedOnInput(double pointOnAxis, BoundingBox axisMargin, eLabelPosition positivePosition, eLabelPosition negativePosition) - { - if(pointOnAxis != 0) - { - //If point is positive - if (pointOnAxis > 0) - { - //We must place to the left - SetPositionBasic(axisMargin, positivePosition); - } - //if basePoint is to the left - else - { - //We must place to the right - SetPositionBasic(axisMargin, negativePosition); - } - } - } - - /// - /// Set the basic label position based on the direction of a vector - /// - /// Shows what direction is in or out - /// Set the label in the same direction as the vector (set to false for opposite) - private void SetBasicPositionForInOrOut(Vector2 direction, bool inDirection) - { - //Could be simplified by pos Array defined by isIn - if(inDirection) - { - //If direction is to the right (positive) - //We must set the label to the Right to stay outside the shape. and vice versa - SetBasicPositionBasedOnInput(direction.X, new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Right, eLabelPosition.Left); - //If direction is to the bottom (positive) - //We must set the label to the bottom to stay within the shape. and vice versa - SetBasicPositionBasedOnInput(direction.Y, new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Bottom, eLabelPosition.Top); - } - else - { - //If direction is to the right (positive) - //We must set the label to the left to stay within the shape. and vice versa - SetBasicPositionBasedOnInput(direction.X, new BoundingBox(0, 0) { Width = 5d }, eLabelPosition.Left, eLabelPosition.Right); - //If direction is to the downwards (positive) - //We must set the label to the top to stay within the shape. and vice versa - SetBasicPositionBasedOnInput(direction.Y, new BoundingBox(0, 0) { Height = 5d }, eLabelPosition.Top, eLabelPosition.Bottom); - } - } - private void SetAdjustedTextBoxPosition(Vector2 direction, bool reverseDirection) { if(reverseDirection) @@ -357,22 +300,23 @@ private void SetAdjustedTextBoxPosition(Vector2 direction, bool reverseDirection direction *= -1; } + //Ensure vector is normalized + var directionOnly = direction / direction.Length; + + //Get txtbox-size based vector var txtBoxAdjustVector = new Vector2(_txtBox.Width / 2d, _txtBox.Height / 2d); - var percentualLength = txtBoxAdjustVector.Length / direction.Length; - Rectangle.Bounds.Position += direction * percentualLength; + + //Apply translation to current position + Rectangle.Bounds.Position += directionOnly * txtBoxAdjustVector; } private void SetInOut(Vector2 direction, Vector2 translation, bool reverseDirection) { - //Translate to the base point + //Translate to the translation point Rectangle.Bounds.Position += translation; SetAdjustedTextBoxPosition(direction, reverseDirection); } - //private void SetInEnd(Vector2 direction, Vector2 translation) - - //private void SetLeft(Transform centerPoint) - /// /// /// @@ -432,47 +376,12 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) break; case eLabelPosition.InBase: SetInOut(endToBaseVector, basePoint.LocalPosition, true); - //SetInBase(endToBaseVector, basePoint.LocalPosition); - ////Translate to the base point - //Rectangle.Bounds.Position += basePoint.LocalPosition; - //SetAdjustedTextBoxPosition(endToBaseVector, true); - //var textPartToPoint = new Vector2(_txtBox.Width + 5, _txtBox.Height + 5) * -1; - //Rectangle.Bounds.Position += textPartToPoint; - //var reverseDir = new Vector2(direction.X + _txtBox.Width + 5, direction.Y + _txtBox.Height + 5) * -1; - - ////We want to stay inside the object so if the direction to end is Right we want to set the label to the Left - //SetBasicPositionForInOrOut(endToBaseVector, false); break; case eLabelPosition.InEnd: SetInOut(endToBaseVector, endPoint.LocalPosition, false); - ////Move to end point - //Rectangle.Bounds.Position += endPoint.LocalPosition; - - //SetAdjustedTextBoxPosition(endToBaseVector, false); - //var txtBoxAdjustVector = new Vector2(_txtBox.Width / 2d, _txtBox.Height / 2d); - //var percentualLength = txtBoxAdjustVector.Length / endToBaseVector.Length; - //Rectangle.Bounds.Position += endToBaseVector * percentualLength; - - //var textPartToPoint2 = new Vector2(1 - ((_txtBox.Width/2) / endPoint.LocalPosition.X), 1 - ((_txtBox.Height/2) / endPoint.LocalPosition.Y)); - //Rectangle.Bounds.Position *= textPartToPoint2; - ////We want to stay inside the object so if the direction to end is Right we want to set the label to the Right - ////Since the only time endToBaseVector is Positive is when the end is further positive than the base - //SetBasicPositionForInOrOut(endToBaseVector, true); break; case eLabelPosition.OutEnd: - //Move to end point SetInOut(endToBaseVector, endPoint.LocalPosition, true); - //Rectangle.Bounds.Position += endPoint.LocalPosition; - //SetAdjustedTextBoxPosition(endToBaseVector, true); - //var txtBoxAdjustVector = new Vector2(_txtBox.Width / 2d, _txtBox.Height / 2d); - //var percentualLength = txtBoxAdjustVector.Length / endToBaseVector.Length; - //Rectangle.Bounds.Position += endToBaseVector * percentualLength; - //var textPartToPoint3 = new Vector2(_txtBox.Width + 5, _txtBox.Height + 5) * -1; - //Rectangle.Bounds.Position += textPartToPoint3; - ////We want to set the label outside the object - ////So we do the opposite of InEnd - //SetBasicPositionForInOrOut(endToBaseVector, false); - break; //Only available in charts that include pie chart case eLabelPosition.BestFit: @@ -507,59 +416,18 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) } } - internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, Vector2 startToEndDir) + internal void SetParentPoint(BoundingBox parentPoint) { Rectangle.Bounds.Parent = parentPoint; _parentPoint = parentPoint; - _parentShapeBounds = parentShape; var dataLabelCenter = new Vector2(Rectangle.Bounds.Left, Rectangle.Bounds.Top); - Vector2 startPointDirection = Vector2.Zero; - - if ((startToEndDir.X == 0 && startToEndDir.Y == 0) == false) - { - startPointDirection = startToEndDir / startToEndDir.Length; - } - - //Rectangle.Bounds.Left += 20; - - //if (_parentShapeBounds != null) - //{ - // //shapeCenter - // dataLabelCenter = new Graphics.Math.Vector2((_parentShapeBounds.Width / 2)+parentShape.Left, (_parentShapeBounds.Height / 2)+parentShape.Top); - - // //Get directional vector (in local coords but does not matter since we make it directional) - // startPointDirection = dataLabelCenter - parentPoint.LocalPosition; - // //Divide by length to only get direction - // var startPointDirectionOnly = startPointDirection / startPointDirection.Length; - - // //var lenX = Math.Abs() - // //////Pythagoran theorem - // var len = Math.Sqrt(Math.Pow(startPointDirection.X, 2) + Math.Pow(startPointDirection.Y, 2)); - // startPointDirection = startPointDirectionOnly * len; - //} - //else - //{ - // dataLabelCenter = new Graphics.Math.Vector2(Bounds.Left, Bounds.Top); - //} switch (_labelPosition) { case eLabelPosition.Center: - - if ((startToEndDir.X == 0 && startToEndDir.Y == 0) == false) - { - //Half and invert - dataLabelCenter = ((startToEndDir * 0.5d) * -1d); - } - else if (startToEndDir.Y != 0) - { - //Half and invert - dataLabelCenter = ((startToEndDir * 0.5d) * -1d); - } - - Rectangle.Bounds.Left += dataLabelCenter.X; - Rectangle.Bounds.Top += dataLabelCenter.Y; + Rectangle.Bounds.Left += Rectangle.Bounds.Left; + Rectangle.Bounds.Top += Rectangle.Bounds.Top; break; case eLabelPosition.Left: SetPositionBasic(parentPoint, _labelPosition); @@ -574,135 +442,6 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V case eLabelPosition.Bottom: SetPositionBasic(parentPoint, _labelPosition); break; - case eLabelPosition.InBase: - var endToStartVector = startToEndDir * -1; - //Rectangle.Left *= endToStartVector.X; - //Rectangle.Top *= endToStartVector.Y; - if (startPointDirection.X != 0) - { - Rectangle.Left += endToStartVector.X; - //If basePoint is to the left - if (startPointDirection.X < 0) - { - //We must place to the left - SetPositionBasic(new BoundingBox(0,0), eLabelPosition.Left); - } - //if basePoint is to the right - else - { - //We must place to the right - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Right); - } - } - - if (startPointDirection.Y != 0) - { - Rectangle.Top += endToStartVector.Y; - //If endpoint is on Top - if (startPointDirection.Y < 0) - { - //We must place on bottom - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Top); - } - //If endpoint is on bottom - else - { - //We must place on top - SetPositionBasic(new BoundingBox(0, 0), eLabelPosition.Bottom); - } - } - break; - case eLabelPosition.InEnd: - if (startPointDirection.X != 0) - { - //If endPoint is to the left - if (startPointDirection.X < 0) - { - //We must place to the right - SetPositionBasic(parentPoint, eLabelPosition.Right); - } - //if endpoint is to the right - else - { - //We must place to the left - SetPositionBasic(parentPoint, eLabelPosition.Left); - } - } - - if (startPointDirection.Y != 0) - { - //If endpoint is on Top - if (startPointDirection.Y < 0) - { - //We must place on bottom - SetPositionBasic(parentPoint, eLabelPosition.Bottom); - } - //If endpoint is on bottom - else - { - //We must place on top - SetPositionBasic(parentPoint, eLabelPosition.Top); - } - } - //if (startPointDirection.X == 0 && startPointDirection.Y == 0) - //{ - // throw new InvalidOperationException("eLabelPosition.InEnd MUST have a direction." + - // "Cannot be within End if EndPoint is undefined."); - //} - //var insidePos = startToEndDir * 0.15 * -1; - //Rectangle.Bounds.Left += insidePos.X; - //Rectangle.Bounds.Top += insidePos.Y; - break; - case eLabelPosition.OutEnd: - //if (startPointDirection.X == 0 && startPointDirection.Y == 0) - //{ - // throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a direction." + - // "Cannot be within End if EndPoint is undefined."); - //} - //if (startPointDirection.X == 0 && startPointDirection.Y == 0) - //{ - // throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a direction." + - // "Cannot be within End if EndPoint is undefined."); - //} - //Rectangle.Bounds.Left += startToEndDir.X * 0.15; - //Rectangle.Bounds.Top += startToEndDir.Y * 0.15; - //if (parentShape == null) - //{ - // throw new InvalidOperationException("eLabelPosition.OutEnd MUST have a parentShape"); - //} - - if (startPointDirection.X != 0) - { - //If endPoint is to the left - if (startPointDirection.X < 0) - { - //We must place to the left - SetPositionBasic(parentPoint, eLabelPosition.Left); - } - //if endpoint is to the right - else - { - //We must place to the right - SetPositionBasic(parentPoint, eLabelPosition.Right); - } - } - - if (startPointDirection.Y != 0) - { - //If endpoint is on Top - if (startPointDirection.Y < 0) - { - //We must place on Top - SetPositionBasic(parentPoint, eLabelPosition.Top); - } - //If endpoint is on bottom - else - { - //We must place on Bottom - SetPositionBasic(parentPoint, eLabelPosition.Bottom); - } - } - break; default: throw new InvalidOperationException($"The datalabel position {_labelPosition} has not been implemented yet"); } From 3bb4be8db5ab6012b73ab52b247ce0f6b634a1b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 22 Jun 2026 10:26:20 +0200 Subject: [PATCH 085/118] Cleanup and added all pie datalabels to same file --- .../Chart/PieChartTests.cs | 147 +----------------- .../BarColumnChartTypeDrawer.cs | 2 - .../ChartTypeDrawers/PieChartTypeDrawer.cs | 18 --- 3 files changed, 2 insertions(+), 165 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs index 196ee89afc..693294b5c4 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs @@ -12,31 +12,10 @@ public class PieChartTests : TestBase { [TestMethod] - public void ReadAndGenerateAll() + public void ReadAndCreateSvgsAll() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("PieChartSvgALL.xlsx")) - { - var ws = p.Workbook.Worksheets[4]; - - for (int i = 0; i < p.Workbook.Worksheets.Count; i++) - { - ws = p.Workbook.Worksheets[i]; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\PieChartSvgALL\\s{i}_{ws.Name}_{c.Name}.svg", svg); - } - } - } - } - - - [TestMethod] - public void ReadAndGenerateExcelPieChartPointExplosion() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("PointExplosionStandAlone.xlsx")) { var ws = p.Workbook.Worksheets[0]; @@ -46,129 +25,7 @@ public void ReadAndGenerateExcelPieChartPointExplosion() foreach (ExcelChart c in ws.Drawings) { var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\PointExplosionStandAlone\\s{i}_{ws.Name}_{c.Name}.svg", svg); - } - } - } - } - - [TestMethod] - public void BasicPieChart() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("BasicPieChart.xlsx")) - { - var ws = p.Workbook.Worksheets[0]; - - var ix = 0; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\BasicPieChart{ix++}.svg", svg); - } - } - } - - [TestMethod] - public void Datalabels() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("PieChartDlblsOrig.xlsx")) - { - //var ws = p.Workbook.Worksheets[0]; - - //var drawing = ws.Drawings["InsideEnd"]; - - //var svg = drawing.ToSvg(); - //SaveTextFileToWorkbook($"svg\\PieChartDlbls\\s{0}_{ws.Name}_{drawing.Name}.svg", svg); - - for (int i = 0; i < p.Workbook.Worksheets.Count; i++) - { - var ws = p.Workbook.Worksheets[i]; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\PieChartDlbls\\s{i}_{ws.Name}_{c.Name}.svg", svg); - } - } - } - } - - [TestMethod] - public void Datalabels2() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("PieChartDlblsInsideEndOnly.xlsx")) - { - var ws = p.Workbook.Worksheets[0]; - - for (int i = 0; i < p.Workbook.Worksheets.Count; i++) - { - ws = p.Workbook.Worksheets[i]; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\PieChartDlbls\\s{i}_{ws.Name}_{c.Name}.svg", svg); - } - } - } - } - - [TestMethod] - public void ReadLegendIssue() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("PieChartSvgLegendIssue.xlsx")) - { - var ws = p.Workbook.Worksheets[0]; - - for (int i = 0; i < p.Workbook.Worksheets.Count; i++) - { - ws = p.Workbook.Worksheets[i]; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\PieChartSvgLegendIssue\\s{i}_{ws.Name}_{c.Name}.svg", svg); - } - } - } - } - - [TestMethod] - public void Datalabels22() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("PieChartDlblsInside.xlsx")) - { - var ws = p.Workbook.Worksheets[0]; - - for (int i = 0; i < p.Workbook.Worksheets.Count; i++) - { - ws = p.Workbook.Worksheets[i]; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\PieChartDlbls22\\s{i}_{ws.Name}_{c.Name}.svg", svg); - } - } - } - } - - - [TestMethod] - public void Datalabels3() - { - ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("PieChartDlblsOutside.xlsx")) - { - var ws = p.Workbook.Worksheets[0]; - for (int i = 0; i < p.Workbook.Worksheets.Count; i++) - { - ws = p.Workbook.Worksheets[i]; - foreach (ExcelChart c in ws.Drawings) - { - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\PieChartDlbls3\\s{i}_{ws.Name}_{c.Name}.svg", svg); + SaveTextFileToWorkbook($"svg\\PieChartSvgALL\\s{i}_{ws.Name}_{c.Name}.svg", svg); } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index bc3fbe8a61..05a14f19fb 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -5,9 +5,7 @@ using EPPlusImageRenderer; using EPPlusImageRenderer.RenderItems; using EPPlusImageRenderer.Svg; -using OfficeOpenXml.DigitalSignatures; using OfficeOpenXml.Drawing.Chart; -using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; using OfficeOpenXml.Utils.TypeConversion; using System; using System.Collections.Generic; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs index bc24cc1d55..9c4ec5975e 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs @@ -172,17 +172,9 @@ internal override void DrawSeries() { _groupItem = new GroupRenderItem(ChartRenderer.Plotarea.Group.Bounds); - //_groupItem.Left = ChartRenderer.Plotarea.Group.Left; - //_groupItem.Top = ChartRenderer.Plotarea.Group.Top; - - //_groupItem.TransformOrigin = new Coordinate(ChartRenderer.Plotarea.LeftMargin, ChartRenderer.Plotarea.TopMargin); - Rectangle.Bounds.Name = "ChartDrawer"; - _groupItem.Bounds.Name = "OuterGroupChartDrawer"; - //_groupitem.bounds.parent = _groupitem.translationoffset; - var chartType = (ExcelPieChart)_chartType; //Read and set Starting angle offset as a rotation on the container @@ -260,12 +252,6 @@ internal override void DrawSeries() startPt.Position = innerGroup.Position + (ctrToMid * -1); serieDataLabels[i].SetDimensions(j, startPt, innerGroup); - - //var endPoint = Slices[j].CopyOuterMidPoint(); - //var startPoint = _circleCenter.Position - //serieDataLabels[i].SetDimensions(j, startPoint, endPoint); - - //serieDataLabels[i].SetParentVector(dlblBounds, j, Slices[j].GetWholeVectorCenterToMid()); } } } @@ -284,14 +270,10 @@ internal override void DrawSeries() public override void AppendRenderItems(List renderItems) { ChartRenderer.Plotarea.Group.AddChildItem(_groupItem); - //ChartRenderer.Plotarea.Group.AddChildItem(SeriesRenderItems[0]); if(SeriesRenderItems != null && SeriesRenderItems.Count > 0) { ChartRenderer.RenderItems.Add(SeriesRenderItems[0]); } - //SeriesRenderItems.ForEach(x => ChartRenderer.Plotarea.Group.AddChildItem(x)); - //renderItems.AddRange(ChartAreaRenderItems); - //SeriesRenderItems.ForEach(x => _groupItem.AddChildItem(x)); } } } From e3695a8e1d37481d2bb1e34bb531b0d62c424084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 22 Jun 2026 13:33:18 +0200 Subject: [PATCH 086/118] Start of adding svg to html export --- .../SvgStandAloneTests.cs | 3 -- .../HtmlExport/Enums/eDrawingInclude.cs | 33 +++++++++++++ .../Internal/AbstractRangeExporter.cs | 36 +++++++++++++- .../Exporters/Internal/CssExporterBase.cs | 2 +- .../Internal/HtmlExporterBaseInternal.cs | 47 ++++++++++++++++++- .../Internal/HtmlTableExporterBase.cs | 2 +- src/EPPlus/Export/HtmlExport/HtmlDrawing.cs | 21 +++++++++ src/EPPlus/Export/HtmlExport/HtmlElements.cs | 1 + src/EPPlus/Export/HtmlExport/HtmlImage.cs | 11 +---- .../Export/HtmlExport/HtmlSvgDrawing.cs | 13 +++++ .../Settings/HtmlDrawingSettings.cs | 28 +++++++++++ .../HtmlExport/Settings/HtmlExportSettings.cs | 7 +++ .../Export/HtmlExport/SvgShapeExportTests.cs | 33 +++++++++++++ 13 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 src/EPPlus/Export/HtmlExport/Enums/eDrawingInclude.cs create mode 100644 src/EPPlus/Export/HtmlExport/HtmlDrawing.cs create mode 100644 src/EPPlus/Export/HtmlExport/HtmlSvgDrawing.cs create mode 100644 src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs create mode 100644 src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index bf24ef0fd7..6bdb9e74b8 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -4,11 +4,8 @@ using EPPlus.Export.ImageRenderer.RenderItems.SvgItem; using EPPlus.Fonts.OpenType.Integration.DataHolders; using EPPlus.Graphics; -using OfficeOpenXml.FormulaParsing.Excel.Functions.Engineering; -using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using System.Drawing; using System.Text; -using static OfficeOpenXml.Drawing.OleObject.Structures.OleObjectDataStructures; namespace EPPlus.Export.ImageRenderer.Tests.DrawingShapeRenderer { diff --git a/src/EPPlus/Export/HtmlExport/Enums/eDrawingInclude.cs b/src/EPPlus/Export/HtmlExport/Enums/eDrawingInclude.cs new file mode 100644 index 0000000000..d092f66c83 --- /dev/null +++ b/src/EPPlus/Export/HtmlExport/Enums/eDrawingInclude.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.Export.HtmlExport +{ + /// + /// What drawings to include in html export + /// + [Flags] + public enum eDrawingInclude + { + /// + /// Include no drawings + /// + None = 0, + /// + /// Include Shapes + /// + Shapes = 2, + /// + /// Include Charts + /// + Charts = 4, + + //TODO: This is already handled by image enum. We may need restructure here + /// + /// Include Images ? + /// + Images = 8, + } +} diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/AbstractRangeExporter.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/AbstractRangeExporter.cs index 61cc6bc4e5..151fbae2a8 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/AbstractRangeExporter.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/AbstractRangeExporter.cs @@ -27,6 +27,7 @@ public AbstractHtmlExporter() internal const string TableClass = "epplus-table"; internal List _rangePictures = null; + internal List _rangeDrawings = null; protected List _dataTypes = new List(); protected ExporterContext _exporterContext; @@ -45,13 +46,14 @@ protected void GetDataTypes(ExcelAddressBase adr, ExcelTable table) } } - internal void LoadRangeImages(List ranges) + internal void LoadRangeDrawings(List ranges) { if (_rangePictures != null) { return; } _rangePictures = new List(); + _rangeDrawings = new List(); //Render in-cell images. foreach (var worksheet in ranges.Select(x => x.Worksheet).Distinct()) { @@ -76,6 +78,25 @@ internal void LoadRangeImages(List ranges) ToColumnOff = toColOff }); } + if(d is ExcelShape s) + { + s.GetFromBounds(out int fromRow, out int fromRowOff, out int fromCol, out int fromColOff); + s.GetToBounds(out int toRow, out int toRowOff, out int toCol, out int toColOff); + + _rangeDrawings.Add(new HtmlSvgDrawing() + { + WorksheetId = worksheet.PositionId, + Drawing = s, + FromRow = fromRow, + FromRowOff = fromRowOff, + FromColumn = fromCol, + FromColumnOff = fromColOff, + ToRow = toRow, + ToRowOff = toRowOff, + ToColumn = toCol, + ToColumnOff = toColOff + }); + } } } } @@ -114,5 +135,18 @@ protected HtmlImage GetImage(int worksheetId, int row, int col) } return null; } + + protected HtmlSvgDrawing GetDrawing(int worksheetId, int row, int col) + { + if (_rangeDrawings == null) return null; + foreach (var d in _rangeDrawings) + { + if (d.FromRow == row - 1 && d.FromColumn == col - 1 && d.WorksheetId == worksheetId) + { + return d; + } + } + return null; + } } } diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs index 789d89e7fe..22fb929ac2 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs @@ -139,7 +139,7 @@ protected void AddCssRulesToCollection(CssRangeRuleCollection cssTranslator, Htm if (Settings.Pictures.Include == ePictureInclude.Include) { - LoadRangeImages(_ranges._list); + LoadRangeDrawings(_ranges._list); foreach (var p in _rangePictures) { cssTranslator.AddPictureToCss(p); diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs index e970079292..d2a9f086c4 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs @@ -50,7 +50,7 @@ public HtmlExporterBaseInternal(HtmlExportSettings settings, ExcelRangeBase rang } } - LoadRangeImages(_ranges._list); + LoadRangeDrawings(_ranges._list); } public HtmlExporterBaseInternal(HtmlExportSettings settings, EPPlusReadOnlyList ranges) @@ -59,7 +59,7 @@ public HtmlExporterBaseInternal(HtmlExportSettings settings, EPPlusReadOnlyList< Require.Argument(ranges).IsNotNull("ranges"); _ranges = ranges; //TODO: Fix support for all ranges - LoadRangeImages(_ranges._list); + LoadRangeDrawings(_ranges._list); } protected void SetColumnGroup(HTMLElement element, ExcelRangeBase _range, HtmlExportSettings settings, bool isMultiSheet) @@ -121,6 +121,7 @@ protected HTMLElement GetThead(ExcelRangeBase range, List headers = null ExcelWorksheet worksheet = range.Worksheet; HtmlImage image = null; + HtmlSvgDrawing drawing = null; foreach (var col in _columns) { if (InMergeCellSpan(row, col)) continue; @@ -153,7 +154,13 @@ protected HTMLElement GetThead(ExcelRangeBase range, List headers = null image = GetImage(cell.Worksheet.PositionId, cell._fromRow, cell._fromCol); } + if(Settings.Drawings.Include == (ePictureInclude.Include | ePictureInclude.IncludeInHtmlOnly)) + { + drawing = GetDrawing(cell.Worksheet.PositionId, cell._fromRow, cell._fromCol); + } + AddImage(contentElement, Settings, image, cell.Value); + AddDrawing(contentElement, Settings, drawing, cell.Value); if (headerRows > 0 || table != null) { @@ -224,6 +231,7 @@ protected HTMLElement GetTableBody(ExcelRangeBase range, int row, int endRow) var ws = range.Worksheet; HtmlImage image = null; + HtmlDrawing drawing = null; bool hasFooter = table != null && table.ShowTotal; while (row <= endRow) { @@ -274,6 +282,11 @@ protected HTMLElement GetTableBody(ExcelRangeBase range, int row, int endRow) image = GetImage(cell.Worksheet.PositionId, cell._fromRow, cell._fromCol); } + if (Settings.Drawings.Include == (ePictureInclude.Include | ePictureInclude.IncludeInHtmlOnly)) + { + drawing = GetDrawing(cell.Worksheet.PositionId, cell._fromRow, cell._fromCol); + } + if (cell.Hyperlink == null) { var addRowScope = table == null ? false : table.ShowFirstColumn && col == table.Address._fromCol || table.ShowLastColumn && col == table.Address._toCol; @@ -429,6 +442,36 @@ protected void AddImage(HTMLElement parent, HtmlExportSettings settings, HtmlIma } } + protected void AddDrawing(HTMLElement parent, HtmlExportSettings settings, HtmlSvgDrawing d, object value) + { + if (d != null) + { + var child = new HTMLElement(HtmlElements.Svg); + var name = d.Drawing.Name; + string drawingName = HtmlExportTableUtil.GetClassName(d.Drawing.Name, $"drawing{d.Drawing.Id}"); + child.AddAttribute("alt", d.Drawing.Name); + if (settings.Pictures.AddNameAsId) + { + child.AddAttribute("id", drawingName); + } + + if (settings.Drawings.Include == ePictureInclude.IncludeInHtmlOnly) + { + child.ElementName = "div"; + child.Content = d.Drawing.As.Shape.ToSvg(); + //ePictureType? type; + //var _encodedImage = ImageEncoder.EncodeImage(image, out type); + + //child.AddAttribute("src", $"data:{GetContentType(type.Value)};base64,{_encodedImage}"); + } + else + { + child.AddAttribute("class", $"{settings.StyleClassPrefix}drawing-{name} {settings.StyleClassPrefix}drawing-prop-{drawingName}"); + } + parent._childElements.Add(child); + } + } + protected List _columns = new List(); protected HtmlExportSettings Settings; protected readonly List _mergedCells = new List(); diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlTableExporterBase.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlTableExporterBase.cs index 9648722f95..64633caec8 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlTableExporterBase.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlTableExporterBase.cs @@ -31,7 +31,7 @@ internal HtmlTableExporterBase _table = table; _tableExportSettings = settings; - LoadRangeImages(new List() { table.Range }); + LoadRangeDrawings(new List() { table.Range }); } protected readonly ExcelTable _table; diff --git a/src/EPPlus/Export/HtmlExport/HtmlDrawing.cs b/src/EPPlus/Export/HtmlExport/HtmlDrawing.cs new file mode 100644 index 0000000000..61836e5710 --- /dev/null +++ b/src/EPPlus/Export/HtmlExport/HtmlDrawing.cs @@ -0,0 +1,21 @@ +using OfficeOpenXml.Drawing; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.Export.HtmlExport +{ + internal class HtmlDrawing + { + public int WorksheetId { get; set; } + public int FromRow { get; set; } + public int FromRowOff { get; set; } + public int ToRow { get; set; } + public int ToRowOff { get; set; } + public int FromColumn { get; set; } + public int FromColumnOff { get; set; } + public int ToColumn { get; set; } + public int ToColumnOff { get; set; } + } +} diff --git a/src/EPPlus/Export/HtmlExport/HtmlElements.cs b/src/EPPlus/Export/HtmlExport/HtmlElements.cs index 8f32db0ca0..9822084b40 100644 --- a/src/EPPlus/Export/HtmlExport/HtmlElements.cs +++ b/src/EPPlus/Export/HtmlExport/HtmlElements.cs @@ -46,5 +46,6 @@ internal static readonly HashSet NoIndentElements public const string Span = "span"; public const string ColGroup = "colgroup"; public const string Img = "img"; + public const string Svg = "svg"; } } diff --git a/src/EPPlus/Export/HtmlExport/HtmlImage.cs b/src/EPPlus/Export/HtmlExport/HtmlImage.cs index 4ee719a7a9..a5256cb624 100644 --- a/src/EPPlus/Export/HtmlExport/HtmlImage.cs +++ b/src/EPPlus/Export/HtmlExport/HtmlImage.cs @@ -14,17 +14,8 @@ Date Author Change namespace OfficeOpenXml.Export.HtmlExport { - internal class HtmlImage + internal class HtmlImage : HtmlDrawing { - public int WorksheetId { get; set; } public ExcelPicture Picture { get; set; } - public int FromRow { get; set; } - public int FromRowOff { get; set; } - public int ToRow { get; set; } - public int ToRowOff { get; set; } - public int FromColumn { get; set; } - public int FromColumnOff { get; set; } - public int ToColumn { get; set; } - public int ToColumnOff { get; set; } } } diff --git a/src/EPPlus/Export/HtmlExport/HtmlSvgDrawing.cs b/src/EPPlus/Export/HtmlExport/HtmlSvgDrawing.cs new file mode 100644 index 0000000000..19275c6b09 --- /dev/null +++ b/src/EPPlus/Export/HtmlExport/HtmlSvgDrawing.cs @@ -0,0 +1,13 @@ +using OfficeOpenXml.Drawing; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.Export.HtmlExport +{ + internal class HtmlSvgDrawing : HtmlDrawing + { + public ExcelDrawing Drawing; + } +} diff --git a/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs b/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs new file mode 100644 index 0000000000..bab14da648 --- /dev/null +++ b/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.Export.HtmlExport +{ + public class HtmlDrawingSettings + { + internal HtmlDrawingSettings() + { + + } + + //Use picture for now. Possibly re-name + /// + /// If how drawings should be included in the html. Default is + /// + public ePictureInclude Include = ePictureInclude.Exclude; + + /// + /// Which type of drawing should be included + /// + public eDrawingInclude DrawTypeInclude = eDrawingInclude.None; + + + } +} diff --git a/src/EPPlus/Export/HtmlExport/Settings/HtmlExportSettings.cs b/src/EPPlus/Export/HtmlExport/Settings/HtmlExportSettings.cs index eeddc128af..5cde7470e7 100644 --- a/src/EPPlus/Export/HtmlExport/Settings/HtmlExportSettings.cs +++ b/src/EPPlus/Export/HtmlExport/Settings/HtmlExportSettings.cs @@ -136,6 +136,13 @@ public HtmlPictureSettings Pictures { get; } = new HtmlPictureSettings(); + /// + /// If and which Charts and/or Shapes will be included + /// + public HtmlDrawingSettings Drawings + { + get; + } = new HtmlDrawingSettings(); /// /// If set to true classes that identifies Excel table styling will be included in the html. Default value is true. diff --git a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs new file mode 100644 index 0000000000..751d75db01 --- /dev/null +++ b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs @@ -0,0 +1,33 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using OfficeOpenXml.Export.HtmlExport; + +namespace EPPlusTest.Export.HtmlExport +{ + [TestClass] + public class SvgShapeExportTests : TestBase + { + [TestMethod] + public void ExportBasicShapeWorksheet() + { + int[] values = { 5, 10, 15, 20 }; + using (var package = OpenPackage("HtmlBasicSvgShape.xlsx", true)) + { + var ws = package.Workbook.Worksheets.Add("ShapeWs"); + ws.Drawings.AddShape("SimpleRect", OfficeOpenXml.Drawing.eShapeStyle.Rect); + + var exporter = ws.Cells["A1:A20"].CreateHtmlExporter(); + + exporter.Settings.Drawings.Include = ePictureInclude.IncludeInHtmlOnly; + exporter.Settings.Drawings.DrawTypeInclude = eDrawingInclude.Shapes; + + var htmlPage = exporter.GetSinglePage(); + + GetOutputFile("html", "svgRect"); + } + } + } +} From 64b8bbb7bd9a69de6570eb660c715e5df19a74b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Mon, 22 Jun 2026 14:54:51 +0200 Subject: [PATCH 087/118] Render of Errorbars for line charts --- .../RenderItems/RenderItem.cs | 2 +- .../Renderer/Chart/ChartAxisRenderer.cs | 48 ++---- .../BarColumnChartTypeDrawer.cs | 2 +- .../ChartTypeDrawers/ChartErrorBarRenderer.cs | 146 +++++++++++++++--- .../Chart/ChartTypeDrawers/ChartTypeDrawer.cs | 3 +- .../ChartTypeDrawers/LineChartTypeDrawer.cs | 4 +- .../DrawingRenderItemExtentions.cs | 62 ++++---- 7 files changed, 180 insertions(+), 87 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/RenderItems/RenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/RenderItem.cs index 9befe6cb01..73babf0adb 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/RenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/RenderItem.cs @@ -316,7 +316,7 @@ public LineRenderItem Clone() } public abstract class DrawingObject { - public abstract void AppendRenderItems(List renderItems); + public virtual void AppendRenderItems(List renderItems) { } } public abstract class RenderItem : RenderItemBase { diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index d665ab1fef..72a058173f 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -92,48 +92,15 @@ internal ChartAxisRenderer(ChartRenderer sc, ExcelChartAxisStandard ax) : base(s aav == eActualAxisPosition.LeftSecond) { Rectangle.Width = GetTextWidest(ax) + LeftMargin; - //var ll = 8D; - //if (sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Left) - //{ - // ll = sc.Legend.Rectangle.Right + sc.Legend.RightMargin; - //} - //Rectangle.Left = Title == null ? ll : Title.Rectangle.Left + Title.TextBox.GetActualWidth(); } else { Rectangle.Width = GetTextWidest(ax) + RightMargin; - //var lp = sc.ChartArea.Rectangle.Width - Rectangle.Width - 8D; - //if (aav == eActualAxisPosition.Right) - //{ - // if (sc.SecondVerticalAxis?.Axis?.ActualAxisPosition == eActualAxisPosition.RightSecond) - // { - // lp -= sc.SecondVerticalAxis.Rectangle.Width; - // } - // else - // { - // } - //} - //else - //{ - - //} - //if (sc.Chart.HasLegend && sc.Chart.Legend.Position == eLegendPosition.Right) - //{ - // lp = sc.Legend.Rectangle.Left + -Rectangle.Width; - //} - //Rectangle.Left = Title == null ? lp : Title.Rectangle.Left - Rectangle.Width - LeftMargin; } } else { Rectangle.Height = GetTextHeight(ax); - //var rb = 0D; - //if (aav == eActualAxisPosition.Bottom && sc.SecondHorizontalAxis?.Axis.ActualAxisPosition==eActualAxisPosition.BottomSecond) - //{ - // rb = sc.SecondHorizontalAxis.Rectangle.Height; - //} - - //Rectangle.Top = Title == null || ax.AxisPosition == eAxisPosition.Top ? sc.ChartArea.Rectangle.Height - 8 - Rectangle.Height : Title.Rectangle.Top - Rectangle.Height - 8; } } @@ -1134,6 +1101,21 @@ private void AdjustminMaxFromChartObjects(ExcelChartAxisStandard ax, ref double? } } } + if(drawer.SupportsErrorBars) + { + foreach(var v in drawer.ErrorBars.Values) + { + if (v[0] < min) + { + min = v[0]; + } + if (v[2] > max) + { + max = v[2]; + } + } + + } } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 2953ea65ca..4f47ee4c90 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -20,7 +20,7 @@ internal class BarColumnChartTypeDrawer : ChartTypeDrawer List serieDataLabels = new List(); List> dataPointsPerSerie = new List>(); internal override bool SupportsTrendlines => true; - + internal override bool SupportsErrorBars => true; internal BarColumnChartTypeDrawer(ChartRenderer svgChart, ExcelBarChart chartType) : base(svgChart, chartType) { _catValues = new List>(); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartErrorBarRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartErrorBarRenderer.cs index 16e156e31d..25c666a616 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartErrorBarRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartErrorBarRenderer.cs @@ -4,6 +4,7 @@ using EPPlusImageRenderer.RenderItems; using EPPlusImageRenderer.Svg; using OfficeOpenXml.Drawing.Chart; +using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.Utils.TypeConversion; using System; using System.Collections.Generic; @@ -43,21 +44,20 @@ public ChartErrorBarRenderer(ChartRenderer svgChart, ExcelChartErrorBars errorba double avg = _ySerie.Average(); double sumSquaredDeviations = _ySerie.Sum(v => (v - avg) * (v - avg)); double sampleStdDev = Math.Sqrt(sumSquaredDeviations / (n - 1)); // sample std dev (n-1) - - for(int i=0;i < _xSerie.Count;i++) + for (int i=0;i < _xSerie.Count;i++) { if (i >= _ySerie.Length) break; if(errorbars.ValueType == eErrorValueType.StandardError) { + double se = sampleStdDev / Math.Sqrt(n); double y = _ySerie[i]; - Values.Add(new double[] { y - sampleStdDev, y + sampleStdDev }); + Values.Add(new double[] { y - se, y, y + se }); } else { var mult = errorbars.Value ?? 1D; - sampleStdDev*= mult; - Values.Add(new double[] { avg - sampleStdDev, avg + sampleStdDev }); + Values.Add(new double[] { avg - sampleStdDev, avg, avg + sampleStdDev }); } } } @@ -67,7 +67,7 @@ public ChartErrorBarRenderer(ChartRenderer svgChart, ExcelChartErrorBars errorba for (int i = 0; i < _xSerie.Count; i++) { double y = _ySerie[i]; - Values.Add(new double[] { y * (1 - percent), y + (1 + percent) }); + Values.Add(new double[] { y * (1 - percent), y, y * (1 + percent) }); } break; case eErrorValueType.FixedValue: @@ -75,7 +75,7 @@ public ChartErrorBarRenderer(ChartRenderer svgChart, ExcelChartErrorBars errorba for (int i = 0; i < _xSerie.Count; i++) { double y = _ySerie[i]; - Values.Add(new double[] { y - fixedValue, y + fixedValue }); + Values.Add(new double[] { y - fixedValue, y, y + fixedValue }); } break; @@ -87,7 +87,7 @@ public ChartErrorBarRenderer(ChartRenderer svgChart, ExcelChartErrorBars errorba double y = _ySerie[i]; double minus = GetCustomValue(minusList, i); double plus = GetCustomValue(plusList, i); - Values.Add(new double[] { y - minus, y + plus }); + Values.Add(new double[] { y - minus, y, y + plus }); } break; @@ -105,35 +105,135 @@ public double GetCustomValue(List l, int i) } return 0D; } - public override void AppendRenderItems(List renderItems) - { - throw new NotImplementedException(); - } - internal RenderItem GetErrorBarRenderItem(int index, ChartAxisRenderer xAxis, ChartAxisRenderer yAxis, double x, double y) + internal List GetErrorBarRenderItem(int index, ChartAxisRenderer xAxis, ChartAxisRenderer yAxis, double x, double y, double xPos, double yPos) { - var path = new PathRenderItem(ChartRenderer.Bounds); - double addTop=0, addBottom=0; + //var path = new PathRenderItem(ChartRenderer.Bounds); + var l = new List(); + double topValue=0, bottomValue=0; if (_errorbars.BarType == eErrorBarType.Plus || _errorbars.BarType == eErrorBarType.Both) { - addTop = Values[index][1]; + topValue = Values[index][2]; } if(_errorbars.BarType == eErrorBarType.Minus || _errorbars.BarType == eErrorBarType.Both) { - addBottom = Values[index][0]; + bottomValue = Values[index][0]; } - if(_errorbars.Direction == eErrorBarDirection.X) + if (_errorbars.Direction == eErrorBarDirection.X) { - path.Commands.Add(new PathCommands(PathCommandType.Move, xAxis.GetPositionInPlotarea(x - addBottom), yAxis.GetPositionInPlotarea(y))); - path.Commands.Add(new PathCommands(PathCommandType.Line, xAxis.GetPositionInPlotarea(x + addTop), yAxis.GetPositionInPlotarea(y))); + var rightPos = xAxis.GetPositionInPlotarea(topValue); + var centerPos = yAxis.GetPositionInPlotarea(Values[index][1]); + var leftPos = xAxis.GetPositionInPlotarea(bottomValue); + + //Bottom line + //path.Commands.Add(new PathCommands(PathCommandType.Move, leftPos, yPos, centerPos, yPos)); + var bl = new LineRenderItem(ChartRenderer.Bounds) + { + X1 = leftPos, + Y1 = yPos, + X2 = centerPos, + Y2 = yPos + }; + //Top line + //path.Commands.Add(new PathCommands(PathCommandType.Move, centerPos, yPos, rightPos, yPos)); + var tl = new LineRenderItem(ChartRenderer.Bounds) + { + X1 = centerPos, + Y1 = yPos, + X2 = rightPos, + Y2 = yPos + }; + l.Add(bl); + l.Add(tl); + if (_errorbars.NoEndCap == false) + { + //Bottom cap + //path.Commands.Add(new PathCommands(PathCommandType.Move, leftPos, yPos - 3, leftPos, yPos + 3)); + var bc = new LineRenderItem(ChartRenderer.Bounds) + { + X1 = leftPos, + Y1 = yPos - 3, + X2 = leftPos, + Y2 = yPos + 3 + }; + //Top cap + //path.Commands.Add(new PathCommands(PathCommandType.Move, rightPos, yPos - 3, rightPos, yPos + 3)); + var tc = new LineRenderItem(ChartRenderer.Bounds) + { + X1 = rightPos, + Y1 = yPos - 3, + X2 = rightPos, + Y2 = yPos + 3 + }; + l.Add(bc); + l.Add(tc); + } } else { - path.Commands.Add(new PathCommands(PathCommandType.Move, xAxis.GetPositionInPlotarea(x), yAxis.GetPositionInPlotarea(y - addBottom))); - path.Commands.Add(new PathCommands(PathCommandType.Line, xAxis.GetPositionInPlotarea(x), yAxis.GetPositionInPlotarea(y + addTop))); + var bottomPos = yAxis.GetPositionInPlotarea(bottomValue); + var centerPos = yAxis.GetPositionInPlotarea(Values[index][1]); + var topPos = yAxis.GetPositionInPlotarea(topValue); + + //Bottom line + //path.Commands.Add(new PathCommands(PathCommandType.Move, xPos, bottomPos, xPos, centerPos)); + var bl = new LineRenderItem(ChartRenderer.Bounds) + { + X1 = xPos, + Y1 = bottomPos, + X2 = xPos, + Y2 = centerPos + }; + //Top line + //path.Commands.Add(new PathCommands(PathCommandType.Move, xPos, topPos, xPos, centerPos)); + var tl = new LineRenderItem(ChartRenderer.Bounds) + { + X1 = xPos, + Y1 = topPos, + X2 = xPos, + Y2 = centerPos + }; + l.Add(bl); + l.Add(tl); + if (_errorbars.NoEndCap == false) + { + //Bottom cap + //path.Commands.Add(new PathCommands(PathCommandType.Move, xPos-3, bottomPos, xPos+3, bottomPos)); + var bc = new LineRenderItem(ChartRenderer.Bounds) + { + X1 = xPos - 3, + Y1 = bottomPos, + X2 = xPos + 3, + Y2 = bottomPos + }; + + //Top cap + //path.Commands.Add(new PathCommands(PathCommandType.Move, xPos - 3, topPos, xPos + 3, topPos)); + var tc = new LineRenderItem(ChartRenderer.Bounds) + { + X1 = xPos - 3, + Y1 = topPos, + X2 = xPos + 3, + Y2 = topPos + }; + l.Add(bc); + l.Add(tc); + } + } + foreach (var ri in l) + { + if (_errorbars.Border.LineElement == null) + { + ri.SetDrawingPropertiesBorder(ChartRenderer.Theme, ChartRenderer.Chart.StyleManager.Style?.ErrorBar.Border, ChartRenderer.Chart.StyleManager.Style?.ErrorBar.BorderReference.Color, true, 0.75); + } + else + { + ri.SetDrawingPropertiesBorder(ChartRenderer.Theme, _errorbars.Border, ChartRenderer.Chart.StyleManager.Style?.ErrorBar.BorderReference.Color, _errorbars.Border.Fill.Style != eFillStyle.NoFill, 0.75); + } + ri.SetDrawingPropertiesEffects(ChartRenderer.Theme, _errorbars.Effect); } - return path; + return l; } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs index 7c454c747b..96b91846d8 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs @@ -213,8 +213,7 @@ protected void CreateErrorBars(ExcelChart chartType, List> xValues, { var xSerie = xValues[serieIndex]; var ySerie = yValues[serieIndex]; - ErrorBars = new ChartErrorBarRenderer(ChartRenderer, serie.ErrorBars, xSerie, ySerie, _chartType, serieIndex); - + ErrorBars = new ChartErrorBarRenderer(ChartRenderer, serie.ErrorBars, xSerie, ySerie, _chartType, serieIndex); } serieIndex++; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs index d0f7a42771..4371e3883b 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs @@ -21,6 +21,7 @@ internal class LineChartTypeDrawer : ChartTypeDrawer List serieDataLabels = new List(); List> dataPointsPerSerie = new List>(); internal override bool SupportsTrendlines => true; + internal override bool SupportsErrorBars => true; internal LineChartTypeDrawer(ChartRenderer svgChart, ExcelLineChart chartType) : base(svgChart, chartType) { var isStacked = chartType.IsTypeStacked(); @@ -169,7 +170,7 @@ private void AddLine(ExcelChart chartType, ExcelLineChartSerie serie, List Date: Mon, 22 Jun 2026 14:57:02 +0200 Subject: [PATCH 088/118] Added html tests --- .../Internal/AbstractRangeExporter.cs | 22 +++- .../Internal/HtmlExporterBaseInternal.cs | 3 +- .../Export/HtmlExport/SvgShapeExportTests.cs | 112 +++++++++++++++++- 3 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/AbstractRangeExporter.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/AbstractRangeExporter.cs index 151fbae2a8..ca8d63f8bf 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/AbstractRangeExporter.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/AbstractRangeExporter.cs @@ -11,6 +11,7 @@ Date Author Change 6/4/2022 EPPlus Software AB ExcelTable Html Export *************************************************************************************************/ using OfficeOpenXml.Drawing; +using OfficeOpenXml.Drawing.Chart; using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; using OfficeOpenXml.Table; using OfficeOpenXml.Utils.String; @@ -78,7 +79,7 @@ internal void LoadRangeDrawings(List ranges) ToColumnOff = toColOff }); } - if(d is ExcelShape s) + else if(d is ExcelShape s) { s.GetFromBounds(out int fromRow, out int fromRowOff, out int fromCol, out int fromColOff); s.GetToBounds(out int toRow, out int toRowOff, out int toCol, out int toColOff); @@ -97,6 +98,25 @@ internal void LoadRangeDrawings(List ranges) ToColumnOff = toColOff }); } + else if(d is ExcelChart) + { + d.GetFromBounds(out int fromRow, out int fromRowOff, out int fromCol, out int fromColOff); + d.GetToBounds(out int toRow, out int toRowOff, out int toCol, out int toColOff); + + _rangeDrawings.Add(new HtmlSvgDrawing() + { + WorksheetId = worksheet.PositionId, + Drawing = d, + FromRow = fromRow, + FromRowOff = fromRowOff, + FromColumn = fromCol, + FromColumnOff = fromColOff, + ToRow = toRow, + ToRowOff = toRowOff, + ToColumn = toCol, + ToColumnOff = toColOff + }); + } } } } diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs index d2a9f086c4..ea81ac73cd 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs @@ -458,8 +458,7 @@ protected void AddDrawing(HTMLElement parent, HtmlExportSettings settings, HtmlS if (settings.Drawings.Include == ePictureInclude.IncludeInHtmlOnly) { child.ElementName = "div"; - child.Content = d.Drawing.As.Shape.ToSvg(); - //ePictureType? type; + child.Content = d.Drawing.ToSvg(); //var _encodedImage = ImageEncoder.EncodeImage(image, out type); //child.AddAttribute("src", $"data:{GetContentType(type.Value)};base64,{_encodedImage}"); diff --git a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs index 751d75db01..f4b7585473 100644 --- a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Text; using OfficeOpenXml.Export.HtmlExport; +using System.IO; +using System.Drawing; namespace EPPlusTest.Export.HtmlExport { @@ -17,16 +19,120 @@ public void ExportBasicShapeWorksheet() using (var package = OpenPackage("HtmlBasicSvgShape.xlsx", true)) { var ws = package.Workbook.Worksheets.Add("ShapeWs"); - ws.Drawings.AddShape("SimpleRect", OfficeOpenXml.Drawing.eShapeStyle.Rect); + var rect = ws.Drawings.AddShape("SimpleRect", OfficeOpenXml.Drawing.eShapeStyle.Rect); + rect.Fill.Color = System.Drawing.Color.AliceBlue; + var rect2 = ws.Drawings.AddShape("SimpleRect2", OfficeOpenXml.Drawing.eShapeStyle.Rect); + rect2.Fill.Color = System.Drawing.Color.BlanchedAlmond; - var exporter = ws.Cells["A1:A20"].CreateHtmlExporter(); + for (int i = 0; i< values.Count()*2; i++) + { + if(i >= values.Count()) + { + ws.Cells[i+1, 1].Value = -values[i - values.Count()]; + } + else + { + ws.Cells[i+1, 1].Value = values[i]; + } + } + + var exporter = ws.Cells["A1:C20"].CreateHtmlExporter(); exporter.Settings.Drawings.Include = ePictureInclude.IncludeInHtmlOnly; exporter.Settings.Drawings.DrawTypeInclude = eDrawingInclude.Shapes; var htmlPage = exporter.GetSinglePage(); - GetOutputFile("html", "svgRect"); + var file = GetOutputFile("html", "svgRect.html"); + + SaveAndCleanup(package); + + File.WriteAllText(file.FullName, htmlPage); + } + } + + [TestMethod] + public void ExportBarChart() + { + int[] values = { 5, 10, 15, 20 }; + using (var package = OpenPackage("HtmlSvgColChart.xlsx", true)) + { + var ws = package.Workbook.Worksheets.Add("ShapeWs"); + + for (int i = 0; i < values.Count() * 2; i++) + { + if (i >= values.Count()) + { + ws.Cells[i + 1, 1].Value = -values[i - values.Count()]; + } + else + { + ws.Cells[i + 1, 1].Value = values[i]; + } + } + + var chart = ws.Drawings.AddBarChart("myColChart", OfficeOpenXml.Drawing.Chart.eBarChartType.ColumnClustered); + chart.Series.Add(ws.Cells["A1:A10"]); + + chart.Fill.Color = System.Drawing.Color.BlanchedAlmond; + chart.Series[0].Fill.Color = System.Drawing.Color.LightCoral; + + var exporter = ws.Cells["A1:C20"].CreateHtmlExporter(); + + exporter.Settings.Drawings.Include = ePictureInclude.IncludeInHtmlOnly; + exporter.Settings.Drawings.DrawTypeInclude = eDrawingInclude.Charts; + + var htmlPage = exporter.GetSinglePage(); + + var file = GetOutputFile("html", "myColChart.html"); + + SaveAndCleanup(package); + + File.WriteAllText(file.FullName, htmlPage); + } + } + + [TestMethod] + public void ExportBarChartWithCategories() + { + int[] values = { 5, 10, 15, 20 }; + using (var package = OpenPackage("HtmlSvgColChartCategories.xlsx", true)) + { + var ws = package.Workbook.Worksheets.Add("ShapeWs"); + + ws.Cells["A1"].Value = "Value"; + ws.Cells["B1"].Value = "Title"; + + for (int i = 0; i < values.Count() * 2; i++) + { + if (i >= values.Count()) + { + ws.Cells[i + 2, 1].Value = -values[i - values.Count()]; + } + else + { + ws.Cells[i + 2, 1].Value = values[i]; + } + } + + var chart = ws.Drawings.AddBarChart("myColChart", OfficeOpenXml.Drawing.Chart.eBarChartType.ColumnClustered); + chart.Series.Add(ws.Cells["A1:A10"]); + + chart.Fill.Color = System.Drawing.Color.BlanchedAlmond; + chart.Series[0].Fill.Color = System.Drawing.Color.LightCoral; + + var exporter = ws.Cells["A1:C20"].CreateHtmlExporter(); + + exporter.Settings.Drawings.Include = ePictureInclude.IncludeInHtmlOnly; + exporter.Settings.Drawings.DrawTypeInclude = eDrawingInclude.Charts; + + var htmlPage = exporter.GetSinglePage(); + + var file = GetOutputFile("html", "myColChart.html"); + + SaveAndCleanup(package); + + File.WriteAllText(file.FullName, htmlPage); } } } From 0df0bcdc8494f9c61f534dcd90dda3decbe910cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Mon, 22 Jun 2026 15:00:51 +0200 Subject: [PATCH 089/118] Fixed Errorbars issue --- src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 72a058173f..434d9a25eb 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -1101,7 +1101,7 @@ private void AdjustminMaxFromChartObjects(ExcelChartAxisStandard ax, ref double? } } } - if(drawer.SupportsErrorBars) + if(drawer.SupportsErrorBars && drawer.ErrorBars!=null) { foreach(var v in drawer.ErrorBars.Values) { From 7c46aaad4c0405c803f46612f23f3499b8312d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 22 Jun 2026 15:01:12 +0200 Subject: [PATCH 090/118] Added category test --- src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs index f4b7585473..da5a297bb1 100644 --- a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs @@ -120,6 +120,7 @@ public void ExportBarChartWithCategories() chart.Fill.Color = System.Drawing.Color.BlanchedAlmond; chart.Series[0].Fill.Color = System.Drawing.Color.LightCoral; + chart.SetPixelWidth(250); var exporter = ws.Cells["A1:C20"].CreateHtmlExporter(); @@ -128,7 +129,7 @@ public void ExportBarChartWithCategories() var htmlPage = exporter.GetSinglePage(); - var file = GetOutputFile("html", "myColChart.html"); + var file = GetOutputFile("html", "colChartCats.html"); SaveAndCleanup(package); From 15fbf9d258c4ac6e1ccebd60c9d95c77abed1f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 22 Jun 2026 15:23:10 +0200 Subject: [PATCH 091/118] Fixed gapwidth for barcharts --- .../Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs | 5 ++++- .../Export/HtmlExport/SvgShapeExportTests.cs | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index f65c204106..14ca1d7c4a 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -173,7 +173,10 @@ private void AddBar(ExcelBarChart chartType, ExcelBarChartSerie serie, List286 287 " + var gapWidth = chartType.GapWidth == int.MinValue ? 150 : chartType.GapWidth; + var gapPercent = gapWidth / 100D; // Gap width between bars/columns in percent var overlapPercent = chartType.Overlap / 100D; // Overlap between bars/columns in percent var slotWidth = yWidth / slotSize; var clusterWidth = slotWidth * 100 / (100 + chartType.GapWidth); diff --git a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs index da5a297bb1..ed5b7c9658 100644 --- a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs @@ -20,9 +20,9 @@ public void ExportBasicShapeWorksheet() { var ws = package.Workbook.Worksheets.Add("ShapeWs"); var rect = ws.Drawings.AddShape("SimpleRect", OfficeOpenXml.Drawing.eShapeStyle.Rect); - rect.Fill.Color = System.Drawing.Color.AliceBlue; - var rect2 = ws.Drawings.AddShape("SimpleRect2", OfficeOpenXml.Drawing.eShapeStyle.Rect); - rect2.Fill.Color = System.Drawing.Color.BlanchedAlmond; + //rect.Fill.Color = System.Drawing.Color.AliceBlue; + //var rect2 = ws.Drawings.AddShape("SimpleRect2", OfficeOpenXml.Drawing.eShapeStyle.Rect); + //rect2.Fill.Color = System.Drawing.Color.BlanchedAlmond; for (int i = 0; i< values.Count()*2; i++) { @@ -74,8 +74,8 @@ public void ExportBarChart() var chart = ws.Drawings.AddBarChart("myColChart", OfficeOpenXml.Drawing.Chart.eBarChartType.ColumnClustered); chart.Series.Add(ws.Cells["A1:A10"]); - chart.Fill.Color = System.Drawing.Color.BlanchedAlmond; - chart.Series[0].Fill.Color = System.Drawing.Color.LightCoral; + //chart.Fill.Color = System.Drawing.Color.BlanchedAlmond; + //chart.Series[0].Fill.Color = System.Drawing.Color.LightCoral; var exporter = ws.Cells["A1:C20"].CreateHtmlExporter(); From 03272838c5bb3a90e0abde6bd5ef5b4042e4ef70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Mon, 22 Jun 2026 15:58:15 +0200 Subject: [PATCH 092/118] Strong named EPPlus.DrawingRenderer and added tests for .NET 4.6.2. --- .../EPPlus.DrawingRenderer.Tests.csproj | 2 +- .../TestFontMeasurer.cs | 4 ++-- .../EPPlus.DrawingRenderer.csproj | 2 ++ .../EPPlus.DrawingRenderer.snk | Bin 0 -> 596 bytes .../Properties/AssemblyInfo.cs | 15 +++++++++++++++ 5 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.snk create mode 100644 src/EPPlus.DrawingRenderer/Properties/AssemblyInfo.cs diff --git a/src/EPPlus.DrawingRenderer.Tests/EPPlus.DrawingRenderer.Tests.csproj b/src/EPPlus.DrawingRenderer.Tests/EPPlus.DrawingRenderer.Tests.csproj index 613f6b4ecc..b3596b206a 100644 --- a/src/EPPlus.DrawingRenderer.Tests/EPPlus.DrawingRenderer.Tests.csproj +++ b/src/EPPlus.DrawingRenderer.Tests/EPPlus.DrawingRenderer.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0;net462 latest enable enable diff --git a/src/EPPlus.DrawingRenderer.Tests/TestFontMeasurer.cs b/src/EPPlus.DrawingRenderer.Tests/TestFontMeasurer.cs index 80733eb230..27a5333788 100644 --- a/src/EPPlus.DrawingRenderer.Tests/TestFontMeasurer.cs +++ b/src/EPPlus.DrawingRenderer.Tests/TestFontMeasurer.cs @@ -260,7 +260,7 @@ public void LoremIpsum20Paragraphs() const string SavedComparisonString = "Lorem\r\nipsum dolor\r\nsit amet,\r\nconsectetur\r\nadipiscing\r\nelit. Nulla\r\npulvinar\r\ninterdum\r\nimperdiet.\r\nPraesent ut\r\nauctor urna.\r\nPhasellus\r\nsollicitudin\r\nquam vitae\r\nest\r\nconvallis,\r\neu mattis\r\nlorem\r\nefficitur.\r\nMauris nulla\r\nlibero,\r\ntincidunt id\r\nipsum non,\r\nlobortis\r\ntristique\r\nmauris.\r\nDonec ut\r\nenim sed\r\nenim\r\nfermentum\r\nmolestie vel\r\nquis odio.\r\nMorbi a\r\nfermentum\r\nmassa, sit\r\namet\r\nultrices est.\r\nAenean\r\nante mi,\r\nfermentum\r\nnec\r\nrhoncus et,\r\nvulputate\r\nvel sapien.\r\nDonec\r\ntempus, leo\r\nquis luctus\r\nrhoncus,\r\naugue odio\r\npharetra\r\nlibero, ac\r\nblandit urna\r\nturpis sed\r\ndiam.\r\nVivamus\r\naugue\r\npurus,\r\neleifend et\r\njusto\r\nfacilisis,\r\nimperdiet\r\nrhoncus\r\nsem.\r\nQuisque\r\naccumsan\r\npellentesqu\r\ne elit, eget\r\nfinibus\r\nmassa\r\naccumsan\r\nin. Fusce eu\r\naccumsan\r\nenim. Cras\r\npulvinar\r\nenim vel\r\ntellus\r\nlacinia,\r\nconsectetur\r\neuismod\r\ntortor\r\nconsectetur\r\n. Praesent\r\ntincidunt\r\npretium\r\neros, ac\r\nauctor\r\nmagna\r\nluctus sed.\r\nUt porta\r\nlectus\r\nquam, non\r\nornare\r\nmauris\r\nlacinia sit\r\namet.\r\nNullam\r\negestas\r\ndolor quis\r\nmagna\r\nporttitor, ac\r\niaculis nisi\r\nhendrerit.\r\nProin at\r\nmollis\r\nlacus, in\r\nporttitor\r\nnunc.\r\nAliquam\r\nerat\r\nvolutpat.\r\nSed vel\r\negestas\r\nrisus, at\r\naliquam\r\narcu.\r\nVestibulum\r\nquis\r\nlobortis\r\nnulla. Etiam\r\npellentesqu\r\ne auctor\r\nnulla, eget\r\ntincidunt\r\nfelis\r\nrhoncus id.\r\nSed metus\r\nante,\r\nefficitur id\r\ndui eu,\r\nfermentum\r\nmollis odio.\r\nPhasellus\r\nullamcorper\r\niaculis\r\naugue vel\r\nconsequat.\r\nEtiam\r\nfringilla\r\neuismod\r\ninterdum.\r\nUt molestie\r\nmassa id\r\nfringilla\r\nlobortis.\r\nVestibulum\r\nmalesuada,\r\nante vel\r\nmattis\r\nultrices,\r\nsem ante\r\nmolestie\r\naugue, non\r\ntristique dui\r\nmi non\r\nnibh.\r\nMaecenas\r\ndictum,\r\nsem eget\r\nconvallis\r\nrhoncus,\r\nlacus enim\r\nporta\r\nneque, in\r\nposuere dui\r\nex a sapien.\r\nNam lacus\r\nnibh,\r\nposuere sed\r\nelit eget,\r\ncondimentu\r\nm facilisis\r\nligula. Cras\r\nconsectetur\r\nlacus\r\nullamcorper\r\nvelit aliquet\r\nbibendum\r\neget vel\r\nnulla.\r\nAenean\r\nvarius ac\r\nerat quis\r\nullamcorper\r\n. Donec\r\nlaoreet arcu\r\na lorem\r\nvolutpat\r\nfaucibus.\r\nVivamus\r\nvehicula leo\r\nut erat\r\nluctus\r\nscelerisque.\r\nMorbi\r\nposuere ex\r\net magna\r\negestas\r\nfacilisis.\r\nFusce\r\nscelerisque\r\nvolutpat\r\nerat\r\nbibendum\r\nhendrerit.\r\nNam blandit\r\nmi ut metus\r\npulvinar, vel\r\ntempus\r\nlacus\r\neuismod.\r\nQuisque\r\nimperdiet\r\nsit amet\r\nsapien sed\r\nultricies.\r\nPhasellus\r\nsodales,\r\nipsum vitae\r\ntincidunt\r\nfacilisis,\r\nnulla ligula\r\nfaucibus\r\nfelis, eget\r\nvehicula\r\nante lacus\r\neu lorem.\r\nInteger\r\ncongue\r\ndiam ac\r\nviverra\r\ntristique.\r\nCurabitur\r\ntristique\r\ndolor quis\r\nquam\r\npretium, et\r\nscelerisque\r\nquam\r\ndictum.\r\nMaecenas\r\nvitae\r\nsodales\r\nligula.\r\nPellentesqu\r\ne maximus\r\ndiam vel\r\nporta\r\nconvallis. Ut\r\naliquam\r\neros quis\r\nporta\r\npellentesqu\r\ne. Fusce in\r\nex ut mi\r\negestas\r\ncursus.\r\nAliquam\r\nerat\r\nvolutpat.\r\nCras laoreet\r\ncondimentu\r\nm laoreet.\r\nSed eget\r\nfacilisis\r\ntellus.\r\nMorbi\r\nviverra odio\r\nsed odio\r\nplacerat\r\nmollis. Duis\r\nturpis\r\nmetus,\r\ndignissim\r\nvarius urna\r\nquis, viverra\r\ndignissim\r\ndui.\r\nVivamus\r\nviverra at\r\nnisi quis\r\nconvallis.\r\nSuspendiss\r\ne fringilla\r\nrisus et ante\r\nsollicitudin,\r\nsed eleifend\r\nsem\r\nplacerat.\r\nProin\r\npretium\r\nblandit\r\narcu, eget\r\nrhoncus\r\nrisus\r\nhendrerit at.\r\nInterdum et\r\nmalesuada\r\nfames ac\r\nante ipsum\r\nprimis in\r\nfaucibus.\r\nPhasellus\r\nvulputate\r\nefficitur\r\nmaximus.\r\nCras blandit\r\nnulla eu nisi\r\nauctor\r\ntempus.\r\nSed pretium\r\nlacus ac\r\nmagna\r\nvestibulum,\r\naliquam\r\nfaucibus\r\norci luctus.\r\nMauris enim\r\nlorem,\r\nvarius ut\r\nante quis,\r\nvarius\r\nviverra\r\nlectus.\r\nFusce\r\nblandit nibh\r\nvel feugiat\r\nefficitur.\r\nDonec\r\nmaximus id\r\njusto ac\r\nmollis.\r\nVestibulum\r\nante ipsum\r\nprimis in\r\nfaucibus\r\norci luctus\r\net ultrices\r\nposuere\r\ncubilia\r\ncurae; Nulla\r\nplacerat\r\nlectus et\r\npurus\r\ndictum, id\r\ncongue nisi\r\neuismod.\r\nMaecenas\r\neuismod\r\nfermentum\r\ndiam, sit\r\namet\r\ngravida\r\nmagna\r\nsuscipit a.\r\nQuisque\r\nconsectetur\r\narcu eu\r\nnunc\r\nsodales\r\nscelerisque.\r\nNulla non\r\ntincidunt\r\nnulla.\r\nPellentesqu\r\ne ut tortor\r\nvel enim\r\nconvallis\r\nmalesuada.\r\nAliquam\r\nultricies\r\nbibendum\r\nultrices.\r\nMauris\r\nrutrum ac\r\nnisl vel\r\nluctus.\r\nDonec quis\r\nnibh vitae\r\norci ultricies\r\ngravida.\r\nAliquam\r\nvitae velit\r\nporttitor\r\nlorem\r\nbibendum\r\nfringilla\r\nvolutpat a\r\neros.\r\nCurabitur at\r\ncommodo\r\ntortor. Etiam\r\nultricies,\r\nneque et\r\niaculis\r\neuismod,\r\ndiam ligula\r\nluctus mi,\r\nvitae\r\nlobortis felis\r\nlorem eu\r\nnulla. Sed a\r\nsemper ex.\r\nInterdum et\r\nmalesuada\r\nfames ac\r\nante ipsum\r\nprimis in\r\nfaucibus.\r\nNulla\r\nmauris elit,\r\npulvinar ac\r\ntortor et,\r\nluctus\r\nhendrerit\r\nnisl. In\r\negestas\r\nauctor urna\r\nvitae\r\nlaoreet.\r\nPraesent\r\nbibendum\r\negestas\r\nconvallis.\r\nProin non\r\nsuscipit\r\ntellus.\r\nNullam at\r\nnibh in urna\r\nlaoreet\r\nsodales non\r\nvel tellus.\r\nDonec in\r\nenim dui.\r\nPhasellus\r\nquis quam\r\ntincidunt,\r\npellentesqu\r\ne lorem ac,\r\nscelerisque\r\nneque.\r\nInteger nec\r\ntempus\r\nurna. Donec\r\nelit massa,\r\neleifend eu\r\nsapien sit\r\namet,\r\nmollis\r\npellentesqu\r\ne est.\r\nNullam\r\ntristique\r\ntellus\r\niaculis arcu\r\nconsectetur\r\npretium.\r\nSed\r\nvenenatis\r\nconvallis\r\nscelerisque.\r\nSuspendiss\r\ne varius\r\nurna sit\r\namet purus\r\naccumsan,\r\nid ultricies\r\nerat\r\nefficitur.\r\nCras non\r\nipsum eget\r\nnulla\r\nefficitur\r\ncommodo\r\nsit amet non\r\nlacus. Proin\r\nviverra enim\r\nsit amet\r\nenim\r\ntempus\r\nullamcorper\r\n. Class\r\naptent taciti\r\nsociosqu ad\r\nlitora\r\ntorquent per\r\nconubia\r\nnostra, per\r\ninceptos\r\nhimenaeos.\r\nDuis ac\r\nmassa\r\ninterdum,\r\ngravida ex\r\negestas,\r\nfinibus\r\npurus. Nunc\r\nconsectetur\r\ncommodo\r\nlacus, ac\r\nconvallis\r\nquam\r\nlobortis eu.\r\nSed\r\nconvallis\r\ntempor\r\ncommodo.\r\nNulla sed\r\nconvallis\r\nmauris.\r\nDonec\r\nvenenatis\r\nnisi est, ac\r\nullamcorper\r\nmi pretium\r\nquis. Donec\r\nvitae eros at\r\nipsum\r\ninterdum\r\nscelerisque\r\nnec vitae\r\nnisi. Sed\r\nvestibulum\r\nerat ac\r\nbibendum\r\ndapibus.\r\nMorbi nec\r\nelit id quam\r\ntristique\r\ncursus id\r\nsed sem.\r\nPraesent\r\nnon ante\r\nenim.\r\nPellentesqu\r\ne habitant\r\nmorbi\r\ntristique\r\nsenectus et\r\nnetus et\r\nmalesuada\r\nfames ac\r\nturpis\r\negestas.\r\nPraesent\r\nnon mauris\r\ndui.\r\nAliquam\r\nrhoncus\r\nmattis ante\r\nsed\r\nvenenatis.\r\nVivamus\r\nvehicula\r\nsed sapien\r\nsed dictum.\r\nIn aliquet,\r\nurna\r\nefficitur\r\ntincidunt\r\nlobortis,\r\nnibh justo\r\ntristique\r\npurus, sed\r\nvolutpat\r\nrisus magna\r\net\r\nlibero.Susp\r\nendisse\r\nlectus justo,\r\nvarius eget\r\narcu et,\r\nsemper\r\nlaoreet erat.\r\nQuisque\r\neget lacus\r\nornare,\r\npellentesqu\r\ne erat sit\r\namet,\r\nvulputate\r\nfelis. Duis\r\nluctus,\r\nmassa a\r\npellentesqu\r\ne mollis,\r\nmassa elit\r\nconvallis\r\nmi, vel\r\nbibendum\r\nex ex eu\r\npurus.\r\nSuspendiss\r\ne vel\r\nfermentum\r\nurna, ac\r\ncommodo\r\nenim.\r\nMauris\r\ntincidunt\r\ncursus elit,\r\na volutpat\r\nlibero\r\ncommodo\r\net. Etiam\r\ndapibus\r\nlibero\r\nvenenatis\r\ntellus\r\nlobortis, vel\r\nlacinia elit\r\nfaucibus.\r\nMaecenas\r\nsemper sed\r\nquam quis\r\nfinibus.\r\nInteger\r\nefficitur,\r\nlibero\r\nimperdiet\r\nsollicitudin\r\ncommodo,\r\nelit arcu\r\nvulputate\r\nest, eget\r\nfinibus mi\r\nurna sit\r\namet\r\nmagna.\r\nCras\r\nullamcorper\r\nconsequat\r\nornare.\r\nFusce\r\nconvallis\r\nnunc vel\r\nrisus\r\ncursus, at\r\nmaximus\r\nligula\r\ncursus.\r\nPellentesqu\r\ne vulputate\r\nrisus libero,\r\neget cursus\r\nnibh\r\nsodales\r\nsed. Donec\r\naccumsan\r\nsem et\r\nmassa\r\nsemper, id\r\ndignissim\r\nvelit\r\nvehicula.Cr\r\nas cursus\r\nipsum ac\r\nerat\r\nvehicula,\r\nnec iaculis\r\npurus\r\ndictum.\r\nQuisque\r\nlacinia elit\r\nvitae leo\r\ndictum, vel\r\ndignissim\r\nvelit\r\ndapibus.\r\nAenean sem\r\nnisi,\r\nfaucibus\r\ninterdum\r\njusto eu,\r\neuismod\r\nporttitor ex.\r\nMorbi et\r\nlectus\r\nlectus. Duis\r\nneque felis,\r\nsuscipit at\r\nscelerisque\r\neu,\r\nscelerisque\r\nid orci.\r\nCurabitur et\r\nplacerat\r\nipsum.\r\nProin\r\ngravida\r\nsapien nisl,\r\net varius\r\nipsum\r\nmollis nec.\r\nQuisque\r\ndignissim\r\nconsectetur\r\nfeugiat.\r\nAenean\r\neros purus,\r\nlaoreet\r\ninterdum\r\nrutrum at,\r\naliquet sit\r\namet\r\nlectus.\r\nDonec\r\ngravida\r\nlorem ut\r\ntincidunt\r\nlaoreet.\r\nDonec\r\nconsequat\r\nviverra\r\nligula, in\r\naccumsan\r\nmi\r\nbibendum\r\nscelerisque.\r\nQuisque ac\r\nrisus justo.\r\nMorbi\r\nmagna\r\narcu,\r\negestas nec\r\nluctus\r\ncommodo,\r\ncursus eget\r\nnunc.\r\nVivamus\r\neuismod\r\nlorem ex, et\r\nmaximus\r\nfelis\r\nhendrerit\r\neget.\r\nNullam\r\nullamcorper\r\neuismod\r\nligula, et\r\niaculis\r\nligula\r\nultricies a.\r\nFusce\r\naliquam,\r\nenim vel\r\nfermentum\r\nultrices, elit\r\nquam\r\nsemper\r\nerat, vitae\r\nsemper velit\r\naugue non\r\nmagna.Quis\r\nque\r\nmaximus\r\nsemper\r\narcu, id\r\npellentesqu\r\ne est\r\ntempus a.\r\nPhasellus\r\nlacus elit,\r\nauctor sit\r\namet lacinia\r\na, dapibus\r\nvitae velit.\r\nPhasellus ut\r\npharetra\r\njusto, ut\r\nultricies\r\nerat. Sed\r\nmolestie\r\nsapien vel\r\ninterdum\r\nlobortis.\r\nNulla\r\nfacilisi.\r\nVestibulum\r\nante ipsum\r\nprimis in\r\nfaucibus\r\norci luctus\r\net ultrices\r\nposuere\r\ncubilia\r\ncurae; Nulla\r\nnec mauris\r\nquis nisi\r\nvulputate\r\ngravida quis\r\nnec\r\nvelit.Nam et\r\ncongue\r\nipsum.\r\nNulla vel elit\r\nnon dolor\r\nmollis\r\naliquet vel\r\nat magna.\r\nPellentesqu\r\ne nec\r\nfacilisis elit.\r\nIn vulputate\r\nquis sem\r\nporta\r\nsuscipit.\r\nNullam sed\r\nex ornare\r\nnibh\r\nsuscipit\r\nmattis quis\r\nnon lacus.\r\nMauris vel\r\nex urna.\r\nVivamus\r\nultricies\r\nsapien sit\r\namet sapien\r\nvehicula\r\ngravida.\r\nDonec\r\nfeugiat\r\nvolutpat\r\nquam.\r\nVestibulum\r\nauctor\r\ndictum nisl,\r\nid hendrerit\r\nmetus\r\nullamcorper\r\nsed. Nulla\r\nmaximus\r\nlacus vel\r\nmollis\r\nmaximus.\r\nNulla\r\nlaoreet\r\nplacerat\r\nquam eu\r\nviverra.\r\nEtiam\r\nfeugiat\r\naccumsan\r\nnisl a\r\ncondimentu\r\nm. Sed\r\nultricies\r\nante ante,\r\nac auctor\r\nligula\r\ngravida nec.\r\nPraesent a\r\nneque\r\ndignissim,\r\nsagittis felis\r\nsit amet,\r\ncondimentu\r\nm turpis.\r\nFusce at leo\r\nvel est\r\nblandit\r\nmalesuada.\r\nPellentesqu\r\ne et neque\r\nnon metus\r\npellentesqu\r\ne imperdiet.\r\nPraesent\r\npellentesqu\r\ne lacinia\r\nlorem, et\r\ntristique\r\ntellus\r\nefficitur id.\r\nSuspendiss\r\ne aliquet\r\nultricies\r\njusto vitae\r\ninterdum.\r\nCras\r\ntristique\r\nviverra\r\nquam, eget\r\ngravida mi\r\nfermentum\r\nimperdiet.\r\nSed\r\nimperdiet\r\nvitae purus\r\nut volutpat.\r\nNulla\r\nlacinia elit\r\nin\r\nfermentum\r\nconsectetur\r\n. Phasellus\r\ncommodo\r\nut nisl sit\r\namet\r\nsagittis.\r\nDuis ac\r\nornare orci.\r\nVivamus vel\r\nenim\r\nposuere,\r\npharetra ex\r\nvel,\r\nelementum\r\nest.\r\nVestibulum\r\ncommodo\r\nluctus\r\nmetus eget\r\nmaximus.\r\nSuspendiss\r\ne a nulla a\r\nodio\r\neleifend\r\nfaucibus.\r\nSuspendiss\r\ne semper\r\nlacus non\r\nporttitor\r\naliquet.\r\nCras ac\r\nscelerisque\r\nmagna, et\r\npulvinar\r\njusto.\r\nInteger\r\ncursus\r\npulvinar\r\nfringilla.\r\nMauris\r\nimperdiet\r\nnibh sit\r\namet\r\ntempor\r\nlaoreet.\r\nMorbi\r\ntincidunt\r\ntortor ex, sit\r\namet\r\nmaximus\r\npurus\r\ntristique\r\nquis.\r\nQuisque\r\nsed\r\nhendrerit\r\nvelit. Mauris\r\nmattis nibh\r\nut eros\r\nluctus, eget\r\nmattis\r\nmassa\r\nauctor.\r\nPhasellus\r\neu neque at\r\naugue\r\ngravida\r\nsagittis nec\r\nnon tortor.\r\nEtiam\r\nporttitor\r\nsem\r\nsodales mi\r\nullamcorper\r\ngravida. In\r\nin dictum\r\norci. In vitae\r\nvestibulum\r\nquam. Cras\r\naugue eros,\r\ntincidunt ac\r\nelit posuere,\r\nsollicitudin\r\nefficitur\r\nlectus.\r\nPraesent\r\nquis\r\nsodales\r\nnisl. Proin\r\nsit amet\r\nmolestie\r\nest. In\r\ncommodo\r\nmauris vel\r\nmauris\r\nefficitur,\r\nnec mollis\r\nmauris\r\nsagittis.\r\nCras ligula\r\nnibh,\r\negestas sit\r\namet eros\r\nin, lacinia\r\ntristique\r\nmagna.\r\nCras risus\r\nlibero,\r\nlacinia eget\r\nlibero vitae,\r\nmaximus\r\naliquet\r\nnibh. Mauris\r\nid sodales\r\npurus, vitae\r\ndictum\r\nlectus. Cras\r\nconsectetur\r\nligula velit,\r\ntempus\r\npulvinar\r\nlacus\r\nporttitor\r\nvitae.\r\nPhasellus\r\neget tellus\r\nipsum.\r\nDonec\r\ninterdum\r\nlaoreet elit\r\nnon\r\nvestibulum.\r\nCras sed\r\nurna\r\nullamcorper\r\n, aliquam\r\nerat eget,\r\nporta orci.\r\nVestibulum\r\neget congue\r\nnulla. Sed\r\nsem tortor,\r\neuismod at\r\nrutrum id,\r\nsagittis a\r\nnunc. Duis\r\nin nibh\r\nfacilisis,\r\ndignissim\r\npurus ut,\r\nhendrerit\r\nmagna. Sed\r\nsemper\r\nligula id\r\nmassa\r\nelementum,\r\nnon\r\nmalesuada\r\nvelit\r\negestas.\r\nNullam\r\ndictum, mi\r\nnec\r\neuismod\r\nsagittis,\r\nligula leo\r\nullamcorper\r\ndolor, quis\r\nfaucibus\r\nodio metus\r\neget magna.\r\nUt gravida\r\nmetus non\r\nmetus\r\nbibendum\r\nbibendum.\r\nIn sagittis\r\neleifend\r\naliquet.\r\nInterdum et\r\nmalesuada\r\nfames ac\r\nante ipsum\r\nprimis in\r\nfaucibus.\r\nNam mollis\r\nsagittis\r\nfelis, in\r\nfaucibus\r\ntortor\r\npretium vel.\r\nNam nec\r\nenim\r\nmetus.\r\nDonec in\r\naugue arcu.\r\nProin non\r\nlobortis\r\npurus, sit\r\namet lacinia\r\nelit.\r\nSuspendiss\r\ne quis eros\r\ncondimentu\r\nm, blandit\r\njusto sit\r\namet,\r\nlobortis nisl.\r\nSuspendiss\r\ne maximus\r\nmassa sed\r\nurna tempor\r\nornare.\r\nNunc\r\nmalesuada\r\npurus odio,\r\neu luctus\r\nlectus\r\nauctor nec.\r\nMorbi\r\nauctor\r\npellentesqu\r\ne auctor.\r\nSed\r\nullamcorper\r\n, ex vitae\r\naliquam\r\nvulputate,\r\nest diam\r\nfeugiat mi,\r\nid porttitor\r\nlectus orci\r\nac leo.\r\nDonec sit\r\namet velit\r\npulvinar,\r\nvenenatis\r\nturpis ut,\r\ninterdum\r\nligula.\r\nInterdum et\r\nmalesuada\r\nfames ac\r\nante ipsum\r\nprimis in\r\nfaucibus.\r\nVestibulum\r\neu lacus\r\nurna.\r\nMaecenas\r\nsem nulla,\r\naccumsan\r\neu ultricies\r\nsed, tempor\r\nvel magna.\r\nCras aliquet\r\nsollicitudin\r\nsapien ac\r\npulvinar.\r\nPraesent ac\r\nsodales mi.\r\nInteger vitae\r\nmauris\r\nmassa.\r\nMaecenas\r\niaculis orci\r\net faucibus\r\ninterdum.\r\nNunc nec\r\nmaximus\r\nfelis, sed\r\nfinibus\r\nquam.\r\nPellentesqu\r\ne felis\r\nmassa,\r\nvestibulum\r\nin tellus\r\nvitae,\r\ncongue\r\ntincidunt\r\njusto. Nunc\r\nvitae enim\r\nmalesuada,\r\nbibendum\r\nante nec,\r\nvarius\r\ntellus.\r\nPraesent\r\nvitae nisi id\r\nquam\r\nauctor\r\nlacinia at\r\nnon quam.\r\nNam nec\r\nligula sit\r\namet felis\r\nauctor\r\nsagittis.\r\nNunc in\r\nrisus eu\r\nurna varius\r\nlaoreet quis\r\nsit amet\r\nfelis. Morbi\r\nvarius\r\ntempor orci,\r\neu\r\nvestibulum\r\nnunc\r\nvestibulum\r\nac. Nunc\r\nvehicula\r\nvelit\r\neleifend\r\nconsequat\r\nporta.\r\nSuspendiss\r\ne maximus\r\ndapibus\r\norci, in\r\nvulputate\r\nmassa\r\npretium ac.\r\nQuisque\r\nmalesuada\r\naliquet\r\naliquet."; - var savedStrings = SavedComparisonString.Split("\r\n"); + var savedStrings = SavedComparisonString.Split(new string[] { "\r\n" }, StringSplitOptions.None); List faultyStrings = new(); List excpectedStrings = new(); @@ -311,7 +311,7 @@ public void LoremIpsum20ParagraphsMultipleFragments() var pointWidth = 54.085732283464566929133858267717f; var wrappedStrings = handler.WrapText(Lorem20Str, pointWidth); - var savedStrings = SavedComparisonString.Split("\r\n"); + var savedStrings = SavedComparisonString.Split(new string[] { "\r\n" }, StringSplitOptions.None); List differingStrings = new(); diff --git a/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.csproj b/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.csproj index 687c3fedc8..7643545c66 100644 --- a/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.csproj +++ b/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.csproj @@ -11,6 +11,8 @@ A spreadsheet library for .NET framework and .NET core latest + True + EPPlus.DrawingRenderer.snk diff --git a/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.snk b/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.snk new file mode 100644 index 0000000000000000000000000000000000000000..8a53d24382a044ae914e160a05e53f7625c1a966 GIT binary patch literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50097fJ;!cPIJOqGWGm$>^v-5Xe~SVO&SgET zT_sv5R7KB6X zMTR`jsp@jQW*CQI>D6$+V_IrGu{ugVqpw%>^aWdmnEG*b(AsDAvCnaYGTG`nnYqum zOvko&kWAVYnKHv;qnZxWh(Gb~UxU4C8FcCj%`V29I^4;=*^#j&(m=`9{c^`C7yp8r zLY%3)Gcl%Aj>z|GJCsM5M^V_B%kl{QaHoXgMX_N{G8Su93z?J>4I>3(;98{Sbk3=$@`I8=Z5ug14ff5 zQM2%4g!NZ{BG3m92wePhx_ap~FbSDsF2V_?jA4scaVuKP$5>$VH*j|XBE(7f zAKnLW>5C{matzN_&WLzP=I~ehq+NPw>2{_oHtiV!tlRI}YRy)dy@ Date: Mon, 22 Jun 2026 15:58:35 +0200 Subject: [PATCH 093/118] Added stylemanager styling --- .../RenderItems/DrawingRenderItemExtentions.cs | 14 +++++++------- .../Export/HtmlExport/SvgShapeExportTests.cs | 18 ++++++++++++------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs index 44e8a4806d..2654e873c2 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs @@ -53,14 +53,14 @@ internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTh switch (fill.Style) { case eFillStyle.NoFill: - //if (fill.IsEmpty) //Removed for now. - //{ - // item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity); - //} - //else - //{ + if (fill.IsEmpty) //Removed for now. + { + item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity); + } + else + { item.FillColor = "none"; - //} + } break; case eFillStyle.SolidFill: item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity); diff --git a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs index ed5b7c9658..7be5aab573 100644 --- a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs @@ -1,11 +1,12 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml.Drawing.Chart.Style; +using OfficeOpenXml.Export.HtmlExport; using System; using System.Collections.Generic; +using System.Drawing; +using System.IO; using System.Linq; using System.Text; -using OfficeOpenXml.Export.HtmlExport; -using System.IO; -using System.Drawing; namespace EPPlusTest.Export.HtmlExport { @@ -72,8 +73,11 @@ public void ExportBarChart() } var chart = ws.Drawings.AddBarChart("myColChart", OfficeOpenXml.Drawing.Chart.eBarChartType.ColumnClustered); - chart.Series.Add(ws.Cells["A1:A10"]); + chart.Series.Add(ws.Cells["A1:A8"]); + var theme = ws.Workbook.ThemeManager.GetOrCreateTheme(); + chart.StyleManager.SetChartStyle(ePresetChartStyle.BarChartStyle1, ePresetChartColors.MonochromaticPalette1); + //chart.StyleManager.SetChartStyle(ePresetChartStyle.ColumnChartStyle1, ePresetChartColors.ColorfulPalette1); //chart.Fill.Color = System.Drawing.Color.BlanchedAlmond; //chart.Series[0].Fill.Color = System.Drawing.Color.LightCoral; @@ -85,10 +89,12 @@ public void ExportBarChart() var htmlPage = exporter.GetSinglePage(); var file = GetOutputFile("html", "myColChart.html"); - - SaveAndCleanup(package); + var svgFile = GetOutputFile("html", "myColChartSvg.svg"); File.WriteAllText(file.FullName, htmlPage); + File.WriteAllText(svgFile.FullName, chart.ToSvg()); + + SaveAndCleanup(package); } } From 1922b10542abc5e4baf1bd27cd57ed1504c9bcd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 22 Jun 2026 17:04:15 +0200 Subject: [PATCH 094/118] Fixed ellipse items having rect type --- src/EPPlus.DrawingRenderer.Tests/StyleTests.cs | 16 ++++++++++++++++ .../RenderItems/RenderItem.cs | 2 +- .../RenderItems/DrawingRenderItemExtentions.cs | 2 +- .../Export/HtmlExport/SvgShapeExportTests.cs | 10 +++++++--- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs index d98b49f066..07bdb81fd2 100644 --- a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs @@ -22,6 +22,22 @@ public void BaseThemeChartStyle() } } + [TestMethod] + public void BaseThemeChartStyle2() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + + using (var p = OpenTemplatePackage("baseThemeChartStyle2.xlsx")) + { + var c = p.Workbook.Worksheets[0].Drawings[0].As.Chart.LineChart; + var svg = c.ToSvg(); + //var renderer = new EPPlusImageRenderer.ImageRenderer(); + //var svg = renderer.RenderDrawingToSvg(c); + SaveTextFileToWorkbook($"svg\\baseThemeChartStyle.svg", svg); + } + } + + [TestMethod] public void ExtractThemeStyleWorks() { diff --git a/src/EPPlus.DrawingRenderer/RenderItems/RenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/RenderItem.cs index 73babf0adb..bd46e6b0c0 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/RenderItem.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/RenderItem.cs @@ -230,7 +230,7 @@ public EllipseRenderItem(BoundingBox parent) : base(parent) { } - public override RenderItemType Type => RenderItemType.Rect; + public override RenderItemType Type => RenderItemType.Ellipse; public double Cx { get; set; } public double Cy { get; set; } public double Rx { get; set; } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs index 2654e873c2..922d468322 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs @@ -53,7 +53,7 @@ internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTh switch (fill.Style) { case eFillStyle.NoFill: - if (fill.IsEmpty) //Removed for now. + if (fill.IsEmpty) //Do NOT remove. This if is required for Shapes { item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity); } diff --git a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs index 7be5aab573..d65926a4fa 100644 --- a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs @@ -75,9 +75,13 @@ public void ExportBarChart() var chart = ws.Drawings.AddBarChart("myColChart", OfficeOpenXml.Drawing.Chart.eBarChartType.ColumnClustered); chart.Series.Add(ws.Cells["A1:A8"]); - var theme = ws.Workbook.ThemeManager.GetOrCreateTheme(); - chart.StyleManager.SetChartStyle(ePresetChartStyle.BarChartStyle1, ePresetChartColors.MonochromaticPalette1); - //chart.StyleManager.SetChartStyle(ePresetChartStyle.ColumnChartStyle1, ePresetChartColors.ColorfulPalette1); + chart.StyleManager.SetChartStyle(ePresetChartStyleMultiSeries.BarChartStyle1); + + //chart.Style = OfficeOpenXml.Drawing.Chart.eChartStyle.Style1; + //var theme = ws.Workbook.ThemeManager.GetOrCreateTheme(); + //chart.StyleManager.SetChartStyle(0); + //chart.StyleManager.SetChartStyle(0); + //chart.StyleManager.ApplyStyles(); //chart.Fill.Color = System.Drawing.Color.BlanchedAlmond; //chart.Series[0].Fill.Color = System.Drawing.Color.LightCoral; From 8dcdd7b29a7cb4ddc892bc7f17ac5c0c69d94380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 23 Jun 2026 08:25:45 +0200 Subject: [PATCH 095/118] Fixed trendline label positioning --- .../Chart/ChartTypeDrawers/LineChartTypeDrawer.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs index 4371e3883b..1c368a47d7 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs @@ -11,6 +11,7 @@ using OfficeOpenXml.Utils.TypeConversion; using System; using System.Collections.Generic; +using System.Linq; namespace EPPlus.Export.ImageRenderer.Svg.Chart { @@ -33,12 +34,16 @@ internal LineChartTypeDrawer(ChartRenderer svgChart, ExcelLineChart chartType) : { var yValue = LoadSeriesValues(serie.Series, serie.NumberLiteralsY, serie.StringLiteralsY); var xValue = LoadSeriesValues(serie.XSeries, serie.NumberLiteralsX, serie.StringLiteralsX); - + if(xValue==null) + { + //No x-axis. Create serie from 1..y-items. + xValue = yValue.Select((x, index) => (object)(double)(index + 1)).ToList(); + } _xValues.Add(xValue); _yValues.Add(yValue); serCounter++; - } + } if (chartType.IsTypeStacked()) { From 5ee696adbc03e63e2a29f80f4af500508bae5618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 23 Jun 2026 10:49:32 +0200 Subject: [PATCH 096/118] Fixed trendline label positioning. --- .../Chart/ErrorbarsTests.cs | 20 +++++++++++++++++++ .../Trendlines/ChartTrendlineRenderer.cs | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs index 5b67b2c388..a3a2e76f64 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs @@ -26,5 +26,25 @@ public void GenerateSvgForErrorbars_Sheet1() } } } + [TestMethod] + public void GenerateSvgForErrorbars_Column_Sheet2() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("Errorbars.xlsx")) + { + var ws = p.Workbook.Worksheets[1]; + //var ix = 4; + //var c = ws.Drawings[ix]; + //var svg = c.ToSvg(); + //SaveTextFileToWorkbook($"svg\\Error_sheet1_ind{ix++}.svg", svg); + + var ix = 0; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\Errorbar_sheet2_{ix++}.svg", svg); + } + } + } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index e3f400a22a..86ee433cc9 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -159,9 +159,9 @@ private void CreateDatalabel() DataLabel.TopMargin = DataLabel.BottomMargin = 2; //Set datalabel position. - if(DataLabel.Left + (DataLabel.Width + 5) > ChartRenderer.Bounds.Right) + if(DataLabel.Left + 5 > ChartRenderer.Bounds.Right) { - DataLabel.Left = ChartRenderer.Bounds.Right - DataLabel.Width; + DataLabel.Left = ChartRenderer.Bounds.Right - DataLabel.Width - 5; } else { @@ -493,7 +493,7 @@ private void CalculatePower() var intercept = Math.Pow(Math.E, (sumLnY - slope * sumLnX) / n); Coefficients = [slope, intercept]; - Formula = $"y = {intercept:G5} x |ss:{slope:G3}|"; + Formula = $"y = {intercept:G5} x |ss:{slope:G5}|"; var ylogSerie = _ySerie.Select(y => Math.Log(y)).ToArray(); var r2 = CalculateRSquaredPearson(x => intercept * Math.Pow(x, slope), _ySerie); RSquare = $"R² = {r2:N4} "; From 8f99a327a8d0e87fdf0b2b03c86e3778a2b964b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 23 Jun 2026 11:01:35 +0200 Subject: [PATCH 097/118] Fixed databar bug + styling border issue --- .../Chart/BarChartTests.cs | 2 +- .../StyleTests.cs | 34 ++++++++++++++++--- src/EPPlus/Drawing/Enums/eSchemeColor.cs | 1 + .../BarColumnChartTypeDrawer.cs | 6 ++-- .../Chart/DataLabels/SvgDataLabelPoint.cs | 2 +- .../DrawingRenderItemExtentions.cs | 3 +- .../Utils/TypeConversion/ColorConverter.cs | 21 ++++++++++++ 7 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index 25c529bcfb..cbecdc4035 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -51,7 +51,7 @@ public void DatalabelBarCharts() { var ws = p.Workbook.Worksheets[0]; var drawings = ws.Drawings; - var ix = 0; + var ix = 1; for (int i = ix; i < drawings.Count; i++) { diff --git a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs index 07bdb81fd2..8fd968a2f2 100644 --- a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs @@ -67,18 +67,20 @@ public void ExtractThemeStyleWorks() //context.SetTranslator(fillTranslator); //context.AddDeclarations(styleClass); - simpleChart.StyleManager.ApplyStyles(); + //simpleChart.StyleManager.ApplyStyles(); //chartDefaultStyle.DataPointLine.FillReference.Color - simpleChart.Title.Font.Color = Color.CornflowerBlue; + //simpleChart.Title.Font.Color = Color.CornflowerBlue; //simpleChart.StyleManager.Style.Title.FontReference.Color.SetPresetColor(Color.CornflowerBlue); //Highest order of styling if datapoint does not exist //simpleChart.StyleManager.Style.DeleteAllNode("cs:dataPointLine"); simpleChart.StyleManager.Style.DataPointLine.BorderReference.Color.SetPresetColor(Color.Green); + // + //var currBorderFill = simpleChart.StyleManager.Style.DataPointLine.Border.Fill; //simpleChart.StyleManager.Style.DataPointLine.Border.Fill.DeleteNode(path); - simpleChart.StyleManager.Style.DataPointLine.Border.Fill.Color = Color.Red; + //simpleChart.StyleManager.Style.DataPointLine.Border.Fill.Color = Color.Red; //simpleChart.StyleManager.Style.DataPointLine.Fill.Color = Color.Green; //simpleChart.StyleManager.Style.DataPoint.Fill.Color = Color.Yellow; @@ -86,7 +88,7 @@ public void ExtractThemeStyleWorks() simpleChart.StyleManager.ApplyStyles(); - simpleChart.Title.TextBody.Paragraphs[0].TextRuns[0].Fill.Color = Color.CornflowerBlue; + //simpleChart.Title.TextBody.Paragraphs[0].TextRuns[0].Fill.Color = Color.CornflowerBlue; var svg = simpleChart.ToSvg(); SaveTextFileToWorkbook($"svg\\MyLineIonThemeExcel2.svg", svg); @@ -103,6 +105,30 @@ public void ExtractThemeStyleWorks() } + [TestMethod] + public void ExtractThemeStyleWorksDataLine() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + + using (var p = OpenTemplatePackage("MyLineIonThemeExcel.xlsx")) + { + var wbStyles = p.Workbook.Styles; + var simpleChart = p.Workbook.Worksheets[0].Drawings[0].As.Chart.LineChart; + + simpleChart.Title.Text = "Hello"; + + var chartDefaultStyle = simpleChart.StyleManager.Style; + simpleChart.StyleManager.ApplyStyles(); + + //simpleChart.Title.TextBody.Paragraphs[0].TextRuns[0].Fill.Color = Color.CornflowerBlue; + + var svg = simpleChart.ToSvg(); + SaveTextFileToWorkbook($"svg\\MyLineIonThemeExcelChangeOnlyTitle.svg", svg); + SaveAndCleanup(p); + } + + } + [TestMethod] public void TextRunIsStyledButNotTitleFont() { diff --git a/src/EPPlus/Drawing/Enums/eSchemeColor.cs b/src/EPPlus/Drawing/Enums/eSchemeColor.cs index fd51e80a13..adedcb5bff 100644 --- a/src/EPPlus/Drawing/Enums/eSchemeColor.cs +++ b/src/EPPlus/Drawing/Enums/eSchemeColor.cs @@ -66,6 +66,7 @@ public enum eSchemeColor /// FollowedHyperlink, /// + /// AKA "phClr" /// A color used in theme definitions which means to use the color of the style /// Style, diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 14ca1d7c4a..a2db076ba3 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -85,7 +85,7 @@ internal override void DrawSeries() { var middleRight = dataPoints[j].Left + (dataPoints[j].Width / 2); - if (chartBaseY >= dataPoints[j].Top) + if (chartBaseY <= dataPoints[j].Top) { //We are a negative column // ----- Base-Axis @@ -99,8 +99,8 @@ internal override void DrawSeries() // _ // | | Col // ----- Base-Axis - basePoint.Position = new Vector2(middleRight, dataPoints[j].Top); - endPoint.Position = new Vector2(middleRight, dataPoints[j].Bottom); + basePoint.Position = new Vector2(middleRight, dataPoints[j].Bottom); + endPoint.Position = new Vector2(middleRight, dataPoints[j].Top); } serieDataLabels[i].SetDimensions(j, basePoint, endPoint); diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index 2222af6c02..f1bcda64f5 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -353,7 +353,7 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) //And endVector is the top center position. //--- Visualize positions for debugging purposes - //CreateDebugPoints(basePoint, endPoint, centerPoint); + CreateDebugPoints(basePoint, endPoint, centerPoint); //--- switch (_labelPosition) diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs index 922d468322..0afa6e1965 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs @@ -198,7 +198,8 @@ private static string GetFillColor(ExcelTheme theme, ExcelDrawingFillBasic fill, } else if (fill.Style == eFillStyle.SolidFill) { - fc = tc.ColorConverter.GetThemeColor(theme, fill.SolidFill.Color); + //Send in styleFill as well since a solid fill can refer to style color + fc = tc.ColorConverter.GetThemeColor(theme, fill.SolidFill.Color, styleFillColor); } else { diff --git a/src/EPPlus/Utils/TypeConversion/ColorConverter.cs b/src/EPPlus/Utils/TypeConversion/ColorConverter.cs index b25f4a8565..6b60f9d91a 100644 --- a/src/EPPlus/Utils/TypeConversion/ColorConverter.cs +++ b/src/EPPlus/Utils/TypeConversion/ColorConverter.cs @@ -41,6 +41,27 @@ public static Color GetThemeColor(ExcelTheme theme, ExcelDrawingColorManager cm) } + public static Color GetThemeColor(ExcelTheme theme, ExcelDrawingColorManager cm, ExcelDrawingColorManager cmStyle) + { + if (cm != null && cm.ColorType == eDrawingColorType.Scheme) + { + ExcelDrawingThemeColorManager newCm; + if(cm.SchemeColor.Color == eSchemeColor.Style) + { + return GetThemeColor(theme, cmStyle); + } + else + { + newCm = theme.ColorScheme.GetColorByEnum(cm.SchemeColor.Color); + } + var nc = GetThemeColor(newCm); + return ApplyTransforms(nc, cm.Transforms); + } + var c = GetThemeColor(cm); + return ApplyTransforms(c, cm.Transforms); + + } + private static Color ApplyTransforms(Color c, ExcelColorTransformCollection transforms) { if (transforms==null || transforms.Count == 0) return c; From 2ecc4d139c0927c4232ae118703e998d80245f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 23 Jun 2026 12:49:30 +0200 Subject: [PATCH 098/118] Fixed null color for shapes and charts --- .../Drawing/Renderer/Chart/ChartPlotareaRenderer.cs | 2 +- src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs | 2 +- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 2 +- .../Renderer/RenderItems/DrawingRenderItemExtentions.cs | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs index e1abb1c709..5903e8ed14 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs @@ -59,7 +59,7 @@ internal void SetPlotAreaRectangle() ChartRenderer.Legend.Rectangle.Top = Group.Top + rect.Height / 2 - ChartRenderer.Legend.Rectangle.Height / 2; } - rect.SetDrawingPropertiesFill(ChartRenderer.Theme, pa.Fill, ChartRenderer.Chart.StyleManager.Style?.PlotArea.FillReference.Color); + rect.SetDrawingPropertiesFill(ChartRenderer.Theme, pa.Fill, ChartRenderer.Chart.StyleManager.Style?.PlotArea.FillReference.Color, ChartRenderer.Theme.ColorScheme.Light1); rect.SetDrawingPropertiesBorder(ChartRenderer.Theme, pa.Border, ChartRenderer.Chart.StyleManager.Style?.PlotArea.BorderReference.Color, pa.Border.Fill.Style != eFillStyle.NoFill, 0.75); Rectangle = rect; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs b/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs index 53d2015c52..b0452efee1 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs @@ -127,7 +127,7 @@ internal static RenderItem GetMarkerItem(ChartRenderer sc, ExcelLineChartSerie l } else if (ls.Fill.IsEmpty) { - item?.SetDrawingPropertiesFillBasic(sc.Theme, ls.Border.Fill, sc.Chart.StyleManager.Style.DataPointMarker.FillReference.Color); + item?.SetDrawingPropertiesFillBasic(sc.Theme, ls.Border.Fill, sc.Chart.StyleManager.Style?.DataPointMarker.FillReference.Color, sc.Theme.ColorScheme.Accent1); } else { diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index 02bcd06172..378b8d339e 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -306,7 +306,7 @@ private void SetChartArea() var item = new ChartAreaRenderer(this); item.Rectangle.Width = Bounds.Width; item.Rectangle.Height = Bounds.Height; - item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style?.ChartArea.FillReference.Color); + item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style?.ChartArea.FillReference.Color, Theme.ColorScheme.Light1); item.Rectangle.SetDrawingPropertiesBorder(Theme, Chart.Border, Chart.StyleManager.Style?.ChartArea.BorderReference.Color, Chart.Border.Width > 0); item.AppendRenderItems(RenderItems); item.SetMargins(Chart.TextBody); diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs index 0afa6e1965..7a5099505c 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs @@ -31,7 +31,7 @@ internal enum SvgFillType } internal static class DrawingRenderItemExtentions { - internal static void SetDrawingPropertiesFill(this RenderItem item, ExcelTheme theme, ExcelDrawingFill fill, ExcelDrawingColorManager color) + internal static void SetDrawingPropertiesFill(this RenderItem item, ExcelTheme theme, ExcelDrawingFill fill, ExcelDrawingColorManager color, ExcelDrawingThemeColorManager nullColor=null) { switch (fill.Style) { @@ -43,11 +43,11 @@ internal static void SetDrawingPropertiesFill(this RenderItem item, ExcelTheme t item.BlipFill = new DrawingRenderBlipFill(fill.BlipFill); break; default: - SetDrawingPropertiesFillBasic(item, theme, fill, color); + SetDrawingPropertiesFillBasic(item, theme, fill, color, nullColor ?? theme.ColorScheme.Accent1); break; } } - internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTheme theme, ExcelDrawingFillBasic fill, ExcelDrawingColorManager color) + internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTheme theme, ExcelDrawingFillBasic fill, ExcelDrawingColorManager color, ExcelDrawingThemeColorManager nullColor) { double? opacity = null; switch (fill.Style) @@ -55,7 +55,7 @@ internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTh case eFillStyle.NoFill: if (fill.IsEmpty) //Do NOT remove. This if is required for Shapes { - item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity); + item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity, nullColor); } else { From c2f3e88687df062756375f86e49fc8cd91ab0c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Tue, 23 Jun 2026 13:30:42 +0200 Subject: [PATCH 099/118] Fixed blip fills --- src/EPPlus.DrawingRenderer/Svg/IRender.cs | 2 +- src/EPPlus/Utils/Rendering/DrawingExtensions.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/EPPlus.DrawingRenderer/Svg/IRender.cs b/src/EPPlus.DrawingRenderer/Svg/IRender.cs index 88480d371e..ca3a62b34b 100644 --- a/src/EPPlus.DrawingRenderer/Svg/IRender.cs +++ b/src/EPPlus.DrawingRenderer/Svg/IRender.cs @@ -607,7 +607,7 @@ private string SetStretchTileProps(RenderBlipFill blipFill) var y = Bounds.Height * blipFill.StretchOffset.TopOffset / 100; var width = Bounds.Width - x - Bounds.Width * blipFill.StretchOffset.RightOffset / 100; var height = Bounds.Height - x - Bounds.Height * blipFill.StretchOffset.BottomOffset / 100; - return $" preserveAspectRatio=\"none\" x=\"{x.ToString(CultureInfo.InvariantCulture)}\" y=\"{y.ToString(CultureInfo.InvariantCulture)}\" width=\"{width.ToString(CultureInfo.InvariantCulture)}\" height=\"{height.ToString(CultureInfo.InvariantCulture)}\" "; + return $" preserveAspectRatio=\"none\" x=\"{x.ToString(CultureInfo.InvariantCulture)}\" y=\"{y.ToString(CultureInfo.InvariantCulture)}\" width=\"{width.PointToPixelString()}\" height=\"{height.PointToPixelString()}\" "; } else if (!(blipFill.Tile.HorizontalOffset == 0 && blipFill.Tile.VerticalOffset == 0 && blipFill.Tile.HorizontalRatio == 100 && blipFill.Tile.VerticalRatio == 100 && blipFill.Tile.FlipMode == TileFlipMode.None)) diff --git a/src/EPPlus/Utils/Rendering/DrawingExtensions.cs b/src/EPPlus/Utils/Rendering/DrawingExtensions.cs index d9242c41cb..7d93cfe143 100644 --- a/src/EPPlus/Utils/Rendering/DrawingExtensions.cs +++ b/src/EPPlus/Utils/Rendering/DrawingExtensions.cs @@ -146,7 +146,7 @@ internal static FillTile AsFillTile(this ExcelDrawingBlipFillTile fillTile) { return new FillTile { - Alignment = (RectangleAlignment)fillTile.Alignment, + Alignment = (RectangleAlignment?)fillTile.Alignment, FlipMode = (TileFlipMode)fillTile.FlipMode, HorizontalOffset = fillTile.HorizontalOffset, VerticalOffset = fillTile.VerticalOffset, From a60e584a3331eddc90ba5958ab2a4bdd74cdefb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 23 Jun 2026 18:46:07 +0200 Subject: [PATCH 100/118] Start of fix for title element --- .../Shape/ShapeToSvgTests.cs | 23 ++++++++++++- .../StyleTests.cs | 13 +++++--- src/EPPlus/Drawing/Chart/ExcelChartTitle.cs | 13 +++++--- .../Style/Text/ExcelDrawingParagraph.cs | 19 ++++++++++- .../Text/ExcelDrawingParagraphCollection.cs | 4 +-- .../RichText/ExcelParagraphCollection.cs | 32 ++++++++++++++++--- 6 files changed, 86 insertions(+), 18 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs index ce95ac8260..76f3640038 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs @@ -508,7 +508,7 @@ public void SuperScriptShape() //var c = ws.Drawings[ix]; //var svg = renderer.RenderDrawingToSvg(c); //SaveTextFileToWorkbook($"svg\\LineChartForSvg_Single{ix++}.svg", svg); - var ix = 1; + var ix = 0; foreach (var c in ws.Drawings) { var svg = c.ToSvg(); @@ -638,6 +638,27 @@ public void GenerateShapeCenteredParagraph() SaveAndCleanup(p); } } + + [TestMethod] + public void ChartAndShapeGreen() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("ShapeAndChartTestGreen.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + //var ix = 1; + //var c = ws.Drawings[ix]; + //var svg = renderer.RenderDrawingToSvg(c); + //SaveTextFileToWorkbook($"svg\\LineChartForSvg_Single{ix++}.svg", svg); + var ix = 0; + foreach (var c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\TestGreen{ix++}.svg", svg); + } + } + } + [TestMethod] public void CreateChartsWithDifferentSize() { diff --git a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs index 8fd968a2f2..57bb72132b 100644 --- a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs @@ -33,7 +33,7 @@ public void BaseThemeChartStyle2() var svg = c.ToSvg(); //var renderer = new EPPlusImageRenderer.ImageRenderer(); //var svg = renderer.RenderDrawingToSvg(c); - SaveTextFileToWorkbook($"svg\\baseThemeChartStyle.svg", svg); + SaveTextFileToWorkbook($"svg\\baseThemeChartStyle2.svg", svg); } } @@ -117,13 +117,16 @@ public void ExtractThemeStyleWorksDataLine() simpleChart.Title.Text = "Hello"; - var chartDefaultStyle = simpleChart.StyleManager.Style; - simpleChart.StyleManager.ApplyStyles(); + //var chartDefaultStyle = simpleChart.StyleManager.Style; + //simpleChart.StyleManager.ApplyStyles(); //simpleChart.Title.TextBody.Paragraphs[0].TextRuns[0].Fill.Color = Color.CornflowerBlue; - var svg = simpleChart.ToSvg(); - SaveTextFileToWorkbook($"svg\\MyLineIonThemeExcelChangeOnlyTitle.svg", svg); + //var svg = simpleChart.ToSvg(); + //SaveTextFileToWorkbook($"svg\\MyLineIonThemeExcelChangeOnlyTitle.svg", svg); + + var fontSize = simpleChart.Title.Font.Size; + SaveAndCleanup(p); } diff --git a/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs index 400f28e5b7..cf9759862b 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs @@ -148,11 +148,10 @@ public ExcelTextBody TextBody { if (_textBody == null) { - //var defBody = DefaultTextBody; - //var firstDefaultRunProperties = DefaultTextBody.Paragraphs.CreateOrGetDefaultRunProperties($"{_defTxBodyPath}/a:bodyPr/a:p/a:pPr/a:defRPr", TopNode); + var defBody = DefaultTextBody; + var firstDefaultRunProperties = DefaultTextBody.Paragraphs.CreateOrGetDefaultRunProperties($"{_defTxBodyPath}/a:bodyPr/a:p/a:pPr/a:defRPr", TopNode); _textBody = new ExcelTextBody(_chart, NameSpaceManager, TopNode, $"{_richTextPath}/a:bodyPr", SchemaNodeOrder); - //_textBody.Paragraphs.FirstDefaultRunProperties = firstDefaultRunProperties; - //_textBody.Paragraphs.FirstDefaultRunProperties = DefaultTextBody.Paragraphs.CreateOrGetDefaultRunProperties(); + _textBody.Paragraphs.FirstDefaultRunProperties = firstDefaultRunProperties; } return _textBody; } @@ -250,6 +249,12 @@ internal void CreateRichText() } var tb = TextBody; _richText = new ExcelParagraphCollection(tb, _chart, NameSpaceManager, TopNode, $"{_richTextPath}/a:p", SchemaNodeOrder, defFont, eTextAlignment.Center); + + //if(tb.Paragraphs.Count == 0) + //{ + // var para = _richText.Add(""); + // _richText.Remove(para); + //} } private ExcelChartStyleEntry GetStylePart() diff --git a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs index b3410d654b..47d03d0567 100644 --- a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs +++ b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs @@ -36,6 +36,9 @@ public class ExcelDrawingParagraph : XmlHelper Action _initXml; internal IPictureRelationDocument _prd; internal ExcelDrawingParagraphCollection _paragraphs; + + //bool legacyDefaultRunPropertySetting = false; + internal ExcelDrawingParagraph(ExcelDrawingParagraphCollection paragraphs, IPictureRelationDocument prd, XmlNamespaceManager nameSpaceManager, XmlNode topNode, string[] schemaNodeOrder, Action initXml) : base(nameSpaceManager, topNode) { _paragraphs = paragraphs; @@ -43,13 +46,27 @@ internal ExcelDrawingParagraph(ExcelDrawingParagraphCollection paragraphs, IPict _initXml = initXml; _prd = prd; + + if (_paragraphs.FirstDefaultRunProperties == null) { DefaultRunProperties = new ExcelTextFontXml(prd, nameSpaceManager, topNode, "a:pPr/a:defRPr", schemaNodeOrder, initXml); } else { - DefaultRunProperties = _paragraphs.FirstDefaultRunProperties; + if(paragraphs.Count == 0) + { + //The node must still be created + var xmlFirstDefault = ((ExcelTextFontXml)paragraphs.FirstDefaultRunProperties).XmlHelper; + var textFont = new ExcelTextFontXml(prd, nameSpaceManager, topNode, "a:pPr/a:defRPr", schemaNodeOrder, initXml); + var xmlNewNode = textFont.XmlHelper; + CopyElement((XmlElement)xmlFirstDefault.TopNode, (XmlElement)xmlNewNode.TopNode); + DefaultRunProperties = textFont; + } + else + { + DefaultRunProperties = _paragraphs.FirstDefaultRunProperties; + } } var normalStyle = _prd.Package.Workbook.Styles.GetNormalStyle(); diff --git a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraphCollection.cs b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraphCollection.cs index 0ec2e5ecc2..6348c8bfd2 100644 --- a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraphCollection.cs +++ b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraphCollection.cs @@ -47,7 +47,7 @@ internal ExcelDrawingParagraphCollection(IPictureRelationDocument prd, XmlNamesp { var paragraph = new ExcelDrawingParagraph(this, prd, NameSpaceManager, pn, schemaNodeOrder, initXml); _paragraphs.Add(paragraph); - //if(_paragraphs.Count == 1) + //if (_paragraphs.Count == 1 && FirstDefaultRunProperties == null) //{ // FirstDefaultRunProperties = paragraph.DefaultRunProperties; //} @@ -90,10 +90,10 @@ public ExcelDrawingParagraph Add(string text) } var p = new ExcelDrawingParagraph(this, _prd, NameSpaceManager, pn, SchemaNodeOrder, _initXml); + var tr = p.TextRuns.Add(text); _paragraphs.Add(p); - //_addCallback?.Invoke(tr); if (placeHolderNode != null) diff --git a/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs b/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs index ef13fff0a6..b0b6d31256 100644 --- a/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs +++ b/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs @@ -33,13 +33,13 @@ public class ExcelParagraphCollection : XmlHelper, IEnumerable private readonly float _defaultFontSize; private readonly ExcelTextFont _defaultFont; private readonly ExcelTextBody _textBody; - internal ExcelParagraphCollection(ExcelTextBody tb, ExcelDrawing drawing, XmlNamespaceManager ns, XmlNode topNode, string path, string[] schemaNodeOrder, float defaultFontSize =11, eTextAlignment defaultAlignment = eTextAlignment.Left) : + internal ExcelParagraphCollection(ExcelTextBody tb, ExcelDrawing drawing, XmlNamespaceManager ns, XmlNode topNode, string path, string[] schemaNodeOrder, float defaultFontSize = 11, eTextAlignment defaultAlignment = eTextAlignment.Left) : base(ns, topNode) { _drawing = drawing; _textBody = tb; _defaultFontSize = defaultFontSize; - AddSchemaNodeOrder(schemaNodeOrder, new string[] { "strRef","rich", "f", "strCache", "bodyPr", "lstStyle", "p", "ptCount","pt","pPr", "lnSpc", "spcBef", "spcAft", "buClrTx", "buClr", "buSzTx", "buSzPct", "buSzPts", "buFontTx", "buFont","buNone", "buAutoNum", "buChar","buBlip", "tabLst","defRPr", "r","br","fld" ,"endParaRPr" }); + AddSchemaNodeOrder(schemaNodeOrder, new string[] { "strRef", "rich", "f", "strCache", "bodyPr", "lstStyle", "p", "ptCount", "pt", "pPr", "lnSpc", "spcBef", "spcAft", "buClrTx", "buClr", "buSzTx", "buSzPct", "buSzPts", "buFontTx", "buFont", "buNone", "buAutoNum", "buChar", "buBlip", "tabLst", "defRPr", "r", "br", "fld", "endParaRPr" }); //if (topNode.SelectSingleNode(path, ns) == null) //{ @@ -70,7 +70,7 @@ internal ExcelParagraphCollection(ExcelTextBody tb, ExcelDrawing drawing, XmlNa foreach (var p in tb.Paragraphs) { p.defaultAlignment = defaultAlignment; - foreach(var tr in p.TextRuns) + foreach (var tr in p.TextRuns) { _list.Add(new ExcelParagraph(tr)); } @@ -95,7 +95,29 @@ private void AddParagraph(ExcelParagraphTextRunBase tr) return; } } - _list.Add(new ExcelParagraph(tr)); + if (_list.Count == 0) + { + var para = new ExcelParagraph(tr); + _list.Add(para); + + ////_textBody.Paragraphs[0].DefaultRunProperties = _textBody.Paragraphs.FirstDefaultRunProperties; + //var para = new ExcelParagraph(tr); + //_list.Add(para); + //var defRpr = tr.Paragraph._paragraphs.FirstDefaultRunProperties; + //var para = new ExcelParagraph(tr); + //var mf = defRpr.GetMeasureFont(); + //para.SetFromFont(mf.FontFamily, mf.Size, + // (mf.Style & MeasurementFontStyles.Bold) != 0, + // (mf.Style & MeasurementFontStyles.Italic) != 0, + // (mf.Style & MeasurementFontStyles.Underline) != 0, + // (mf.Style & MeasurementFontStyles.Strikeout) != 0); + //_list.Add(para); + } + else + { + _list.Add(new ExcelParagraph(tr)); + } + //_list.Add(new ExcelParagraph(tr)); } private void RemoveTextRun(ExcelParagraphTextRunBase textRun) { @@ -239,7 +261,7 @@ public string Text { if (_textBody.Paragraphs.Count == 0) { - Add(value); + Add(value, true); } else { From 08439a704675a7b4c5376837aa6d491617627929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 24 Jun 2026 09:47:24 +0200 Subject: [PATCH 101/118] Fixed title bug in epplus --- .../Style/Text/ExcelDrawingParagraph.cs | 15 ++++-- .../RichText/ExcelParagraphCollection.cs | 52 +------------------ 2 files changed, 14 insertions(+), 53 deletions(-) diff --git a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs index 47d03d0567..ff8a9ad04b 100644 --- a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs +++ b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs @@ -57,10 +57,19 @@ internal ExcelDrawingParagraph(ExcelDrawingParagraphCollection paragraphs, IPict if(paragraphs.Count == 0) { //The node must still be created - var xmlFirstDefault = ((ExcelTextFontXml)paragraphs.FirstDefaultRunProperties).XmlHelper; + var xmlFirstDefault = ((ExcelTextFontXml)paragraphs.FirstDefaultRunProperties).XmlHelper.TopNode.ParentNode; + XmlNode paragraphProperties = topNode.SelectSingleNode("a:pPr", NameSpaceManager); + + //Create paragraph properties if it does not already exist + if (paragraphProperties == null) + { + paragraphProperties = CreateNode(topNode, "a:pPr", true); + } + //Create defRPr var textFont = new ExcelTextFontXml(prd, nameSpaceManager, topNode, "a:pPr/a:defRPr", schemaNodeOrder, initXml); - var xmlNewNode = textFont.XmlHelper; - CopyElement((XmlElement)xmlFirstDefault.TopNode, (XmlElement)xmlNewNode.TopNode); + + //Copy the first element and apply it to the paragraphProperties + CopyElement((XmlElement)xmlFirstDefault, (XmlElement)paragraphProperties); DefaultRunProperties = textFont; } else diff --git a/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs b/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs index b0b6d31256..0face72036 100644 --- a/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs +++ b/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs @@ -40,33 +40,8 @@ internal ExcelParagraphCollection(ExcelTextBody tb, ExcelDrawing drawing, XmlNam _textBody = tb; _defaultFontSize = defaultFontSize; AddSchemaNodeOrder(schemaNodeOrder, new string[] { "strRef", "rich", "f", "strCache", "bodyPr", "lstStyle", "p", "ptCount", "pt", "pPr", "lnSpc", "spcBef", "spcAft", "buClrTx", "buClr", "buSzTx", "buSzPct", "buSzPts", "buFontTx", "buFont", "buNone", "buAutoNum", "buChar", "buBlip", "tabLst", "defRPr", "r", "br", "fld", "endParaRPr" }); - - //if (topNode.SelectSingleNode(path, ns) == null) - //{ - // if (tb.Paragraphs.Count == 0) - // { - // var paragraphParent = path.Substring(0, path.LastIndexOf('/')); - // var tmpTop = TopNode; - // TopNode = TopNode.SelectNodes(paragraphParent, ns)[0]; - // var placeHolderNode = tb.Paragraphs.CreateAndReturnParagraphPlaceHolder(); - // TopNode = tmpTop; - // } - //} - - //var tfXml = new ExcelTextFontXml(drawing._drawings, ns, TopNode, path + "/a:pPr/a:defRPr", schemaNodeOrder); - ////if(tb.Paragraphs.Count == 0) - ////{ - //// if(tfXml._rootNode.SelectSingleNode("//a:p", tfXml.XmlHelper.NameSpaceManager) == null) - //// { - - //// var placeHolderNode = tb.Paragraphs.CreateAndReturnParagraphPlaceHolder(); - //// tfXml.XmlHelper.TopNode = placeHolderNode; - //// } - ////} - - //_defaultFont = tfXml; - _path = path; + foreach (var p in tb.Paragraphs) { p.defaultAlignment = defaultAlignment; @@ -95,29 +70,7 @@ private void AddParagraph(ExcelParagraphTextRunBase tr) return; } } - if (_list.Count == 0) - { - var para = new ExcelParagraph(tr); - _list.Add(para); - - ////_textBody.Paragraphs[0].DefaultRunProperties = _textBody.Paragraphs.FirstDefaultRunProperties; - //var para = new ExcelParagraph(tr); - //_list.Add(para); - //var defRpr = tr.Paragraph._paragraphs.FirstDefaultRunProperties; - //var para = new ExcelParagraph(tr); - //var mf = defRpr.GetMeasureFont(); - //para.SetFromFont(mf.FontFamily, mf.Size, - // (mf.Style & MeasurementFontStyles.Bold) != 0, - // (mf.Style & MeasurementFontStyles.Italic) != 0, - // (mf.Style & MeasurementFontStyles.Underline) != 0, - // (mf.Style & MeasurementFontStyles.Strikeout) != 0); - //_list.Add(para); - } - else - { - _list.Add(new ExcelParagraph(tr)); - } - //_list.Add(new ExcelParagraph(tr)); + _list.Add(new ExcelParagraph(tr)); } private void RemoveTextRun(ExcelParagraphTextRunBase textRun) { @@ -174,7 +127,6 @@ public int Count public ExcelParagraph Add(string Text, bool NewParagraph=false) { ExcelDrawingParagraph p; - ExcelParagraph item; if (NewParagraph || _textBody.Paragraphs.Count==0) { _textBody.Paragraphs.Add(Text); From 70c6563c0e57c9aa4a34aaddd5bd1cc4973bbd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Wed, 24 Jun 2026 11:19:37 +0200 Subject: [PATCH 102/118] Fixes errorbars for bar charts --- .../Chart/ErrorbarsTests.cs | 22 ++++++++- .../BarColumnChartTypeDrawer.cs | 45 ++++++++++++++++--- .../ChartTypeDrawers/ChartErrorBarRenderer.cs | 32 ++++++------- .../Chart/ChartTypeDrawers/ChartTypeDrawer.cs | 5 ++- 4 files changed, 80 insertions(+), 24 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs index a3a2e76f64..3b2ea9db41 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs @@ -7,7 +7,7 @@ namespace EPPlus.Export.ImageRenderer.Tests.Chart public class ErrorbarsTests : TestBase { [TestMethod] - public void GenerateSvgForErrorbars_Sheet1() + public void GenerateSvgForErrorbars_Line_Sheet1() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("Errorbars.xlsx")) @@ -46,5 +46,25 @@ public void GenerateSvgForErrorbars_Column_Sheet2() } } } + [TestMethod] + public void GenerateSvgForErrorbars_Bar_Sheet3() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("Errorbars.xlsx")) + { + var ws = p.Workbook.Worksheets[2]; + //var ix = 4; + //var c = ws.Drawings[ix]; + //var svg = c.ToSvg(); + //SaveTextFileToWorkbook($"svg\\Error_sheet1_ind{ix++}.svg", svg); + + var ix = 0; + foreach (ExcelChart c in ws.Drawings) + { + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\Errorbar_sheet3_{ix++}.svg", svg); + } + } + } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index a2db076ba3..84ea7b5496 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -167,19 +167,21 @@ private void AddBar(ExcelBarChart chartType, ExcelBarChartSerie serie, List286 287 " var gapWidth = chartType.GapWidth == int.MinValue ? 150 : chartType.GapWidth; var gapPercent = gapWidth / 100D; // Gap width between bars/columns in percent var overlapPercent = chartType.Overlap / 100D; // Overlap between bars/columns in percent var slotWidth = yWidth / slotSize; - var clusterWidth = slotWidth * 100 / (100 + chartType.GapWidth); + var clusterWidth = slotWidth * 100 / (100 + gapWidth); var step = 1 - overlapPercent; var barWidth = slotWidth / (1 + (seriesCount - 1) * step + gapPercent); var halfGap = (barWidth * gapPercent) / 2; @@ -215,23 +217,38 @@ private void AddBar(ExcelBarChart chartType, ExcelBarChartSerie serie, List l, int i) { - if(l.Count==0) + if(l.Count==1) { return l[0]; } @@ -120,29 +120,31 @@ internal List GetErrorBarRenderItem(int index, ChartAxisRenderer xAx bottomValue = Values[index][0]; } - if (_errorbars.Direction == eErrorBarDirection.X) + //if (_errorbars.Direction == eErrorBarDirection.X ) + if(_chartType.IsTypeBar()) { - var rightPos = xAxis.GetPositionInPlotarea(topValue); + var rightPos = yAxis.GetPositionInPlotarea(topValue); var centerPos = yAxis.GetPositionInPlotarea(Values[index][1]); - var leftPos = xAxis.GetPositionInPlotarea(bottomValue); + var leftPos = yAxis.GetPositionInPlotarea(bottomValue); //Bottom line //path.Commands.Add(new PathCommands(PathCommandType.Move, leftPos, yPos, centerPos, yPos)); var bl = new LineRenderItem(ChartRenderer.Bounds) { X1 = leftPos, - Y1 = yPos, + Y1 = xPos, X2 = centerPos, - Y2 = yPos + Y2 = xPos }; + //Top line //path.Commands.Add(new PathCommands(PathCommandType.Move, centerPos, yPos, rightPos, yPos)); var tl = new LineRenderItem(ChartRenderer.Bounds) { X1 = centerPos, - Y1 = yPos, + Y1 = xPos, X2 = rightPos, - Y2 = yPos + Y2 = xPos }; l.Add(bl); l.Add(tl); @@ -153,18 +155,18 @@ internal List GetErrorBarRenderItem(int index, ChartAxisRenderer xAx var bc = new LineRenderItem(ChartRenderer.Bounds) { X1 = leftPos, - Y1 = yPos - 3, + Y1 = xPos - 3, X2 = leftPos, - Y2 = yPos + 3 + Y2 = xPos + 3 }; //Top cap //path.Commands.Add(new PathCommands(PathCommandType.Move, rightPos, yPos - 3, rightPos, yPos + 3)); var tc = new LineRenderItem(ChartRenderer.Bounds) { X1 = rightPos, - Y1 = yPos - 3, + Y1 = xPos - 3, X2 = rightPos, - Y2 = yPos + 3 + Y2 = xPos + 3 }; l.Add(bc); l.Add(tc); @@ -172,9 +174,9 @@ internal List GetErrorBarRenderItem(int index, ChartAxisRenderer xAx } else { - var bottomPos = yAxis.GetPositionInPlotarea(bottomValue); - var centerPos = yAxis.GetPositionInPlotarea(Values[index][1]); - var topPos = yAxis.GetPositionInPlotarea(topValue); + var bottomPos = xAxis.GetPositionInPlotarea(bottomValue); + var centerPos = xAxis.GetPositionInPlotarea(Values[index][1]); + var topPos = xAxis.GetPositionInPlotarea(topValue); //Bottom line //path.Commands.Add(new PathCommands(PathCommandType.Move, xPos, bottomPos, xPos, centerPos)); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs index 96b91846d8..5b164ad296 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs @@ -205,9 +205,10 @@ protected void CreateTrendlines(ExcelChart chartType, List> xValues } protected void CreateErrorBars(ExcelChart chartType, List> xValues, List> yValues) { + if (!(chartType.IsTypeLine() || chartType.IsTypeColumn() || chartType.IsTypeBar())) return; + var serieIndex = 0; - if (!chartType.IsTypeLine()) return; - foreach (ExcelLineChartSerie serie in chartType.Series) + foreach (ExcelChartSerieWithErrorBars serie in chartType.Series) { if (serie.HasErrorBars()) { From 396227bebf7e42bb0860bc590916b58e8e3ab272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 24 Jun 2026 11:33:15 +0200 Subject: [PATCH 103/118] Fixed one test-failure --- .../StyleTests.cs | 6 ++ src/EPPlus/Drawing/Chart/ExcelChartTitle.cs | 15 ++++- .../Style/Text/ExcelDrawingParagraph.cs | 59 ++++++++++++------- 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs index 57bb72132b..28e3783fe1 100644 --- a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs @@ -1,5 +1,6 @@ using OfficeOpenXml; using System.Drawing; +using System.Linq; namespace EPPlus.Export.ImageRenderer.Tests { @@ -127,6 +128,11 @@ public void ExtractThemeStyleWorksDataLine() var fontSize = simpleChart.Title.Font.Size; + Assert.AreEqual(simpleChart.Title.Font.Size, 14d); + var paragraphPropeties = simpleChart.Title.GetNode("c:txPr/a:p/a:pPr"); + var paragraphPropertiesRich = simpleChart.Title.GetNode("c:tx/c:rich/a:p/a:pPr"); + Assert.AreEqual(paragraphPropeties.InnerXml, paragraphPropertiesRich.InnerXml); + SaveAndCleanup(p); } diff --git a/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs index cf9759862b..055df57ece 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs @@ -149,7 +149,7 @@ public ExcelTextBody TextBody if (_textBody == null) { var defBody = DefaultTextBody; - var firstDefaultRunProperties = DefaultTextBody.Paragraphs.CreateOrGetDefaultRunProperties($"{_defTxBodyPath}/a:bodyPr/a:p/a:pPr/a:defRPr", TopNode); + var firstDefaultRunProperties = DefaultTextBody.Paragraphs.CreateOrGetDefaultRunProperties($"{_defTxBodyPath}/a:p/a:pPr/a:defRPr", TopNode); _textBody = new ExcelTextBody(_chart, NameSpaceManager, TopNode, $"{_richTextPath}/a:bodyPr", SchemaNodeOrder); _textBody.Paragraphs.FirstDefaultRunProperties = firstDefaultRunProperties; } @@ -444,7 +444,18 @@ public override string Text var applyStyle = (RichText.Count == 0); RichText.Text = value; _font = null; - if (applyStyle) _chart.ApplyStyleOnPart(this, _chart.StyleManager?.Style?.Title, true); + if (applyStyle) + { + var defRprOld = GetNode($"{_defTxBodyPath}/a:p/a:pPr/a:defRPr"); + defRprOld.InnerXml = ""; + _chart.ApplyStyleOnPart(this, _chart.StyleManager?.Style?.Title, true); + + //Apply style on part does not update default path element yet replaces it + //Copy the new element to the old. + var defRpr = GetNode($"{_defTxBodyPath}/a:p/a:pPr/a:defRPr"); + var textSettingsDefRPr = GetNode($"{_richTextPath}/a:p/a:pPr/a:defRPr"); + CopyElement((XmlElement)defRpr, (XmlElement)textSettingsDefRPr); + } } } /// diff --git a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs index ff8a9ad04b..f482329dcd 100644 --- a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs +++ b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs @@ -54,28 +54,29 @@ internal ExcelDrawingParagraph(ExcelDrawingParagraphCollection paragraphs, IPict } else { - if(paragraphs.Count == 0) - { - //The node must still be created - var xmlFirstDefault = ((ExcelTextFontXml)paragraphs.FirstDefaultRunProperties).XmlHelper.TopNode.ParentNode; - XmlNode paragraphProperties = topNode.SelectSingleNode("a:pPr", NameSpaceManager); + DefaultRunProperties = _paragraphs.FirstDefaultRunProperties; + //if(paragraphs.Count == 0) + //{ + // //The node must still be created + // var xmlFirstDefault = ((ExcelTextFontXml)paragraphs.FirstDefaultRunProperties).XmlHelper.TopNode.ParentNode; + // XmlNode paragraphProperties = topNode.SelectSingleNode("a:pPr", NameSpaceManager); - //Create paragraph properties if it does not already exist - if (paragraphProperties == null) - { - paragraphProperties = CreateNode(topNode, "a:pPr", true); - } - //Create defRPr - var textFont = new ExcelTextFontXml(prd, nameSpaceManager, topNode, "a:pPr/a:defRPr", schemaNodeOrder, initXml); + // //Create paragraph properties if it does not already exist + // if (paragraphProperties == null) + // { + // paragraphProperties = CreateNode(topNode, "a:pPr", true); + // } + // //Create defRPr + // var textFont = new ExcelTextFontXml(prd, nameSpaceManager, topNode, "a:pPr/a:defRPr", schemaNodeOrder, initXml); - //Copy the first element and apply it to the paragraphProperties - CopyElement((XmlElement)xmlFirstDefault, (XmlElement)paragraphProperties); - DefaultRunProperties = textFont; - } - else - { - DefaultRunProperties = _paragraphs.FirstDefaultRunProperties; - } + // //Copy the first element and apply it to the paragraphProperties + // CopyElement((XmlElement)xmlFirstDefault, (XmlElement)paragraphProperties); + // DefaultRunProperties = textFont; + //} + //else + //{ + // DefaultRunProperties = _paragraphs.FirstDefaultRunProperties; + //} } var normalStyle = _prd.Package.Workbook.Styles.GetNormalStyle(); @@ -105,6 +106,24 @@ internal ExcelDrawingParagraph(ExcelDrawingParagraphCollection paragraphs, IPict } } } + else + { + //The node must still be created + var xmlFirstDefault = ((ExcelTextFontXml)paragraphs.FirstDefaultRunProperties).XmlHelper.TopNode.ParentNode; + XmlNode paragraphProperties = topNode.SelectSingleNode("a:pPr", NameSpaceManager); + + //Create paragraph properties if it does not already exist + if (paragraphProperties == null) + { + paragraphProperties = CreateNode(topNode, "a:pPr", true); + } + //Create defRPr + var textFont = new ExcelTextFontXml(prd, nameSpaceManager, topNode, "a:pPr/a:defRPr", schemaNodeOrder, initXml); + + //Copy the first element and apply it to the paragraphProperties + CopyElement((XmlElement)xmlFirstDefault, (XmlElement)paragraphProperties); + DefaultRunProperties = textFont; + } } else if (legacyDefaultRunPropertySetting && _paragraphs.FirstDefaultRunProperties != null) { From 801f507f06f93e6a16ccdb27f865018c44e4d0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Wed, 24 Jun 2026 12:35:54 +0200 Subject: [PATCH 104/118] Fixed Line Markers --- .../Chart/LineChartToSvgTests.cs | 20 +++++++------- .../Renderer/Chart/LineMarkerHelper.cs | 26 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index b0b3600277..b8eca81ef0 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -19,17 +19,17 @@ public void GenerateSvgForLineCharts_sheet1() { var ws = p.Workbook.Worksheets[0]; - //var ix = 6; - //var c = ws.Drawings[ix]; - //var svg = c.ToSvg(); - //SaveTextFileToWorkbook($"svg\\ChartForSvg_ind{ix++}.svg", svg); + var ix = 5; + var c = ws.Drawings[ix]; + var svg = c.ToSvg(); + SaveTextFileToWorkbook($"svg\\ChartForSvg_ind{ix++}.svg", svg); - for(int i = 0; i< ws.Drawings.Count; i++) - { - var c = ws.Drawings[i]; - var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\ChartForSvg{i}.svg", svg); - } + //for(int i = 0; i< ws.Drawings.Count; i++) + //{ + // var c = ws.Drawings[i]; + // var svg = c.ToSvg(); + // SaveTextFileToWorkbook($"svg\\ChartForSvg{i}.svg", svg); + //} } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs b/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs index b0452efee1..ac92427d2b 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs @@ -19,10 +19,10 @@ internal static RenderItem GetMarkerItem(ChartRenderer sc, ExcelLineChartSerie l float maxSize = isLegend ? 7f : float.MaxValue; var size = m.Size > maxSize ? maxSize : m.Size; var halfSize = size / 2; - var xPath = x / sc.ChartArea.Rectangle.Width; - var yPath = y / sc.ChartArea.Rectangle.Height; - var halfY = halfSize / sc.ChartArea.Rectangle.Height; - var halfX = halfSize / sc.ChartArea.Rectangle.Width; + var xPath = x; + var yPath = y; + //var halfY = halfSize / sc.ChartArea.Rectangle.Height; + //var halfX = halfSize / sc.ChartArea.Rectangle.Width; switch (m.Style) { case eMarkerStyle.Circle: @@ -36,13 +36,13 @@ internal static RenderItem GetMarkerItem(ChartRenderer sc, ExcelLineChartSerie l break; case eMarkerStyle.Triangle: item = new PathRenderItem(sc.Bounds); - var cmd = new PathCommands(PathCommandType.Move, new double[] { xPath + halfX, yPath + halfY, xPath, yPath - halfY, xPath - halfX, yPath + halfY }); + var cmd = new PathCommands(PathCommandType.Move, new double[] { xPath + halfSize, yPath + halfSize, xPath, yPath - halfSize, xPath - halfSize, yPath + halfSize }); ((PathRenderItem)item).Commands.Add(cmd); ((PathRenderItem)item).Commands.Add(new PathCommands(PathCommandType.End)); break; case eMarkerStyle.Diamond: item = new PathRenderItem(sc.ChartArea.Rectangle.Bounds); - cmd = new PathCommands(PathCommandType.Move, new double[] { (xPath - halfX), yPath, xPath, yPath + halfY, xPath + halfX, yPath, xPath, yPath - halfY }); + cmd = new PathCommands(PathCommandType.Move, new double[] { (xPath - halfSize), yPath, xPath, yPath + halfSize, xPath + halfSize, yPath, xPath, yPath - halfSize }); ((PathRenderItem)item).Commands.Add(cmd); ((PathRenderItem)item).Commands.Add(new PathCommands(PathCommandType.End)); break; @@ -91,28 +91,28 @@ internal static RenderItem GetMarkerItem(ChartRenderer sc, ExcelLineChartSerie l var pathItem = new PathRenderItem(sc.Bounds); if (m.Style == eMarkerStyle.Star) { - pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath - halfX, yPath - halfY, xPath + halfX, yPath + halfY })); + pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath - halfSize, yPath - halfSize, xPath + halfSize, yPath + halfSize })); - pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath, yPath + halfY, xPath, yPath - halfY })); + pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath, yPath + halfSize, xPath, yPath - halfSize })); - pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath + halfX, yPath - halfY, xPath - halfX, yPath + halfY })); + pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath + halfSize, yPath - halfSize, xPath - halfSize, yPath + halfSize })); pathItem.Commands.Add(new PathCommands(PathCommandType.End)); } else if (m.Style == eMarkerStyle.X) { - pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath - halfX, yPath - halfY, xPath + halfX, yPath + halfY })); + pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath - halfSize, yPath - halfSize, xPath + halfSize, yPath + halfSize })); pathItem.Commands.Add(new PathCommands(PathCommandType.End)); - pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath - halfX, yPath + halfY, xPath + halfX, yPath - halfY })); + pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath - halfSize, yPath + halfSize, xPath + halfSize, yPath - halfSize })); pathItem.Commands.Add(new PathCommands(PathCommandType.End)); } else { - pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath, yPath - halfY, xPath, yPath + halfY })); + pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath, yPath - halfSize, xPath, yPath + halfSize })); pathItem.Commands.Add(new PathCommands(PathCommandType.End)); - pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath - halfX, yPath, xPath + halfX, yPath })); + pathItem.Commands.Add(new PathCommands(PathCommandType.Move, new double[] { xPath - halfSize, yPath, xPath + halfSize, yPath })); pathItem.Commands.Add(new PathCommands(PathCommandType.End)); } item = pathItem; From 3d451eb2bc1e331d77cc65fb1a8352610ae6baae Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:45:13 +0200 Subject: [PATCH 105/118] Fixed failing tests for Archivo Narrow --- .../SvgStandAloneTests.cs | 14 ++++++------- .../FontResolver/DefaultFontResolverTests.cs | 16 +++++++++++++++ .../FontResolver/DefaultFontResolver.cs | 20 +++++++++++++++---- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index 6bdb9e74b8..a6b824b6de 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -170,7 +170,7 @@ public void SvgTextBodyTestCenterAlignmentGenerated() Assert.AreEqual(59.509033203125d, textBody.Paragraphs[0].Runs[2].Bounds.Left, delta); Assert.AreEqual(0d, textBody.Paragraphs[0].Runs[3].Bounds.Left); - Assert.AreEqual(4.3760001659393311d, textBody.Paragraphs[1].Runs[0].Bounds.Left, delta); + Assert.AreEqual(5.08, textBody.Paragraphs[1].Runs[0].Bounds.Left, delta); Assert.AreEqual(0d, textBody.Paragraphs[1].Runs[1].Bounds.Left); //Assert that the second paragraph has been moved correctly @@ -204,7 +204,7 @@ public void SvgTextBodyTestRightAlignmentGenerated() Assert.AreEqual(69.39990234375d, textBody.Paragraphs[0].Runs[2].Bounds.Left, delta); Assert.AreEqual(0d, textBody.Paragraphs[0].Runs[3].Bounds.Left); - Assert.AreEqual(8.7520003318786621d, textBody.Paragraphs[1].Runs[0].Bounds.Left, delta); + Assert.AreEqual(10.16d, textBody.Paragraphs[1].Runs[0].Bounds.Left, delta); Assert.AreEqual(0d, textBody.Paragraphs[1].Runs[1].Bounds.Left); GenerateSvgFile("textBodyAlignRight", baseGroup.Bounds, baseGroup); @@ -292,8 +292,8 @@ public void BasicTextBox() double delta = 0.001; - Assert.AreEqual(107.95200681686401d, textbox.Width, delta); - Assert.AreEqual(34.979735851287842d, textbox.Height, delta); + Assert.AreEqual(115.2d, textbox.Width, delta, $"textbox.Width was {textbox.Width}, not 107.952 as expected"); + Assert.AreEqual(34.979735851287842d, textbox.Height, delta, $"textbox.Height was {textbox.Height}, not 34.978 as expected"); GenerateSvgFile("BasicTextBox", group.Bounds, group); } @@ -319,7 +319,7 @@ public void TextBoxWithMargins() Assert.AreEqual(10d, textbox.TextBody.Bounds.Position.Y); //Assert width and height changed by margins - Assert.AreEqual(117.95200681686401d, textbox.Width, delta); + Assert.AreEqual(125.2, textbox.Width, delta); Assert.AreEqual(44.979735851287842d, textbox.Height, delta); @@ -341,7 +341,7 @@ public void TextBoxWithAllMargins() textbox.AppendRenderItems(group.RenderItems); //Assert width and height changed by margins - Assert.AreEqual(127.95200681686401d, textbox.Width, delta); + Assert.AreEqual(135.2d, textbox.Width, delta); Assert.AreEqual(54.979735851287842d, textbox.Height, delta); GenerateSvgFile("AllMarginsTextBox", group.Bounds, group); @@ -374,7 +374,7 @@ public void TextBoxWithAllMarginsANDTextbodyChanged() textbox.AppendRenderItems(group.RenderItems); //Assert width and height changed by margins and textbody - Assert.AreEqual(142.952006816864d, textbox.Width, delta); + Assert.AreEqual(150.2d, textbox.Width, delta); Assert.AreEqual(69.979735851287842d, textbox.Height, delta); GenerateSvgFile("TextAnchor_TextBox", group.Bounds, group); diff --git a/src/EPPlus.Fonts.OpenType.Tests/FontResolver/DefaultFontResolverTests.cs b/src/EPPlus.Fonts.OpenType.Tests/FontResolver/DefaultFontResolverTests.cs index 3c57c2ce88..b568398623 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/FontResolver/DefaultFontResolverTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/FontResolver/DefaultFontResolverTests.cs @@ -218,6 +218,22 @@ public void ResolveFont_BuiltinChainExistsButNothingInstalled_FallsBackToArchivo #endregion + #region GetFontAvailability + + [TestMethod] + public void ResolveFont_ShouldResolveArchivoNarrowBold() + { + var scanner = new FakeFontScanner(); + var reader = new FakeFontFileReader(); + + var resolver = new DefaultFontResolver(scanner: scanner, fileReader: reader); + + var availability = resolver.GetFontAvailability("Archivo Narrow", FontSubFamily.Bold); + Assert.AreEqual(FontAvailability.Exact, availability); + } + + #endregion + #region Built-in chain integrity [TestMethod] diff --git a/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs b/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs index 3f9109c068..813b8bacad 100644 --- a/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs +++ b/src/EPPlus.Fonts.OpenType/FontResolver/DefaultFontResolver.cs @@ -56,6 +56,12 @@ public FontAvailability GetFontAvailability(string fontName, FontSubFamily subFa if (string.IsNullOrEmpty(fontName)) return FontAvailability.NotFound; + // special case for Archivo Narrow which is distributed as last-resort-font with EPPlus + if (string.Equals("archivo narrow", fontName, StringComparison.OrdinalIgnoreCase)) + { + return FontAvailability.Exact; + } + var face = _scanner.FindBestMatch( _fontDirectories, fontName, @@ -77,14 +83,20 @@ public FontAvailability GetFontAvailability(string fontName, FontSubFamily subFa public byte[] ResolveFont(string fontName, FontSubFamily subFamily) { - // 1. Try exact match first + // 1. special case for Archivo Narrow which is distributed as last-resort-font with EPPlus + if (string.Equals("archivo narrow", fontName, StringComparison.OrdinalIgnoreCase)) + { + return EmbeddedFonts.LoadArchivoNarrow(subFamily).RawData; + } + + // 2. Try exact match first var face = _scanner.FindBestMatch( _fontDirectories, fontName, subFamily, _searchSystemDirectories); if (face != null && face.IsExactMatch) return _fileReader.ReadFontBytes(face); - // 2. No exact match — try user-configured fallback chain + // 3. No exact match — try user-configured fallback chain if (_config != null) { var userFallbacks = _config.GetFallbacks(fontName); @@ -96,7 +108,7 @@ public byte[] ResolveFont(string fontName, FontSubFamily subFamily) } } - // 3. Try built-in fallback chain for known Office/system fonts. + // 4. Try built-in fallback chain for known Office/system fonts. // Runs after user config so user preferences win, but still provides a metric-aware // safety net for fonts the user hasn't configured. var builtinFallbacks = BuiltinFontFallbackChains.GetFallbacks(fontName); @@ -107,7 +119,7 @@ public byte[] ResolveFont(string fontName, FontSubFamily subFamily) return resolved; } - // 4. No match found — fall back to built-in Archivo Narrow. + // 5. No match found — fall back to built-in Archivo Narrow. // Only applies when using DefaultFontResolver (i.e. no custom resolver installed). return EmbeddedFonts.LoadArchivoNarrow(subFamily).RawData; } From 4e3ab792b76470047679f76d9a8fed635f784a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 24 Jun 2026 15:55:37 +0200 Subject: [PATCH 106/118] Fixed corrupting issues with previous fix --- .../StyleTests.cs | 51 ++++++++++++++- src/EPPlus/Drawing/Chart/ExcelChartTitle.cs | 65 +++++++++++++++++-- .../Style/Text/ExcelDrawingParagraph.cs | 27 ++++---- .../Text/ExcelDrawingParagraphCollection.cs | 25 ++++--- .../RichText/ExcelParagraphCollection.cs | 1 + 5 files changed, 140 insertions(+), 29 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs index 28e3783fe1..89b24c08fd 100644 --- a/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs @@ -37,7 +37,46 @@ public void BaseThemeChartStyle2() SaveTextFileToWorkbook($"svg\\baseThemeChartStyle2.svg", svg); } } + [TestMethod] + public void ChangeSeriesTest() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + + using (var p = OpenTemplatePackage("MyLineIonThemeExcel.xlsx")) + { + var wbStyles = p.Workbook.Styles; + var simpleChart = p.Workbook.Worksheets[0].Drawings[0].As.Chart.LineChart; + //simpleChart.StyleManager.ApplyStyles(); + + p.Workbook.Worksheets[0].Cells["A3"].Value = 15; + p.Workbook.Worksheets[0].Cells["B3"].Value = 2; + p.Workbook.Worksheets[0].Cells["C3"].Value = 25; + + var range = p.Workbook.Worksheets[0].Cells["A3:C3"]; + + var series = simpleChart.Series.Add(range); + series.Header = "MySeries"; + + simpleChart.Title.Text = "Hello"; + + //simpleChart.StyleManager.ApplyStyles(); + //var chartDefaultStyle = simpleChart.StyleManager.Style; + + //simpleChart.StyleManager.Style.Title.FontReference.Color.SetPresetColor(Color.CornflowerBlue); + + ////Highest order of styling if datapoint does not exist + ////simpleChart.StyleManager.Style.DeleteAllNode("cs:dataPointLine"); + //simpleChart.StyleManager.Style.DataPointLine.BorderReference.Color.SetPresetColor(Color.Green); + + //simpleChart.StyleManager.ApplyStyles(); + + var svg = simpleChart.ToSvg(); + SaveTextFileToWorkbook($"svg\\ChangeSeriesStyleTest.svg", svg); + p.SaveAs(GetOutputFile("", "ChangeSeriesStyleTest.xlsx")); + //SaveAndCleanup(p); + } + } [TestMethod] public void ExtractThemeStyleWorks() @@ -49,6 +88,15 @@ public void ExtractThemeStyleWorks() var wbStyles = p.Workbook.Styles; var simpleChart = p.Workbook.Worksheets[0].Drawings[0].As.Chart.LineChart; + //p.Workbook.Worksheets[0].Cells["A3"].Value = 15; + //p.Workbook.Worksheets[0].Cells["B3"].Value = 2; + //p.Workbook.Worksheets[0].Cells["C3"].Value = 25; + + //var range = p.Workbook.Worksheets[0].Cells["A3:C3"]; + + //var series = simpleChart.Series.Add(range); + //series.Header = "MySeries"; + simpleChart.Title.Text = "Hello"; //p.Workbook.ThemeManager.CurrentTheme.ColorScheme.Accent1.SetPresetColor(Color.DarkGoldenrod); @@ -68,11 +116,12 @@ public void ExtractThemeStyleWorks() //context.SetTranslator(fillTranslator); //context.AddDeclarations(styleClass); + //simpleChart.StyleManager.ApplyStyles(); //chartDefaultStyle.DataPointLine.FillReference.Color //simpleChart.Title.Font.Color = Color.CornflowerBlue; - //simpleChart.StyleManager.Style.Title.FontReference.Color.SetPresetColor(Color.CornflowerBlue); + simpleChart.StyleManager.Style.Title.FontReference.Color.SetPresetColor(Color.CornflowerBlue); //Highest order of styling if datapoint does not exist //simpleChart.StyleManager.Style.DeleteAllNode("cs:dataPointLine"); diff --git a/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs index 055df57ece..abc58781fa 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs @@ -151,7 +151,9 @@ public ExcelTextBody TextBody var defBody = DefaultTextBody; var firstDefaultRunProperties = DefaultTextBody.Paragraphs.CreateOrGetDefaultRunProperties($"{_defTxBodyPath}/a:p/a:pPr/a:defRPr", TopNode); _textBody = new ExcelTextBody(_chart, NameSpaceManager, TopNode, $"{_richTextPath}/a:bodyPr", SchemaNodeOrder); - _textBody.Paragraphs.FirstDefaultRunProperties = firstDefaultRunProperties; + + //Set property without updating xml + _textBody.Paragraphs.FirstDefaultRunProperties = DefaultTextBody.Paragraphs.FirstDefaultRunProperties; } return _textBody; } @@ -248,6 +250,21 @@ internal void CreateRichText() defFont = Convert.ToSingle(stylePart.DefaultTextRun.Size); } var tb = TextBody; + + //var rtNode = GetNode($"{_richTextPath}"); + //if(rtNode != null) + //{ + // //parent node has been created but it is possible it is without a paragraph node + // //If e.g. a file is read in and ApplyStyles is done before anything else + // var paragraphNode = GetNode($"{_richTextPath}/a:p"); + // if (paragraphNode == null) + // { + // var runProps = tb.Paragraphs.CreateOrGetDefaultRunProperties($"{_richTextPath}/a:p/a:pPr/a:defRPr", TopNode); + // //var newParagraphNode = GetNode($"{_richTextPath}/a:p"); + // tb.Paragraphs.SetPlaceHolderNode(runProps.PathElement.ParentNode.ParentNode); + // } + //} + _richText = new ExcelParagraphCollection(tb, _chart, NameSpaceManager, TopNode, $"{_richTextPath}/a:p", SchemaNodeOrder, defFont, eTextAlignment.Center); //if(tb.Paragraphs.Count == 0) @@ -394,6 +411,29 @@ void IStyleMandatoryProperties.SetMandatoryProperties() //Textbody cannot exist without a paragraph node if(TextBody.Paragraphs.Count == 0) { + + var rtNode = GetNode($"{_richTextPath}"); + if (rtNode != null) + { + //parent node has been created but it is possible it is without a paragraph node + //If e.g. a file is read in and ApplyStyles is done before anything else + var paragraphNode = GetNode($"{_richTextPath}/a:p"); + if (paragraphNode == null) + { + var runProps = TextBody.Paragraphs.CreateOrGetDefaultRunProperties($"{_richTextPath}/a:p/a:pPr/a:defRPr", TopNode); + var newParagraphNode = GetNode($"{_richTextPath}/a:p"); + + //This should already have been done but do anyway for safety + TextBody.Paragraphs.FirstDefaultRunProperties = DefaultTextBody.Paragraphs.FirstDefaultRunProperties; + + //The above sets it correctly but it does not update the xml. Update xml manually since we are about to set a text value + var defParaProperties = GetNode($"{_defTxBodyPath}/a:p/a:pPr"); + var newNode = GetNode($"{_richTextPath}/a:p/a:pPr"); + CopyElement((XmlElement)defParaProperties, (XmlElement)newNode); + + TextBody.Paragraphs.SetPlaceHolderNode(newNode.ParentNode); + } + } TextBody.Paragraphs.Add("Title"); } @@ -442,6 +482,20 @@ public override string Text CreateRichText(); } var applyStyle = (RichText.Count == 0); + if(applyStyle) + { + var defaultRunPropertiesRich = TextBody.Paragraphs.CreateOrGetDefaultRunProperties($"{_richTextPath}/a:p/a:pPr/a:defRPr", TopNode); + + //This should already have been done but do anyway for safety + TextBody.Paragraphs.FirstDefaultRunProperties = DefaultTextBody.Paragraphs.FirstDefaultRunProperties; + + //The above sets it correctly but it does not update the xml. Update xml manually since we are about to set a text value + var defParaProperties = GetNode($"{_defTxBodyPath}/a:p/a:pPr"); + var newNode = GetNode($"{_richTextPath}/a:p/a:pPr"); + CopyElement((XmlElement)defParaProperties, (XmlElement)newNode); + + TextBody.Paragraphs.SetPlaceHolderNode(newNode.ParentNode); + } RichText.Text = value; _font = null; if (applyStyle) @@ -450,11 +504,10 @@ public override string Text defRprOld.InnerXml = ""; _chart.ApplyStyleOnPart(this, _chart.StyleManager?.Style?.Title, true); - //Apply style on part does not update default path element yet replaces it - //Copy the new element to the old. - var defRpr = GetNode($"{_defTxBodyPath}/a:p/a:pPr/a:defRPr"); - var textSettingsDefRPr = GetNode($"{_richTextPath}/a:p/a:pPr/a:defRPr"); - CopyElement((XmlElement)defRpr, (XmlElement)textSettingsDefRPr); + //re-apply default values + var defParaProperties = GetNode($"{_defTxBodyPath}/a:p/a:pPr"); + var newNode = GetNode($"{_richTextPath}/a:p/a:pPr"); + CopyElement((XmlElement)defParaProperties, (XmlElement)newNode); } } } diff --git a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs index f482329dcd..a29a112819 100644 --- a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs +++ b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs @@ -108,21 +108,22 @@ internal ExcelDrawingParagraph(ExcelDrawingParagraphCollection paragraphs, IPict } else { - //The node must still be created - var xmlFirstDefault = ((ExcelTextFontXml)paragraphs.FirstDefaultRunProperties).XmlHelper.TopNode.ParentNode; - XmlNode paragraphProperties = topNode.SelectSingleNode("a:pPr", NameSpaceManager); + //if(topNode.ParentNode != _paragraphs.FirstDefaultRunProperties) + ////The node must still be created + //var xmlFirstDefault = ((ExcelTextFontXml)paragraphs.FirstDefaultRunProperties).XmlHelper.TopNode.ParentNode; + //XmlNode paragraphProperties = topNode.SelectSingleNode("a:pPr", NameSpaceManager); - //Create paragraph properties if it does not already exist - if (paragraphProperties == null) - { - paragraphProperties = CreateNode(topNode, "a:pPr", true); - } - //Create defRPr - var textFont = new ExcelTextFontXml(prd, nameSpaceManager, topNode, "a:pPr/a:defRPr", schemaNodeOrder, initXml); + ////Create paragraph properties if it does not already exist + //if (paragraphProperties == null) + //{ + // paragraphProperties = CreateNode(topNode, "a:pPr", true); + //} + ////Create defRPr + //var textFont = new ExcelTextFontXml(prd, nameSpaceManager, topNode, "a:pPr/a:defRPr", schemaNodeOrder, initXml); - //Copy the first element and apply it to the paragraphProperties - CopyElement((XmlElement)xmlFirstDefault, (XmlElement)paragraphProperties); - DefaultRunProperties = textFont; + ////Copy the first element and apply it to the paragraphProperties + //CopyElement((XmlElement)xmlFirstDefault, (XmlElement)paragraphProperties); + //DefaultRunProperties = textFont; } } else if (legacyDefaultRunPropertySetting && _paragraphs.FirstDefaultRunProperties != null) diff --git a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraphCollection.cs b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraphCollection.cs index 6348c8bfd2..09241b4506 100644 --- a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraphCollection.cs +++ b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraphCollection.cs @@ -78,7 +78,7 @@ public ExcelDrawingParagraph this[int index] /// public ExcelDrawingParagraph Add(string text) { - XmlNode pn = placeHolderNode; + XmlNode pn = PlaceHolderNode; if (_paragraphs.Count == 0 && pn == null) { CreateTopNode(); @@ -96,9 +96,9 @@ public ExcelDrawingParagraph Add(string text) _paragraphs.Add(p); //_addCallback?.Invoke(tr); - if (placeHolderNode != null) + if (PlaceHolderNode != null) { - placeHolderNode = null; + PlaceHolderNode = null; } return p; } @@ -124,25 +124,32 @@ internal ExcelTextFont CreateOrGetDefaultRunProperties(string fontPath, XmlNode return FirstDefaultRunProperties; } - XmlNode placeHolderNode = null; + XmlNode PlaceHolderNode = null; internal void CreateParagraphPlaceHolder() { - if(placeHolderNode == null && _paragraphs.Count == 0) + if(PlaceHolderNode == null && _paragraphs.Count == 0) { var pn = CreateNode("a:p", false, true); - placeHolderNode = pn; + PlaceHolderNode = pn; } } internal XmlNode CreateAndReturnParagraphPlaceHolder() { - if(placeHolderNode == null) + if(PlaceHolderNode == null) { CreateParagraphPlaceHolder(); } - return placeHolderNode; + return PlaceHolderNode; } + + internal void SetPlaceHolderNode(XmlNode node) + { + PlaceHolderNode = node; + } + + /// /// Removes the item at the index from the collection /// @@ -242,7 +249,7 @@ public bool Contains(ExcelDrawingParagraph item) /// protected internal void CreateTopNode() { - if (_paragraphs.Count == 0 && placeHolderNode == null) + if (_paragraphs.Count == 0 && PlaceHolderNode == null) { if(GetNode(_path) == null) { diff --git a/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs b/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs index 0face72036..2b6bb55d34 100644 --- a/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs +++ b/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs @@ -213,6 +213,7 @@ public string Text { if (_textBody.Paragraphs.Count == 0) { + //_textBody.Paragraphs.Add(value); Add(value, true); } else From 62971c5cb52f12c6f9167b2ce7d1d3794972e960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 24 Jun 2026 16:03:29 +0200 Subject: [PATCH 107/118] Turned off debug points for dlbl --- .../Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs index f1bcda64f5..2222af6c02 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/SvgDataLabelPoint.cs @@ -353,7 +353,7 @@ internal void SetShapeDimensions(Transform basePoint, Transform endPoint) //And endVector is the top center position. //--- Visualize positions for debugging purposes - CreateDebugPoints(basePoint, endPoint, centerPoint); + //CreateDebugPoints(basePoint, endPoint, centerPoint); //--- switch (_labelPosition) From 04447fb027de7a34c8bf779e57bcefdca3ca4442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Wed, 24 Jun 2026 16:36:57 +0200 Subject: [PATCH 108/118] Fixed axis and trendlines issues --- .../Chart/LineChartToSvgTests.cs | 4 ++-- .../Drawing/Renderer/Chart/ChartAxisRenderer.cs | 2 +- .../Renderer/Chart/ChartPlotareaRenderer.cs | 2 +- .../ChartTypeDrawers/BarColumnChartTypeDrawer.cs | 16 ++++++++++++---- .../Trendlines/ChartTrendlineRenderer.cs | 11 ++++++----- .../Drawing/Renderer/Chart/LineMarkerHelper.cs | 8 ++++---- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 6 +++--- .../RenderItems/DrawingRenderItemExtentions.cs | 12 ++++++------ .../Fill/DrawingRenderGradientFill.cs | 8 ++------ 9 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index b8eca81ef0..d54afa7448 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs @@ -19,12 +19,12 @@ public void GenerateSvgForLineCharts_sheet1() { var ws = p.Workbook.Worksheets[0]; - var ix = 5; + var ix = 6; var c = ws.Drawings[ix]; var svg = c.ToSvg(); SaveTextFileToWorkbook($"svg\\ChartForSvg_ind{ix++}.svg", svg); - //for(int i = 0; i< ws.Drawings.Count; i++) + //for (int i = 0; i < ws.Drawings.Count; i++) //{ // var c = ws.Drawings[i]; // var svg = c.ToSvg(); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 434d9a25eb..2bbb9fafb4 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs @@ -429,7 +429,7 @@ private List GetAxisValueTextBoxes() tb.ImportParagraph(p, 0, v); //tb.TextBody.Paragraphs[0].AddText(v, Axis.Font); - tb.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, Axis.Fill, axisStyle?.FillReference.Color); + tb.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, Axis.Fill, axisStyle?.FillReference.Color, true); if(widest < tb.Width) { diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs index 5903e8ed14..b9c0e1f9c5 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs @@ -59,7 +59,7 @@ internal void SetPlotAreaRectangle() ChartRenderer.Legend.Rectangle.Top = Group.Top + rect.Height / 2 - ChartRenderer.Legend.Rectangle.Height / 2; } - rect.SetDrawingPropertiesFill(ChartRenderer.Theme, pa.Fill, ChartRenderer.Chart.StyleManager.Style?.PlotArea.FillReference.Color, ChartRenderer.Theme.ColorScheme.Light1); + rect.SetDrawingPropertiesFill(ChartRenderer.Theme, pa.Fill, ChartRenderer.Chart.StyleManager.Style?.PlotArea.FillReference.Color, false, ChartRenderer.Theme.ColorScheme.Light1); rect.SetDrawingPropertiesBorder(ChartRenderer.Theme, pa.Border, ChartRenderer.Chart.StyleManager.Style?.PlotArea.BorderReference.Color, pa.Border.Fill.Style != eFillStyle.NoFill, 0.75); Rectangle = rect; } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs index 84ea7b5496..50b96224eb 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -173,8 +173,16 @@ private void AddBar(ExcelBarChart chartType, ExcelBarChartSerie serie, List286 287 " var gapWidth = chartType.GapWidth == int.MinValue ? 150 : chartType.GapWidth; @@ -316,8 +324,8 @@ private void AddBar(ExcelBarChart chartType, ExcelBarChartSerie serie, List(); double sum = 0; for (int i=0;i < _ySerie.Length;i++) { sum += _ySerie[i]; - if (i >= _trendline.Period-1) + if (i >= period - 1) { - _ma.Add(sum / (i+1)); + _ma.Add(sum / period); + sum -= _ySerie[i - period + 1]; } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs b/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs index ac92427d2b..42949125f1 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs @@ -123,15 +123,15 @@ internal static RenderItem GetMarkerItem(ChartRenderer sc, ExcelLineChartSerie l } if (ls.Marker.Fill.IsEmpty == false) { - item?.SetDrawingPropertiesFill(sc.Theme, ls.Marker.Fill, sc.Chart.StyleManager.Style.DataPointMarker.FillReference.Color); + item?.SetDrawingPropertiesFill(sc.Theme, ls.Marker.Fill, sc.Chart.StyleManager.Style.DataPointMarker.FillReference.Color, false); } else if (ls.Fill.IsEmpty) { - item?.SetDrawingPropertiesFillBasic(sc.Theme, ls.Border.Fill, sc.Chart.StyleManager.Style?.DataPointMarker.FillReference.Color, sc.Theme.ColorScheme.Accent1); + item?.SetDrawingPropertiesFillBasic(sc.Theme, ls.Border.Fill, sc.Chart.StyleManager.Style?.DataPointMarker.FillReference.Color, false, sc.Theme.ColorScheme.Accent1); } else { - item?.SetDrawingPropertiesFill(sc.Theme, ls.Fill, sc.Chart.StyleManager.Style.DataPointMarker.FillReference.Color); + item?.SetDrawingPropertiesFill(sc.Theme, ls.Fill, sc.Chart.StyleManager.Style.DataPointMarker.FillReference.Color, false); } if (ls.Marker.Border.Width > 0) @@ -160,7 +160,7 @@ internal static RenderItem GetMarkerBackground(ChartRenderer sc, ExcelLineChartS Width = size, Height = size }; - item?.SetDrawingPropertiesFill(sc.Theme, ls.Marker.Fill, sc.Chart.StyleManager.Style.DataPointMarker.FillReference.Color); + item?.SetDrawingPropertiesFill(sc.Theme, ls.Marker.Fill, sc.Chart.StyleManager.Style.DataPointMarker.FillReference.Color, false); return item; } diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index 378b8d339e..49222e3809 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -306,7 +306,7 @@ private void SetChartArea() var item = new ChartAreaRenderer(this); item.Rectangle.Width = Bounds.Width; item.Rectangle.Height = Bounds.Height; - item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style?.ChartArea.FillReference.Color, Theme.ColorScheme.Light1); + item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style?.ChartArea.FillReference.Color, true, Theme.ColorScheme.Light1); item.Rectangle.SetDrawingPropertiesBorder(Theme, Chart.Border, Chart.StyleManager.Style?.ChartArea.BorderReference.Color, Chart.Border.Width > 0); item.AppendRenderItems(RenderItems); item.SetMargins(Chart.TextBody); @@ -398,8 +398,8 @@ internal LineRenderItem GetSeriesIcon(ExcelChartStandardSerie s, int index, Boun const float LineLength = 21; var item = new LineRenderItem(parentItem); - item.SetDrawingPropertiesFill(Theme, s.Fill, Chart.StyleManager.Style.SeriesLine.FillReference.Color); - item.SetDrawingPropertiesBorder(Theme, s.Border, Chart.StyleManager.Style.SeriesLine.BorderReference.Color, s.Border.Fill.Style != eFillStyle.NoFill, 0.75); + item.SetDrawingPropertiesFill(Theme, s.Fill, Chart.StyleManager.Style.SeriesLine.FillReference.Color, false); + item.SetDrawingPropertiesBorder(Theme, s.Border, Chart.StyleManager.Style.SeriesLine.BorderReference.Color, s.Border.Fill.Style != eFillStyle.NoFill, 0.75, false); float y = (float)parentItem.Top + MarginExtra; float x = 0; diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs index 7a5099505c..34e4e7915b 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs @@ -31,7 +31,7 @@ internal enum SvgFillType } internal static class DrawingRenderItemExtentions { - internal static void SetDrawingPropertiesFill(this RenderItem item, ExcelTheme theme, ExcelDrawingFill fill, ExcelDrawingColorManager color, ExcelDrawingThemeColorManager nullColor=null) + internal static void SetDrawingPropertiesFill(this RenderItem item, ExcelTheme theme, ExcelDrawingFill fill, ExcelDrawingColorManager color, bool gradientUserSpace=false, ExcelDrawingThemeColorManager nullColor=null) { switch (fill.Style) { @@ -43,11 +43,11 @@ internal static void SetDrawingPropertiesFill(this RenderItem item, ExcelTheme t item.BlipFill = new DrawingRenderBlipFill(fill.BlipFill); break; default: - SetDrawingPropertiesFillBasic(item, theme, fill, color, nullColor ?? theme.ColorScheme.Accent1); + SetDrawingPropertiesFillBasic(item, theme, fill, color, gradientUserSpace, nullColor ?? theme.ColorScheme.Accent1); break; } } - internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTheme theme, ExcelDrawingFillBasic fill, ExcelDrawingColorManager color, ExcelDrawingThemeColorManager nullColor) + internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTheme theme, ExcelDrawingFillBasic fill, ExcelDrawingColorManager color, bool gradientUserSpaceOnUse, ExcelDrawingThemeColorManager nullColor) { double? opacity = null; switch (fill.Style) @@ -66,7 +66,7 @@ internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTh item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity); break; case eFillStyle.GradientFill: - item.GradientFill = new DrawingRenderGradientFill(theme, fill.GradientFill); + item.GradientFill = new DrawingRenderGradientFill(theme, fill.GradientFill, gradientUserSpaceOnUse); item.FillColor = null; break; } @@ -75,7 +75,7 @@ internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTh item.FillOpacity = opacity; } } - internal static void SetDrawingPropertiesBorder(this RenderItem item, ExcelTheme theme, ExcelDrawingBorder border, ExcelChartStyleColorManager color, bool hasBorder, double defaultWidth = 1.5) + internal static void SetDrawingPropertiesBorder(this RenderItem item, ExcelTheme theme, ExcelDrawingBorder border, ExcelChartStyleColorManager color, bool hasBorder, double defaultWidth = 1.5, bool grandientUserSpaceOnUse=true) { double? opacity = null; if (border == null) @@ -104,7 +104,7 @@ internal static void SetDrawingPropertiesBorder(this RenderItem item, ExcelTheme item.BorderGradientFill = null; break; case eFillStyle.GradientFill: - item.BorderGradientFill = new RenderGradientFill(); + item.BorderGradientFill = new DrawingRenderGradientFill(theme, border.Fill.GradientFill, grandientUserSpaceOnUse); item.BorderColor = null; break; } diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs index c1ef784bf0..b044225512 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs @@ -23,7 +23,7 @@ namespace EPPlus.DrawingRenderer.RenderItems { internal class DrawingRenderGradientFill : RenderGradientFill { - public DrawingRenderGradientFill(ExcelTheme theme, ExcelDrawingGradientFill gradientFill) : base() + public DrawingRenderGradientFill(ExcelTheme theme, ExcelDrawingGradientFill gradientFill, bool userSpaceOnUse) : base() { //this.Settings = gradientFill; for (int i = 0; i < gradientFill.Colors.Count; i++) @@ -50,12 +50,8 @@ public DrawingRenderGradientFill(ExcelTheme theme, ExcelDrawingGradientFill grad { LinearSettings.Angle = gradientFill.LinearSettings.Angle; LinearSettings.Scaled = gradientFill.LinearSettings.Scaled; - UserSpaceOnUse = false; - } - else - { - UserSpaceOnUse = true; } + UserSpaceOnUse = userSpaceOnUse; } } } \ No newline at end of file From 0543a6fd44ea7fc74a0855a3c4018ed29abcc64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Wed, 24 Jun 2026 17:00:28 +0200 Subject: [PATCH 109/118] Differing shape and chart fill --- .../Drawing/Renderer/Chart/Axis/DateAxisScaleCalculator.cs | 2 -- .../Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/Chart/Axis/DateAxisScaleCalculator.cs b/src/EPPlus/Drawing/Renderer/Chart/Axis/DateAxisScaleCalculator.cs index 962f87074e..41568ac8f8 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/Axis/DateAxisScaleCalculator.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/Axis/DateAxisScaleCalculator.cs @@ -14,8 +14,6 @@ namespace EPPlus.Export.ImageRenderer.Svg.Chart.Util { internal class DateAxisScaleCalculator { - - /// /// Calculate automatic axis settings for date data /// diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs index 7a5099505c..ff6dfb2975 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs @@ -55,7 +55,7 @@ internal static void SetDrawingPropertiesFillBasic(this RenderItem item, ExcelTh case eFillStyle.NoFill: if (fill.IsEmpty) //Do NOT remove. This if is required for Shapes { - item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity, nullColor); + item.FillColor = GetFillColor(theme, fill, color, item.FillColorSource, out opacity, null); } else { From 6b7b1341b6153b9e223bb894b19e0a7084e37516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Thu, 25 Jun 2026 16:21:01 +0200 Subject: [PATCH 110/118] Attempt att resolving style hierarchy correctly --- .../Shape/ShapeToSvgTests.cs | 59 +++++++++++++++++++ .../Renderer/Chart/ChartDrawingObject.cs | 2 + .../Renderer/Chart/ChartLegendRenderer.cs | 2 +- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 3 +- .../DrawingRenderItemExtentions.cs | 41 ++++++++++--- .../Export/HtmlExport/SvgShapeExportTests.cs | 2 +- 6 files changed, 99 insertions(+), 10 deletions(-) diff --git a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs index 76f3640038..4a2dc0dd08 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs @@ -15,6 +15,65 @@ namespace EPPlus.Export.ImageRenderer.Tests.Shape [TestClass] public sealed class ShapeToSvgTests : TestBase { + + [TestMethod] + public void GroupFill() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("GroupFill.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + var drawings = ws.Drawings; + + int ix = 0; + + foreach (var drawing in drawings) + { + if(drawing is ExcelGroupShape) + { + var gShape = (ExcelGroupShape)drawing; + var gDrawings = gShape.Drawings; + foreach(var gDrawing in gDrawings) + { + SaveTextFileToWorkbook($"svg\\GroupFill{ix++}.svg", gDrawing.ToSvg()); + } + } + else + { + var shapeCast = drawing.As.Shape; + var filltype = shapeCast.Fill.Style; + + var svg = drawing.ToSvg(); + SaveTextFileToWorkbook($"svg\\GroupFill{ix++}.svg", svg); + } + } + SaveAndCleanup(p); + } + } + + [TestMethod] + public void SeveralFills() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("ShapeWithFillNoFillAndDefault.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + var drawings = ws.Drawings; + + int ix = 0; + + foreach (var drawing in drawings) + { + var shapeCast = drawing.As.Shape; + var filltype = shapeCast.Fill.Style; + + var svg = drawing.ToSvg(); + SaveTextFileToWorkbook($"svg\\manyFills{ix++}.svg", svg); + } + SaveAndCleanup(p); + } + } + [TestMethod] public void Rect() { diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs index bf5cec5c4c..1d6c705910 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs @@ -111,5 +111,7 @@ internal List GetXSerie(List xSerie) } return l; } + + } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs index 3cde9d7348..7d53028a61 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -84,7 +84,7 @@ internal ChartLegendRenderer(ChartRenderer sc, bool isDataLabelLegend = false) : //Bounds.Height = Rectangle.Height; //Rectangle.Bounds.Left = Rectangle.Bounds.Top = 0; - Rectangle.SetDrawingPropertiesFill(sc.Theme, l.Fill, sc.Chart.StyleManager.Style?.Title.FillReference.Color); + Rectangle.SetDrawingPropertiesFill(sc.Theme, l.Fill, sc.Chart.StyleManager.Style?.Title.FillReference.Color, false); Rectangle.SetDrawingPropertiesBorder(sc.Theme, l.Border, sc.Chart.StyleManager.Style?.Title.BorderReference.Color, l.Border.Fill.Style != eFillStyle.NoFill, 0.75); var pSls = SetLegendSeries(entryWidth, entryHeight); diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index 49222e3809..00c429636e 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -306,7 +306,8 @@ private void SetChartArea() var item = new ChartAreaRenderer(this); item.Rectangle.Width = Bounds.Width; item.Rectangle.Height = Bounds.Height; - item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style?.ChartArea.FillReference.Color, true, Theme.ColorScheme.Light1); + + item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style?.ChartArea.FillReference.Color, true); item.Rectangle.SetDrawingPropertiesBorder(Theme, Chart.Border, Chart.StyleManager.Style?.ChartArea.BorderReference.Color, Chart.Border.Width > 0); item.AppendRenderItems(RenderItems); item.SetMargins(Chart.TextBody); diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs index de59330d84..6ba42c04f1 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs @@ -31,7 +31,7 @@ internal enum SvgFillType } internal static class DrawingRenderItemExtentions { - internal static void SetDrawingPropertiesFill(this RenderItem item, ExcelTheme theme, ExcelDrawingFill fill, ExcelDrawingColorManager color, bool gradientUserSpace=false, ExcelDrawingThemeColorManager nullColor=null) + internal static void SetDrawingPropertiesFill(this RenderItem item, ExcelTheme theme, ExcelDrawingFill fill, ExcelDrawingColorManager color, bool gradientUserSpace = false, ExcelDrawingThemeColorManager nullColor = null) { switch (fill.Style) { @@ -43,7 +43,7 @@ internal static void SetDrawingPropertiesFill(this RenderItem item, ExcelTheme t item.BlipFill = new DrawingRenderBlipFill(fill.BlipFill); break; default: - SetDrawingPropertiesFillBasic(item, theme, fill, color, gradientUserSpace, nullColor ?? theme.ColorScheme.Accent1); + SetDrawingPropertiesFillBasic(item, theme, fill, color, gradientUserSpace, nullColor); break; } } @@ -185,15 +185,42 @@ private static string GetFillColor(ExcelTheme theme, ExcelDrawingFillBasic fill, } Color fc; + fc = tc.ColorConverter.GetThemeColor(nullColor ?? theme.ColorScheme.Accent1); if (fill == null || fill.Style == eFillStyle.NoFill) { if (styleFillColor == null) { - fc = tc.ColorConverter.GetThemeColor(nullColor ?? theme.ColorScheme.Accent1); - } - else - { - fc = tc.ColorConverter.GetThemeColor(theme, styleFillColor); + //There is no Style-Specified color. Themed Fill should be applied if it exists + //Fallback to theme + if (theme.FormatScheme.BackgroundFillStyle != null) + { + //Usually, at least for chart objects if the theme fill is not NoFill it is Subtle + var subtleBg = theme.FormatScheme.BackgroundFillStyle[0]; + if (subtleBg.IsEmpty == false) + { + if (subtleBg.Style == eFillStyle.SolidFill) + { + if (subtleBg.SolidFill.Color.ColorType == eDrawingColorType.Scheme) + { + //The theme color is PhClr which is fallback color to style. + //Style does not exist. But The base theme schemecolor does. + //Hardcoded defaults to solid fill according to docs is Bg1 + //Specifically SolidFill has a fallback to bg1 + + var bg1 = theme.ColorScheme.GetColorByEnum(eSchemeColor.Background1); + fc = tc.ColorConverter.GetThemeColor(bg1); + } + } + else + { + //alternatively accent 1 + fc = subtleBg.Color; + } + //var style = subtleBg.Style; + //subtleBg.LoadFill(); + //subtleBg.Color; + } + } } } else if (fill.Style == eFillStyle.SolidFill) diff --git a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs index d65926a4fa..bf977070cc 100644 --- a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs @@ -75,7 +75,7 @@ public void ExportBarChart() var chart = ws.Drawings.AddBarChart("myColChart", OfficeOpenXml.Drawing.Chart.eBarChartType.ColumnClustered); chart.Series.Add(ws.Cells["A1:A8"]); - chart.StyleManager.SetChartStyle(ePresetChartStyleMultiSeries.BarChartStyle1); + //chart.StyleManager.SetChartStyle(ePresetChartStyleMultiSeries.BarChartStyle1); //chart.Style = OfficeOpenXml.Drawing.Chart.eChartStyle.Style1; //var theme = ws.Workbook.ThemeManager.GetOrCreateTheme(); From fd35946e91589eb18d28a817a44f189ee4ac9e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Mon, 29 Jun 2026 10:22:44 +0200 Subject: [PATCH 111/118] Added new option class to the ToSvg method --- .../Chart/BarChartTests.cs | 2 +- .../SvgStandAloneTests.cs | 7 +- src/EPPlus.DrawingRenderer/Svg/IRender.cs | 13 +++- .../Svg/Options/SvgRenderOptions.cs | 23 ++++++ .../Svg/Options/SvgSize.cs | 70 +++++++++++++++++++ src/EPPlus/Drawing/ExcelDrawing.cs | 32 +++++++-- src/EPPlus/Drawing/ExcelShape.cs | 5 +- .../Renderer/Chart/ChartDrawingObject.cs | 12 +++- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 9 +-- src/EPPlus/Drawing/SvgRenderOptions.cs | 26 +++++++ src/EPPlus/Drawing/SvgSize.cs | 34 +++++++++ 11 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 src/EPPlus.DrawingRenderer/Svg/Options/SvgRenderOptions.cs create mode 100644 src/EPPlus.DrawingRenderer/Svg/Options/SvgSize.cs create mode 100644 src/EPPlus/Drawing/SvgRenderOptions.cs create mode 100644 src/EPPlus/Drawing/SvgSize.cs diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index cbecdc4035..c786c38b92 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs @@ -22,7 +22,7 @@ public void GenerateSvgForBarCharts1() var ix = 0; foreach (ExcelChart c in ws.Drawings) { - var svg = c.ToSvg(); + var svg = c.ToSvg(x => { x.Size.Width = 100; x.Size.Height = 100; }); SaveTextFileToWorkbook($"svg\\BarChartForSvg_sheet1_{ix++}.svg", svg); } } diff --git a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index a6b824b6de..54fa8f83d5 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -1,6 +1,7 @@ using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; using EPPlus.DrawingRenderer.RenderItems.SvgItem; +using EPPlus.DrawingRenderer.Svg; using EPPlus.Export.ImageRenderer.RenderItems.SvgItem; using EPPlus.Fonts.OpenType.Integration.DataHolders; using EPPlus.Graphics; @@ -17,7 +18,7 @@ private GroupRenderItem GenerateShapeRenderer() { BoundingBox bounds = new BoundingBox(0, 0, 500, 500); StringBuilder sb = new StringBuilder(); - var svgShapeRenderer = new SvgShapeRenderer(bounds, sb); + var svgShapeRenderer = new SvgShapeRenderer(bounds, sb, new SvgRenderOptions()); var baseGroup = new GroupRenderItem(bounds); @@ -52,7 +53,7 @@ private void GenerateSvgFile(string fileName, BoundingBox bounds, params RenderI { StringBuilder sb = new StringBuilder(); - var svgShapeRenderer = new SvgShapeRenderer(bounds, sb); + var svgShapeRenderer = new SvgShapeRenderer(bounds, sb, new SvgRenderOptions()); List renderItems = items.ToList(); svgShapeRenderer.Render(renderItems); @@ -95,7 +96,7 @@ public void SvgTextRun() private void GenerateTextBodyFile(string fileName, GroupRenderItem baseGroup, SvgTextBodyRenderItem textBody) { StringBuilder sb = new StringBuilder(); - var svgShapeRenderer = new SvgShapeRenderer(baseGroup.Bounds, sb); + var svgShapeRenderer = new SvgShapeRenderer(baseGroup.Bounds, sb, new SvgRenderOptions()); var background = new RectRenderItem(baseGroup.Bounds); diff --git a/src/EPPlus.DrawingRenderer/Svg/IRender.cs b/src/EPPlus.DrawingRenderer/Svg/IRender.cs index ca3a62b34b..84a3603bd9 100644 --- a/src/EPPlus.DrawingRenderer/Svg/IRender.cs +++ b/src/EPPlus.DrawingRenderer/Svg/IRender.cs @@ -22,9 +22,20 @@ namespace EPPlus.DrawingRenderer { public class SvgShapeRenderer : IShapeRenderer { - public SvgShapeRenderer(BoundingBox bounds, StringBuilder outputStream) + public SvgShapeRenderer(BoundingBox bounds, StringBuilder outputStream, SvgRenderOptions options) { BasicShapesRenderer = new SvgBasicShapesRenderer(outputStream); + + //Override size. + if (options.Size.Width.HasValue) + { + bounds.Width = options.Size.WidthPixels; + } + if (options.Size.Height.HasValue) + { + bounds.Height = options.Size.HeightPixels; + } + Bounds = bounds; OutputStream = outputStream; } diff --git a/src/EPPlus.DrawingRenderer/Svg/Options/SvgRenderOptions.cs b/src/EPPlus.DrawingRenderer/Svg/Options/SvgRenderOptions.cs new file mode 100644 index 0000000000..ea42b7c955 --- /dev/null +++ b/src/EPPlus.DrawingRenderer/Svg/Options/SvgRenderOptions.cs @@ -0,0 +1,23 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 01/27/2020 EPPlus Software AB Initial release EPPlus 5 + *************************************************************************************************/ +namespace EPPlus.DrawingRenderer.Svg +{ + /// + /// Options for rendering drawings to svg. + /// + public class SvgRenderOptions + { + public SvgSize Size { get; } = new SvgSize(); + } + +} \ No newline at end of file diff --git a/src/EPPlus.DrawingRenderer/Svg/Options/SvgSize.cs b/src/EPPlus.DrawingRenderer/Svg/Options/SvgSize.cs new file mode 100644 index 0000000000..ba651d948c --- /dev/null +++ b/src/EPPlus.DrawingRenderer/Svg/Options/SvgSize.cs @@ -0,0 +1,70 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 01/27/2020 EPPlus Software AB Initial release EPPlus 5 + *************************************************************************************************/ + +using EPPlus.Fonts.OpenType.Utils; + +namespace EPPlus.DrawingRenderer.Svg +{ + public class SvgSize + { + /// + /// Overrides the width for ouput the svg image. + /// + public double? Width { get; set; } = null; + /// + /// Overrides the height of the ouput svg drawing. + /// + public double? Height { get; set; } = null; + public SvgSizeUnit Unit { get; set; } = SvgSizeUnit.Pixels; + public double WidthPixels + { + get + { + return GetPixels(Width ?? 0D, Unit); + } + } + public double HeightPixels + { + get + { + return GetPixels(Height ?? 0D, Unit); + } + } + + internal static double GetPixels(double width, SvgSizeUnit unit) + { + switch(unit) + { + case SvgSizeUnit.Points: + return width.PointToPixel(); + case SvgSizeUnit.Inches: + return width * 96; + case SvgSizeUnit.Centimeters: + return width * 96 / 2.54; + case SvgSizeUnit.Millimeters: + return width * 96 / 25.4; + default: //Pixels + return width; + } + } + } +} + + public enum SvgSizeUnit + { + Pixels=0, + Points=1, + Millimeters, // mm + Centimeters, // cm + Inches // in +} diff --git a/src/EPPlus/Drawing/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index c181bfb6f3..7c306290be 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -11,8 +11,10 @@ Date Author Change 01/27/2020 EPPlus Software AB Initial release EPPlus 5 *************************************************************************************************/ using EPPlus.DrawingRenderer; +using EPPlus.DrawingRenderer.Svg; using EPPlus.Export.Utils; using EPPlusImageRenderer; +using OfficeOpenXml.ConditionalFormatting; using OfficeOpenXml.Core.Worksheet; using OfficeOpenXml.Drawing.Chart; using OfficeOpenXml.Drawing.Controls; @@ -20,8 +22,8 @@ Date Author Change using OfficeOpenXml.Drawing.Slicer; using OfficeOpenXml.Export.HtmlExport; using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; -using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; using OfficeOpenXml.Packaging; using OfficeOpenXml.Utils; using OfficeOpenXml.Utils.Drawings; @@ -37,6 +39,7 @@ Date Author Change using System.Security.Cryptography; using System.Text; using System.Xml; +using static Microsoft.IO.RecyclableMemoryStreamManager; namespace OfficeOpenXml.Drawing { @@ -2499,24 +2502,43 @@ internal virtual void SaveDrawing(bool hasLoadedPivotTables) //Individual drawings that require certain saving actions do so by overriding this } + /// + /// Converts the drawing to a SVG image. + /// This is currently only supported for shapes, line-, column-, bar- and pie- charts. + /// + /// The svg image. + /// If the drawing type is not supported public string ToSvg() { + return ToSvg(null); + } + /// git statu + /// Converts the drawing to a SVG image. + /// This is currently only supported for shapes, line-, column-, bar- and pie- charts. + /// + /// + /// The svg image. + /// If the drawing type is not supported + public string ToSvg(Action options) + { + var o = new SvgRenderOptions(); + if(options != null) options.Invoke(o); if (this.DrawingType == eDrawingType.Shape) { - return ((ExcelShape)this).ToSvg(); + return ((ExcelShape)this).ToSvg(o); } else if (this.DrawingType == eDrawingType.Chart) { - var cr = new ChartRenderer((ExcelChart)this); + var cr = new ChartRenderer((ExcelChart)this, o); var sb = new StringBuilder(); - var shapeRenderer = new SvgShapeRenderer(this.GetBoundingBox(), sb); + var shapeRenderer = new SvgShapeRenderer(this.GetBoundingBox(), sb, o); shapeRenderer.Render(cr.RenderItems); return sb.ToString(); } - throw new InvalidOperationException("Only line-, column-, bar- and pie charts and shapes can be rendered to svg"); + throw new NotSupportedException("Only line-, column-, bar- and pie charts and shapes can be rendered to svg"); } } diff --git a/src/EPPlus/Drawing/ExcelShape.cs b/src/EPPlus/Drawing/ExcelShape.cs index 6c1b19ae6f..a224eab0de 100644 --- a/src/EPPlus/Drawing/ExcelShape.cs +++ b/src/EPPlus/Drawing/ExcelShape.cs @@ -19,6 +19,7 @@ Date Author Change using System; using System.Text; using System.Xml; +using static Microsoft.IO.RecyclableMemoryStreamManager; namespace OfficeOpenXml.Drawing { /// @@ -95,11 +96,11 @@ internal override void DeleteMe() } base.DeleteMe(); } - public string ToSvg() + public string ToSvg(SvgRenderOptions options) { var sr = new ShapeRenderer(this); var sb = new StringBuilder(); - var svg = new SvgShapeRenderer(this.GetBoundingBox(), sb); + var svg = new SvgShapeRenderer(this.GetBoundingBox(), sb, options); svg.Render(sr.RenderItems); return sb.ToString(); } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs index 1d6c705910..67b461e6a1 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs @@ -12,6 +12,7 @@ Date Author Change *************************************************************************************************/ using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; +using EPPlus.DrawingRenderer.Svg; using EPPlus.Fonts.OpenType.Utils; using EPPlus.Graphics; using EPPlusImageRenderer.RenderItems; @@ -26,8 +27,17 @@ namespace EPPlusImageRenderer.Svg { internal class ChartAreaRenderer : ChartDrawingObject { - public ChartAreaRenderer(ChartRenderer sc) : base(sc) + public ChartAreaRenderer(ChartRenderer sc, SvgRenderOptions options) : base(sc) { + if(options.Size.Width.HasValue) + { + sc.Bounds.Width = options.Size.WidthPixels; + } + if (options.Size.Height.HasValue) + { + sc.Bounds.Height = options.Size.HeightPixels; + } + Rectangle = new RectRenderItem(sc.Bounds); } diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index 00c429636e..123d6cb21c 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -13,6 +13,7 @@ Date Author Change using EPPlus.DrawingRenderer; using EPPlus.DrawingRenderer.RenderItems; +using EPPlus.DrawingRenderer.Svg; using EPPlus.Export.ImageRenderer.Svg.Chart; using EPPlus.Fonts.OpenType.Utils; using EPPlus.Graphics; @@ -30,9 +31,9 @@ namespace EPPlusImageRenderer { internal class ChartRenderer : d.DrawingRenderer { - public ChartRenderer(ExcelChart chart) : base(chart) + public ChartRenderer(ExcelChart chart, SvgRenderOptions options) : base(chart) { - SetChartArea(); + SetChartArea(options); if (chart.HasTitle && chart.Series.Count > 0) { @@ -301,9 +302,9 @@ private void PlaceVerticalAxisTitle(ChartAxisRenderer verticalAxis) } } - private void SetChartArea() + private void SetChartArea(SvgRenderOptions options) { - var item = new ChartAreaRenderer(this); + var item = new ChartAreaRenderer(this, options); item.Rectangle.Width = Bounds.Width; item.Rectangle.Height = Bounds.Height; diff --git a/src/EPPlus/Drawing/SvgRenderOptions.cs b/src/EPPlus/Drawing/SvgRenderOptions.cs new file mode 100644 index 0000000000..a984fa65d6 --- /dev/null +++ b/src/EPPlus/Drawing/SvgRenderOptions.cs @@ -0,0 +1,26 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 01/27/2020 EPPlus Software AB Initial release EPPlus 5 + *************************************************************************************************/ +namespace OfficeOpenXml.Drawing.Svg +{ + /// + /// Options for rendering drawings to svg. + /// + public class SvgRenderOptions + { + /// + /// The size of the svg image, if you want to override the default size. + /// + public SvgSize Size { get; } = new SvgSize(); + } + +} \ No newline at end of file diff --git a/src/EPPlus/Drawing/SvgSize.cs b/src/EPPlus/Drawing/SvgSize.cs new file mode 100644 index 0000000000..89fdf276a6 --- /dev/null +++ b/src/EPPlus/Drawing/SvgSize.cs @@ -0,0 +1,34 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 01/27/2020 EPPlus Software AB Initial release EPPlus 5 + *************************************************************************************************/ +namespace OfficeOpenXml.Drawing.Svg +{ + public class SvgSize + { + /// + /// Overrides the width for ouput the svg image. + /// + public double? Width { get; set; } = null; + /// + /// Overrides the height of the ouput svg drawing. + /// + public double? Height { get; set; } = null; + public SvgSizeUnit Unit { get; set; } = SvgSizeUnit.Pixels; + } +} + + public enum SvgSizeUnit + { + Pixels=0, + Points=1, + + } From 8a8d18884778e2bb638ffa81ca05c2797dfc857b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 29 Jun 2026 11:45:15 +0200 Subject: [PATCH 112/118] Adding additional tests --- src/EPPlus/Drawing/Renderer/ChartRenderer.cs | 2 +- .../DrawingRenderItemExtentions.cs | 70 ++++++++++--------- .../Settings/HtmlDrawingSettings.cs | 1 + .../Export/HtmlExport/RangeExporterTests.cs | 64 +++++++++++++++++ .../Export/HtmlExport/SvgShapeExportTests.cs | 5 ++ 5 files changed, 108 insertions(+), 34 deletions(-) diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index 00c429636e..63f11e2bff 100644 --- a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs @@ -307,7 +307,7 @@ private void SetChartArea() item.Rectangle.Width = Bounds.Width; item.Rectangle.Height = Bounds.Height; - item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style?.ChartArea.FillReference.Color, true); + item.Rectangle.SetDrawingPropertiesFill(Theme, Chart.Fill, Chart.StyleManager.Style?.ChartArea.FillReference.Color, true, Theme.ColorScheme.GetColorByEnum(eSchemeColor.Background1)); item.Rectangle.SetDrawingPropertiesBorder(Theme, Chart.Border, Chart.StyleManager.Style?.ChartArea.BorderReference.Color, Chart.Border.Width > 0); item.AppendRenderItems(RenderItems); item.SetMargins(Chart.TextBody); diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs index 6ba42c04f1..0ef5dde160 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/DrawingRenderItemExtentions.cs @@ -188,40 +188,44 @@ private static string GetFillColor(ExcelTheme theme, ExcelDrawingFillBasic fill, fc = tc.ColorConverter.GetThemeColor(nullColor ?? theme.ColorScheme.Accent1); if (fill == null || fill.Style == eFillStyle.NoFill) { - if (styleFillColor == null) - { - //There is no Style-Specified color. Themed Fill should be applied if it exists - //Fallback to theme - if (theme.FormatScheme.BackgroundFillStyle != null) - { - //Usually, at least for chart objects if the theme fill is not NoFill it is Subtle - var subtleBg = theme.FormatScheme.BackgroundFillStyle[0]; - if (subtleBg.IsEmpty == false) - { - if (subtleBg.Style == eFillStyle.SolidFill) - { - if (subtleBg.SolidFill.Color.ColorType == eDrawingColorType.Scheme) - { - //The theme color is PhClr which is fallback color to style. - //Style does not exist. But The base theme schemecolor does. - //Hardcoded defaults to solid fill according to docs is Bg1 - //Specifically SolidFill has a fallback to bg1 + //if(nullColor != null) + //{ + // fc = tc + //} + //if (styleFillColor == null) + //{ + // //There is no Style-Specified color. Themed Fill should be applied if it exists + // //Fallback to theme + // if (theme.FormatScheme.BackgroundFillStyle != null) + // { + // //Usually, at least for chart objects if the theme fill is not NoFill it is Subtle + // var subtleBg = theme.FormatScheme.BackgroundFillStyle[0]; + // if (subtleBg.IsEmpty == false) + // { + // if (subtleBg.Style == eFillStyle.SolidFill) + // { + // if (subtleBg.SolidFill.Color.ColorType == eDrawingColorType.Scheme) + // { + // //The theme color is PhClr which is fallback color to style. + // //Style does not exist. But The base theme schemecolor does. + // //Hardcoded defaults to solid fill according to docs is Bg1 + // //Specifically SolidFill has a fallback to bg1 - var bg1 = theme.ColorScheme.GetColorByEnum(eSchemeColor.Background1); - fc = tc.ColorConverter.GetThemeColor(bg1); - } - } - else - { - //alternatively accent 1 - fc = subtleBg.Color; - } - //var style = subtleBg.Style; - //subtleBg.LoadFill(); - //subtleBg.Color; - } - } - } + // var bg1 = theme.ColorScheme.GetColorByEnum(eSchemeColor.Background1); + // fc = tc.ColorConverter.GetThemeColor(bg1); + // } + // } + // else + // { + // //alternatively accent 1 + // fc = subtleBg.Color; + // } + // //var style = subtleBg.Style; + // //subtleBg.LoadFill(); + // //subtleBg.Color; + // } + // } + //} } else if (fill.Style == eFillStyle.SolidFill) { diff --git a/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs b/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs index bab14da648..fdb4fae718 100644 --- a/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs +++ b/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs @@ -23,6 +23,7 @@ internal HtmlDrawingSettings() /// public eDrawingInclude DrawTypeInclude = eDrawingInclude.None; + public ePicturePosition Position = ePicturePosition.Relative; } } diff --git a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs index 6ddc5b8515..614d5c2233 100644 --- a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs @@ -176,6 +176,70 @@ public void WriteAllsvenskan() SaveAndCleanup(p); } } + + [TestMethod] + public async Task TaskWriteImagesSimple() + { + using (var p = OpenTemplatePackage("SimpleImageForHtml.xlsx")) + { + var sheet = p.Workbook.Worksheets[0]; + var exporter = sheet.Cells["A1:L17"].CreateHtmlExporter(); + + exporter.Settings.SetColumnWidth = true; + exporter.Settings.SetRowHeight = true; + + var pictureSettings = exporter.Settings.Pictures; + + pictureSettings.Include = ePictureInclude.Include; + pictureSettings.Position = ePicturePosition.Absolute; + pictureSettings.KeepOriginalSize = false; + + exporter.Settings.Minify = false; + exporter.Settings.Encoding = Encoding.UTF8; + var html = exporter.GetSinglePage(); + var htmlAsync = await exporter.GetSinglePageAsync(); + + var outputFile = GetOutputFile("html", "simpleImageSync2.html"); + var outputFileAsync = GetOutputFile("html", "simpleImageAsync2.html"); + + File.WriteAllText(outputFile.FullName, html); + File.WriteAllText(outputFileAsync.FullName, htmlAsync); + Assert.AreEqual(html, htmlAsync); + } + } + + [TestMethod] + public async Task TaskWriteChartSimple() + { + using (var p = OpenTemplatePackage("SimpleChartForHtml.xlsx")) + { + var sheet = p.Workbook.Worksheets[0]; + var exporter = sheet.Cells["A1:L17"].CreateHtmlExporter(); + + exporter.Settings.SetColumnWidth = true; + exporter.Settings.SetRowHeight = true; + + var setting = exporter.Settings.Drawings; + + setting.DrawTypeInclude = eDrawingInclude.Charts; + setting.Include = ePictureInclude.Include; + setting.Position = ePicturePosition.Absolute; + + exporter.Settings.Minify = false; + exporter.Settings.Encoding = Encoding.UTF8; + var html = exporter.GetSinglePage(); + var htmlAsync = await exporter.GetSinglePageAsync(); + + var outputFile = GetOutputFile("html", "simpleChartSync.html"); + var outputFileAsync = GetOutputFile("html", "simpleChartAsync.html"); + + File.WriteAllText(outputFile.FullName, html); + File.WriteAllText(outputFileAsync.FullName, htmlAsync); + Assert.AreEqual(html, htmlAsync); + } + } + + [TestMethod] public async Task WriteImagesAsync() { diff --git a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs index bf977070cc..b54ab3ff04 100644 --- a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs @@ -87,6 +87,11 @@ public void ExportBarChart() var exporter = ws.Cells["A1:C20"].CreateHtmlExporter(); + + exporter.Settings.SetColumnWidth = true; + exporter.Settings.SetRowHeight = true; + exporter.Settings.Minify = false; + exporter.Settings.Encoding = Encoding.UTF8; exporter.Settings.Drawings.Include = ePictureInclude.IncludeInHtmlOnly; exporter.Settings.Drawings.DrawTypeInclude = eDrawingInclude.Charts; From ab3399b79cc2e35167c26d0c9d300a668e35acc5 Mon Sep 17 00:00:00 2001 From: Mats Alm <897655+swmal@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:23:53 +0200 Subject: [PATCH 113/118] Fix for autofit columns with autofilters (#2390) --- src/EPPlus/Core/AutofitHelper.cs | 14 +++++- src/EPPlusTest/Issues/WorksheetIssues.cs | 52 ++++++++++++++++++++ src/EPPlusTest/WorkSheetTests.cs | 62 ++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) 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/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/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() { From aa2d8ab843c1016b8e4159414716c6cfa8b35fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 29 Jun 2026 13:25:39 +0200 Subject: [PATCH 114/118] Added base-functional chart export --- .../CssCollections/CssRangeRuleCollection.cs | 24 ++++++ .../Exporters/Internal/CssExporterBase.cs | 11 ++- .../Internal/HtmlExporterBaseInternal.cs | 6 +- .../CssDrawingPropertiesTranslator.cs | 85 +++++++++++++++++++ .../Translators/CssImageTranslator.cs | 12 ++- .../Export/HtmlExport/RangeExporterTests.cs | 2 +- 6 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs diff --git a/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs b/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs index 77232f1a71..d4f68965e9 100644 --- a/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs +++ b/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs @@ -427,7 +427,31 @@ internal void AddIconSetCF(ExcelConditionalFormattingIconSetBase set, int _ruleCollection.CssRules.Add(contentRule); } + internal void AddDrawingToCss(HtmlSvgDrawing d) + { + var translator = new CssImageTranslator(d); + + if (translator.type == null) return; + + string imageFileName = HtmlExportTableUtil.GetClassName(d.Drawing.Name, "optional-name"); + var imgRule = new CssRule($"img.{_settings.StyleClassPrefix}drawing-{imageFileName}", int.MaxValue); + + _context.SetTranslator(translator); + _context.AddDeclarations(imgRule); + _ruleCollection.AddRule(imgRule); + + AddDrawingPropertiesToCss(d); + } + internal void AddDrawingPropertiesToCss(HtmlSvgDrawing d) + { + string imageName = HtmlExportTableUtil.GetClassName(d.Drawing.Name, "optional-name"); + + var imgProperties = new CssRule($"img.{_settings.StyleClassPrefix}drawing-prop-{imageName}", int.MaxValue); + _context.SetTranslator(new CssDrawingPropertiesTranslator(d)); + _context.AddDeclarations(imgProperties); + RuleCollection.AddRule(imgProperties); + } internal void AddPictureToCss(HtmlImage p) { diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs index 22fb929ac2..74dada4db1 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs @@ -137,7 +137,7 @@ protected void AddCssRulesToCollection(CssRangeRuleCollection cssTranslator, Htm } } - if (Settings.Pictures.Include == ePictureInclude.Include) + if (Settings.Pictures.Include == ePictureInclude.Include || Settings.Pictures.Include == ePictureInclude.IncludeInCssOnly) { LoadRangeDrawings(_ranges._list); foreach (var p in _rangePictures) @@ -145,6 +145,15 @@ protected void AddCssRulesToCollection(CssRangeRuleCollection cssTranslator, Htm cssTranslator.AddPictureToCss(p); } } + + if(Settings.Drawings.Include == ePictureInclude.Include || Settings.Drawings.Include == ePictureInclude.IncludeInCssOnly) + { + LoadRangeDrawings(_ranges._list); + foreach(var d in _rangeDrawings) + { + cssTranslator.AddDrawingToCss(d); + } + } } protected void AddCellCss(CssRangeRuleCollection collection, ExcelRangeBase range, bool isTableExporter = false) diff --git a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs index af432680d9..06f5abe595 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/HtmlExporterBaseInternal.cs @@ -446,8 +446,7 @@ protected void AddDrawing(HTMLElement parent, HtmlExportSettings settings, HtmlS { if (d != null) { - var child = new HTMLElement(HtmlElements.Svg); - var name = d.Drawing.Name; + var child = new HTMLElement(HtmlElements.Img); string drawingName = HtmlExportTableUtil.GetClassName(d.Drawing.Name, $"drawing{d.Drawing.Id}"); child.AddAttribute("alt", d.Drawing.Name); if (settings.Pictures.AddNameAsId) @@ -457,6 +456,7 @@ protected void AddDrawing(HTMLElement parent, HtmlExportSettings settings, HtmlS if (settings.Drawings.Include == ePictureInclude.IncludeInHtmlOnly) { + child = new HTMLElement(HtmlElements.Svg); child.ElementName = "div"; child.Content = d.Drawing.ToSvg(); //var _encodedImage = ImageEncoder.EncodeImage(image, out type); @@ -465,7 +465,7 @@ protected void AddDrawing(HTMLElement parent, HtmlExportSettings settings, HtmlS } else { - child.AddAttribute("class", $"{settings.StyleClassPrefix}drawing-{name} {settings.StyleClassPrefix}drawing-prop-{drawingName}"); + child.AddAttribute("class", $"{settings.StyleClassPrefix}drawing-{drawingName} {settings.StyleClassPrefix}drawing-prop-{drawingName}"); } parent._childElements.Add(child); } diff --git a/src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs new file mode 100644 index 0000000000..873be7c332 --- /dev/null +++ b/src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs @@ -0,0 +1,85 @@ +using EPPlus.Export.Utils; +using EPPlus.Graphics; +using OfficeOpenXml.Drawing; +using OfficeOpenXml.Drawing.Chart; +using OfficeOpenXml.Export.HtmlExport.CssCollections; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace OfficeOpenXml.Export.HtmlExport.Translators +{ + internal class CssDrawingPropertiesTranslator : TranslatorBase + { + + double _width; + double _height; + BoundingBox _bounds; + ExcelDrawingBorder _border; + + internal CssDrawingPropertiesTranslator(HtmlSvgDrawing d) + { + _width = d.Drawing.GetPixelWidth(); + _height = d.Drawing.GetPixelHeight(); + _bounds = d.Drawing.GetBoundingBox(); + if(d.Drawing is ExcelChart) + { + _border = d.Drawing.As.Chart.Chart.Border; + } + } + + internal override List GenerateDeclarationList(TranslatorContext context) + { + if (context.Pictures.KeepOriginalSize == false) + { + if (_width != _bounds.Width) + { + AddDeclaration("max-width", $"{_width:F0}px"); + } + if (_height != _bounds.Height) + { + AddDeclaration("max-height", $"{_height:F0}px"); + } + } + + if (_border.LineStyle != null && context.Pictures.CssExclude.Border == false) + { + var border = GetDrawingBorder(); + AddDeclaration("border", border); + } + + return declarations; + } + + private string GetDrawingBorder() + { + Color color = _border.Fill.Color; + if (color.IsEmpty) return ""; + string lineStyle = $"{_border.Width}px"; + + switch (_border.LineStyle.Value) + { + case eLineStyle.Solid: + lineStyle += " solid"; + break; + case eLineStyle.Dash: + case eLineStyle.LongDashDot: + case eLineStyle.LongDashDotDot: + case eLineStyle.SystemDash: + case eLineStyle.SystemDashDot: + case eLineStyle.SystemDashDotDot: + lineStyle += $" dashed"; + break; + case eLineStyle.Dot: + lineStyle += $" dot"; + break; + } + + lineStyle += " #" + color.ToArgb().ToString("x8").Substring(2); + return lineStyle; + } + } +} diff --git a/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs index 8eff1db2cc..6715232fba 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs @@ -12,13 +12,14 @@ Date Author Change *************************************************************************************************/ using OfficeOpenXml.Drawing; using OfficeOpenXml.Export.HtmlExport.CssCollections; +using System; using System.Collections.Generic; namespace OfficeOpenXml.Export.HtmlExport.Translators { internal class CssImageTranslator : TranslatorBase { - HtmlImage _p; + HtmlDrawing _p; string _encodedImage; internal ePictureType? type; @@ -28,6 +29,15 @@ public CssImageTranslator(HtmlImage p) _encodedImage = ImageEncoder.EncodeImage(p, out type); } + public CssImageTranslator(HtmlSvgDrawing d) + { + _p = d; + var charArr = d.Drawing.ToSvg().ToCharArray(); + var byteArr = System.Text.Encoding.UTF8.GetBytes(charArr); + _encodedImage = Convert.ToBase64String(byteArr); + type = ePictureType.Svg; + } + internal override List GenerateDeclarationList(TranslatorContext context) { AddDeclaration("content", $"url('data:{GetContentType(type.Value)};base64,{_encodedImage}')"); diff --git a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs index 614d5c2233..6f4b1e3009 100644 --- a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs @@ -191,7 +191,7 @@ public async Task TaskWriteImagesSimple() var pictureSettings = exporter.Settings.Pictures; pictureSettings.Include = ePictureInclude.Include; - pictureSettings.Position = ePicturePosition.Absolute; + pictureSettings.Position = ePicturePosition.Relative; pictureSettings.KeepOriginalSize = false; exporter.Settings.Minify = false; From 08fcf4e84a99d3f0b7d77cfb57c5c82cf421a650 Mon Sep 17 00:00:00 2001 From: karlkallman <137038948+karlkallman@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:45:45 +0200 Subject: [PATCH 115/118] Allow AppVersion in OfficeProperties to be set to null. #2393. (#2394) --- src/EPPlus/OfficeProperties.cs | 8 ++++++++ src/EPPlusTest/OfficePropertiesTests.cs | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/EPPlus/OfficeProperties.cs b/src/EPPlus/OfficeProperties.cs index 210483766f..39c01c4596 100644 --- a/src/EPPlus/OfficeProperties.cs +++ b/src/EPPlus/OfficeProperties.cs @@ -293,6 +293,14 @@ public string AppVersion get { return _extendedHelper.GetXmlNodeString(AppVersionPath); } set { + if (value == null) + { + if(_extendedHelper.TopNode != null) + { + _extendedHelper.DeleteNode(AppVersionPath); + } + return; + } var versions = value.Split('.'); if(versions.Length!=2 || versions.Any(x=>!x.IsInt())) { diff --git a/src/EPPlusTest/OfficePropertiesTests.cs b/src/EPPlusTest/OfficePropertiesTests.cs index 2ac4cc6657..17bf6342f8 100644 --- a/src/EPPlusTest/OfficePropertiesTests.cs +++ b/src/EPPlusTest/OfficePropertiesTests.cs @@ -1,5 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using OfficeOpenXml; +using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; using System; using System.Collections.Generic; using System.Linq; @@ -58,5 +59,19 @@ public void ValidateCaseInsensitiveCustomProperties_Loading() p.Dispose(); p2.Dispose(); } + + [TestMethod] + public void AppVersion_SetToNull_RemovesNodeWithoutThrowing() + { + using var package = new ExcelPackage(); + var props = package.Workbook.Properties; + var s = package.Workbook.Worksheets.Add("TestSheet"); + s.Cells["A1"].Value = "Hej"; + props.AppVersion = "16.0300"; + Assert.AreEqual("16.0300", props.AppVersion); + + props.AppVersion = null; + Assert.IsTrue(string.IsNullOrEmpty(props.AppVersion)); + } } } From 3b6954a23b3df620a37c236d8995c37241d0bf3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Mon, 29 Jun 2026 14:36:48 +0200 Subject: [PATCH 116/118] Fixed absolute positioning for charts --- .../CssCollections/CssRangeRuleCollection.cs | 10 ++++++++++ .../Export/HtmlExport/Translators/TranslatorContext.cs | 1 + src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs b/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs index d4f68965e9..2128ca3187 100644 --- a/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs +++ b/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs @@ -61,6 +61,11 @@ internal CssRangeRuleCollection(List ranges, HtmlRangeExportSett _context.Theme = _theme; _context.IndentValue = _cssSettings.IndentValue; _context.IndentUnit = _cssSettings.IndentUnit; + + if(settings.Drawings.Position != ePicturePosition.DontSet) + { + _context.Pictures.Position = settings.Drawings.Position; + } } internal CssRangeRuleCollection(List ranges, HtmlTableExportSettings settings) @@ -74,6 +79,11 @@ internal CssRangeRuleCollection(List ranges, HtmlTableExportSett _context.Theme = _theme; _context.IndentValue = _cssSettings.IndentValue; _context.IndentUnit = _cssSettings.IndentUnit; + + if (settings.Drawings.Position != ePicturePosition.DontSet) + { + _context.Pictures.Position = settings.Drawings.Position; + } } private void Init(List ranges) diff --git a/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs b/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs index fceb1178d5..339fefeac6 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs @@ -35,6 +35,7 @@ internal class TranslatorContext internal CssExportSettings Settings; internal HtmlPictureSettings Pictures; + private TranslatorBase strategy; internal bool SharedIconSetRuleAdded = false; diff --git a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs index 6f4b1e3009..614d5c2233 100644 --- a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs @@ -191,7 +191,7 @@ public async Task TaskWriteImagesSimple() var pictureSettings = exporter.Settings.Pictures; pictureSettings.Include = ePictureInclude.Include; - pictureSettings.Position = ePicturePosition.Relative; + pictureSettings.Position = ePicturePosition.Absolute; pictureSettings.KeepOriginalSize = false; exporter.Settings.Minify = false; From 5b383df5767cf256efd9c9c8a8febbdf46349133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 30 Jun 2026 06:55:27 +0200 Subject: [PATCH 117/118] Start of relative position + multiple charts/shapes --- .../CssCollections/CssRangeRuleCollection.cs | 10 ----- .../Settings/HtmlDrawingSettings.cs | 5 ++- .../CssDrawingPropertiesTranslator.cs | 28 +++++++++++++ .../Translators/CssImageTranslator.cs | 7 ++++ .../Translators/TranslatorContext.cs | 4 +- .../Export/HtmlExport/RangeExporterTests.cs | 39 +++++++++++++++++-- 6 files changed, 77 insertions(+), 16 deletions(-) diff --git a/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs b/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs index 2128ca3187..d4f68965e9 100644 --- a/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs +++ b/src/EPPlus/Export/HtmlExport/CssCollections/CssRangeRuleCollection.cs @@ -61,11 +61,6 @@ internal CssRangeRuleCollection(List ranges, HtmlRangeExportSett _context.Theme = _theme; _context.IndentValue = _cssSettings.IndentValue; _context.IndentUnit = _cssSettings.IndentUnit; - - if(settings.Drawings.Position != ePicturePosition.DontSet) - { - _context.Pictures.Position = settings.Drawings.Position; - } } internal CssRangeRuleCollection(List ranges, HtmlTableExportSettings settings) @@ -79,11 +74,6 @@ internal CssRangeRuleCollection(List ranges, HtmlTableExportSett _context.Theme = _theme; _context.IndentValue = _cssSettings.IndentValue; _context.IndentUnit = _cssSettings.IndentUnit; - - if (settings.Drawings.Position != ePicturePosition.DontSet) - { - _context.Pictures.Position = settings.Drawings.Position; - } } private void Init(List ranges) diff --git a/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs b/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs index fdb4fae718..d94bfbb869 100644 --- a/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs +++ b/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs @@ -23,7 +23,10 @@ internal HtmlDrawingSettings() /// public eDrawingInclude DrawTypeInclude = eDrawingInclude.None; - public ePicturePosition Position = ePicturePosition.Relative; + /// + /// Is absolute by default for charts + /// + public ePicturePosition Position = ePicturePosition.DontSet; } } diff --git a/src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs index 873be7c332..94a2701e32 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs @@ -1,4 +1,5 @@ using EPPlus.Export.Utils; +using EPPlus.Fonts.OpenType.Utils; using EPPlus.Graphics; using OfficeOpenXml.Drawing; using OfficeOpenXml.Drawing.Chart; @@ -29,10 +30,37 @@ internal CssDrawingPropertiesTranslator(HtmlSvgDrawing d) { _border = d.Drawing.As.Chart.Chart.Border; } + else if(d.Drawing is ExcelShapeBase) + { + _border = d.Drawing.As.Shape.Border; + } } internal override List GenerateDeclarationList(TranslatorContext context) { + if (context.Drawings.Position == ePicturePosition.Relative) + { + if (_bounds.Left != 0) + { + AddDeclaration("left", $"{_bounds.Left.PointToPixel():F0}px"); + } + if (_bounds.Top != 0) + { + AddDeclaration("top", $"{_bounds.Top.PointToPixel():F0}px"); + } + } + else if (context.Drawings.Position == ePicturePosition.Absolute) + { + if (_bounds.Left != 0) + { + AddDeclaration("left", $"{_bounds.GlobalLeft.PointToPixel():F0}px"); + } + if (_bounds.Top != 0) + { + AddDeclaration("top", $"{_bounds.GlobalTop.PointToPixel():F0}px"); + } + } + if (context.Pictures.KeepOriginalSize == false) { if (_width != _bounds.Width) diff --git a/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs index 6715232fba..0fc2ec21ec 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs @@ -22,6 +22,7 @@ internal class CssImageTranslator : TranslatorBase HtmlDrawing _p; string _encodedImage; internal ePictureType? type; + bool isDrawing = false; public CssImageTranslator(HtmlImage p) { @@ -36,6 +37,7 @@ public CssImageTranslator(HtmlSvgDrawing d) var byteArr = System.Text.Encoding.UTF8.GetBytes(charArr); _encodedImage = Convert.ToBase64String(byteArr); type = ePictureType.Svg; + isDrawing = true; } internal override List GenerateDeclarationList(TranslatorContext context) @@ -47,6 +49,11 @@ internal override List GenerateDeclarationList(TranslatorContext co AddDeclaration("position", $"{context.Pictures.Position.ToString().ToLower()}"); } + if(isDrawing && context.Drawings.Position != ePicturePosition.DontSet) + { + AddDeclaration("position", $"{context.Drawings.Position.ToString().ToLower()}"); + } + if (_p.FromColumnOff != 0 && context.Pictures.AddMarginLeft) { var leftOffset = _p.FromColumnOff / ExcelPicture.EMU_PER_PIXEL; diff --git a/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs b/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs index 339fefeac6..92e90b0b6b 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs @@ -34,7 +34,7 @@ internal class TranslatorContext internal CssExclude Exclude; internal CssExportSettings Settings; internal HtmlPictureSettings Pictures; - + internal HtmlDrawingSettings Drawings; private TranslatorBase strategy; @@ -54,12 +54,14 @@ public TranslatorContext(HtmlRangeExportSettings settings) Exclude = settings.Css.CssExclude; Settings = settings.Css; Pictures = settings.Pictures; + Drawings = settings.Drawings; } public TranslatorContext(HtmlTableExportSettings settings, CssExclude exclude) { Settings = settings.Css; Pictures = settings.Pictures; + Drawings = settings.Drawings; Exclude = exclude; } diff --git a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs index 614d5c2233..ba36f9b7c8 100644 --- a/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs +++ b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs @@ -191,7 +191,7 @@ public async Task TaskWriteImagesSimple() var pictureSettings = exporter.Settings.Pictures; pictureSettings.Include = ePictureInclude.Include; - pictureSettings.Position = ePicturePosition.Absolute; + pictureSettings.Position = ePicturePosition.Relative; pictureSettings.KeepOriginalSize = false; exporter.Settings.Minify = false; @@ -199,8 +199,39 @@ public async Task TaskWriteImagesSimple() var html = exporter.GetSinglePage(); var htmlAsync = await exporter.GetSinglePageAsync(); - var outputFile = GetOutputFile("html", "simpleImageSync2.html"); - var outputFileAsync = GetOutputFile("html", "simpleImageAsync2.html"); + var outputFile = GetOutputFile("html", "simpleImageSync3.html"); + var outputFileAsync = GetOutputFile("html", "simpleImageAsync3.html"); + + File.WriteAllText(outputFile.FullName, html); + File.WriteAllText(outputFileAsync.FullName, htmlAsync); + Assert.AreEqual(html, htmlAsync); + } + } + + [TestMethod] + public async Task TaskWriteChartAndShape() + { + using (var p = OpenTemplatePackage("ChartAndShapeHtml.xlsx")) + { + var sheet = p.Workbook.Worksheets[0]; + var exporter = sheet.Cells["A1:L17"].CreateHtmlExporter(); + + exporter.Settings.SetColumnWidth = true; + exporter.Settings.SetRowHeight = true; + + var setting = exporter.Settings.Drawings; + + setting.DrawTypeInclude = eDrawingInclude.Shapes & eDrawingInclude.Shapes; + setting.Include = ePictureInclude.Include; + setting.Position = ePicturePosition.Absolute; + + exporter.Settings.Minify = false; + exporter.Settings.Encoding = Encoding.UTF8; + var html = exporter.GetSinglePage(); + var htmlAsync = await exporter.GetSinglePageAsync(); + + var outputFile = GetOutputFile("html", "chartAndShape.html"); + var outputFileAsync = GetOutputFile("html", "chartAndShapeAsync.html"); File.WriteAllText(outputFile.FullName, html); File.WriteAllText(outputFileAsync.FullName, htmlAsync); @@ -223,7 +254,7 @@ public async Task TaskWriteChartSimple() setting.DrawTypeInclude = eDrawingInclude.Charts; setting.Include = ePictureInclude.Include; - setting.Position = ePicturePosition.Absolute; + setting.Position = ePicturePosition.Relative; exporter.Settings.Minify = false; exporter.Settings.Encoding = Encoding.UTF8; From d6862dab51c028f4cd99c2dbd31a66cab5ab85e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ossian=20Edstr=C3=B6m?= Date: Tue, 30 Jun 2026 08:49:09 +0200 Subject: [PATCH 118/118] Documented change to DrawingFillBasic --- docs/articles/breakingchanges.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/articles/breakingchanges.md b/docs/articles/breakingchanges.md index 1a0c58ff35..330ff3421f 100644 --- a/docs/articles/breakingchanges.md +++ b/docs/articles/breakingchanges.md @@ -230,4 +230,5 @@ The misspelled enum eCompundLineStyle has been renamed eCompoundLineStyle. ### 9.0.0 The misspelled enum eCompundLineStyle has been renamed eCompoundLineStyle. +The misspelled property on drawingFill `Transparancy` has been renamed to `Transparency` The `Richtext.Baseline` property now always return that value in whole percent. \ No newline at end of file