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. diff --git a/appveyor8.yml b/appveyor8.yml index c6c17f0c01..164cb73a0b 100644 --- a/appveyor8.yml +++ b/appveyor8.yml @@ -1,4 +1,4 @@ -version: 8.5.4.{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.5.4.$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.5.4.$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.5.4.{build} - file_version: 8.5.4.{build} + assembly_version: 8.6.1.{build} + file_version: 8.6.1.{build} nuget: project_feed: true before_build: 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 diff --git a/docs/articles/fixedissues.md b/docs/articles/fixedissues.md index 67bda9359c..f763512770 100644 --- a/docs/articles/fixedissues.md +++ b/docs/articles/fixedissues.md @@ -2,12 +2,52 @@ ## Version 9.0.0 * Added 'Layout' property to 'ExcelChartTrendlineLabel' class. -## 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 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: + * ´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.DrawingRenderer.Tests/Chart/BarChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/BarChartTests.cs index 6620be6406..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); } } @@ -42,5 +42,42 @@ public void GenerateSvgForBarCharts2() } } } + + [TestMethod] + public void DatalabelBarCharts() + { + ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); + using (var p = OpenTemplatePackage("BarChartForSvgDatalabelsBasic.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + var drawings = ws.Drawings; + var ix = 1; + + for (int i = ix; i < drawings.Count; i++) + { + var svg = drawings[i].ToSvg(); + SaveTextFileToWorkbook($"svg\\BarChartDataLabels{ix++}.svg", svg); + } + } + } + + + [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; + + for (int i = ix; i < drawings.Count; i++) + { + var svg = drawings[i].ToSvg(); + SaveTextFileToWorkbook($"svg\\NegativeLabels{ix++}.svg", svg); + } + } + } } } 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/ErrorbarsTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs new file mode 100644 index 0000000000..3b2ea9db41 --- /dev/null +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/ErrorbarsTests.cs @@ -0,0 +1,70 @@ +using OfficeOpenXml; +using OfficeOpenXml.Drawing.Chart; + +namespace EPPlus.Export.ImageRenderer.Tests.Chart +{ + [TestClass] + public class ErrorbarsTests : TestBase + { + [TestMethod] + public void GenerateSvgForErrorbars_Line_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\\Error_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); + } + } + } + [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); + } + } + } + [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.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/LineChartToSvgTests.cs index b7fe5272b2..d54afa7448 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 = 0; i < ws.Drawings.Count; i++) + //{ + // var c = ws.Drawings[i]; + // var svg = c.ToSvg(); + // SaveTextFileToWorkbook($"svg\\ChartForSvg{i}.svg", svg); + //} } } @@ -128,64 +128,65 @@ public void GenerateSvgForLineCharts() } } } + [TestMethod] - public void GenerateSuperScript() + public void GenerateSvgForCharts_SecondaryAxis() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); - using (var p = OpenTemplatePackage("Superscript.xlsx")) + using (var p = OpenTemplatePackage("ChartForSvg_SecondaryAxis.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 svg = c.ToSvg(); + //SaveTextFileToWorkbook($"svg\\ChartForSvg_sheet2_{ix++}.svg", svg); var ix = 1; - foreach (var c in ws.Drawings) + foreach (ExcelChart c in ws.Drawings) { var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\ss{ix++}.svg", svg); + SaveTextFileToWorkbook($"svg\\ChartForSvg_SecAxis{ix++}.svg", svg); } } } - [TestMethod] - public void GenerateSvgForCharts_SecondaryAxis() + public void GenerateSvgForCharts_SecondaryAxis_sheet2() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("ChartForSvg_SecondaryAxis.xlsx")) { - var ws = p.Workbook.Worksheets[0]; - //var ix = 3; + var ws = p.Workbook.Worksheets[1]; + //var ix = 2; //var c = ws.Drawings[ix]; //var svg = renderer.RenderDrawingToSvg(c); - //SaveTextFileToWorkbook($"svg\\ChartForSvg_sheet2_{ix++}.svg", svg); + //SaveTextFileToWorkbook($"svg\\ChartForSvg_Sheet2_SecAxis{ix++}.svg", svg); var ix = 1; foreach (ExcelChart c in ws.Drawings) { var svg = c.ToSvg(); - SaveTextFileToWorkbook($"svg\\ChartForSvg_SecAxis{ix++}.svg", svg); + SaveTextFileToWorkbook($"svg\\ChartForSvg_Sheet2_SecAxis{ix++}.svg", svg); } } } [TestMethod] - public void GenerateSvgForCharts_SecondaryAxis_sheet2() + public void GenerateSvgForCharts_SecondaryAxis_sheet3() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("ChartForSvg_SecondaryAxis.xlsx")) { - var ws = p.Workbook.Worksheets[1]; - //var ix = 2; + var ws = p.Workbook.Worksheets[2]; + //var ix = 1; //var c = ws.Drawings[ix]; - //var svg = renderer.RenderDrawingToSvg(c); - //SaveTextFileToWorkbook($"svg\\ChartForSvg_Sheet2_SecAxis{ix++}.svg", svg); + //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_Sheet2_SecAxis{ix++}.svg", svg); + SaveTextFileToWorkbook($"svg\\ChartForSvg_Sheet3_SecAxis{ix++}.svg", svg); } } } + [TestMethod] public void GenerateSimplestChart() { @@ -280,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.DrawingRenderer.Tests/Chart/PieChartTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs index 9b73ea8922..693294b5c4 100644 --- a/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/Chart/PieChartTests.cs @@ -12,98 +12,20 @@ public class PieChartTests : TestBase { [TestMethod] - public void ReadAndGenerateExcelPieChartSvgs() + public void ReadAndCreateSvgsAll() { ExcelPackage.License.SetNonCommercialOrganization("EPPlus Project"); using (var p = OpenTemplatePackage("PieChartSvgALL.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\\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]; - - 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\\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]; - - 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 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); + SaveTextFileToWorkbook($"svg\\PieChartSvgALL\\s{i}_{ws.Name}_{c.Name}.svg", svg); } } } diff --git a/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs b/src/EPPlus.DrawingRenderer.Tests/Chart/TrendlineTests.cs index e9df670ed8..219689525b 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; @@ -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() { @@ -36,7 +58,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.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs index f4dbcfd416..54fa8f83d5 100644 --- a/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs +++ b/src/EPPlus.DrawingRenderer.Tests/DrawingShapeRenderer/SvgStandAloneTests.cs @@ -1,66 +1,41 @@ 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.DrawingRenderer.Svg; +using EPPlus.Export.ImageRenderer.RenderItems.SvgItem; 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); - - rectItem.Width = 250; - rectItem.Height = 250; - rectItem.FillColor = "darkblue"; + var svgShapeRenderer = new SvgShapeRenderer(bounds, sb, new SvgRenderOptions()); - //var textBody = new RenderTextBody(baseGroup.Bounds, true); - //textBody.Text = "Hello"; - //var para = new SvgParagraphRenderItem(textBody, textBody.Bounds); - - //var para2 = new DrawingParagraphRenderItem(textBody, textBody.Bounds); - //textBody.Paragraphs.Add - - baseGroup.AddChildItem(rectItem); - //baseGroup.AddChildItem(textBody); - - List items = new List() { baseGroup }; + var baseGroup = new GroupRenderItem(bounds); - svgShapeRenderer.Render(items); + var background = new RectRenderItem(baseGroup.Bounds); - var svg = sb.ToString(); + background.Width = bounds.Width; + background.Height = bounds.Height; + background.FillColor = "aliceBlue"; - SaveTextFileToWorkbook("svg\\rectStandalone.svg", svg); + baseGroup.AddChildItem(background); + return baseGroup; } - [TestMethod] - public void SvgTextRun() + private GroupRenderItem GenerateGroupRenderItem() { BoundingBox bounds = new BoundingBox(0, 0, 500, 500); - StringBuilder sb = new StringBuilder(); - var svgShapeRenderer = new SvgShapeRenderer(bounds, sb); - var baseGroup = new GroupRenderItem(bounds); @@ -71,6 +46,34 @@ public void SvgTextRun() background.FillColor = "aliceBlue"; baseGroup.AddChildItem(background); + return baseGroup; + } + + private void GenerateSvgFile(string fileName, BoundingBox bounds, params RenderItem[] items) + { + + StringBuilder sb = new StringBuilder(); + var svgShapeRenderer = new SvgShapeRenderer(bounds, sb, new SvgRenderOptions()); + + List renderItems = items.ToList(); + svgShapeRenderer.Render(renderItems); + + var svg = sb.ToString(); + + SaveTextFileToWorkbook($"svg\\{fileName}.svg", svg); + } + + [TestMethod] + public void SvgRectTest() + { + var baseGroup = GenerateShapeRenderer(); + GenerateSvgFile("rectStandAlone", baseGroup.Bounds, baseGroup); + } + + [TestMethod] + public void SvgTextRun() + { + var baseGroup = GenerateShapeRenderer(); var rt = new RichTextFormatSimple(); rt.Text = "My text"; @@ -79,12 +82,30 @@ 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); + var textRun = new SvgTextRunRenderItem(baseGroup.Bounds, rt, rt.Text, true); + + //Add size of text since svg renders text upwards from the start point. + textRun.YPosition = rt.Size; + baseGroup.AddChildItem(textRun); + GenerateSvgFile("textRunStandAlone", baseGroup.Bounds, baseGroup); + } + + private void GenerateTextBodyFile(string fileName, GroupRenderItem baseGroup, SvgTextBodyRenderItem textBody) + { + StringBuilder sb = new StringBuilder(); + var svgShapeRenderer = new SvgShapeRenderer(baseGroup.Bounds, sb, new SvgRenderOptions()); + + var background = new RectRenderItem(baseGroup.Bounds); + + background.Width = baseGroup.Bounds.Width; + background.Height = baseGroup.Bounds.Height; + background.FillColor = "aliceBlue"; + + baseGroup.AddChildItem(textBody); + baseGroup.AddChildItem(background); List items = new List() { baseGroup }; @@ -93,49 +114,271 @@ public void SvgTextRun() var svg = sb.ToString(); - SaveTextFileToWorkbook("svg\\textRunStandAlone.svg", svg); + SaveTextFileToWorkbook($"svg\\{fileName}.svg", svg); + } + + + private SvgTextBodyRenderItem GenerateTextBody(GroupRenderItem baseGroup) + { + var textBody = new SvgTextBodyRenderItem(baseGroup.Bounds, true); + var paragraph = textBody.AddParagraph("Hello"); + + paragraph.AddText(" There"); + + var rtItem = new RichTextFormatSimple("Second paragraph", "Archivo Narrow", 16f, true); + 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); - StringBuilder sb = new StringBuilder(); - var svgShapeRenderer = new SvgShapeRenderer(bounds, sb); - + var baseGroup = GenerateGroupRenderItem(); + var textBody = GenerateTextBody(baseGroup); + GenerateSvgFile("standAloneTextBody", baseGroup.Bounds, baseGroup); + } - var baseGroup = new GroupRenderItem(bounds); + [TestMethod] + public void SvgTextBodyTestCenterAlignmentGenerated() + { + var baseGroup = GenerateGroupRenderItem(); - var background = new RectRenderItem(baseGroup.Bounds); + var textBody = GenerateTextBody(baseGroup); + textBody.Paragraphs[0].AddText(" a\r\n new day beckons"); + textBody.Paragraphs[0].HorizontalAlignment = RenderItems.Shared.TextAlignment.Center; - background.Width = bounds.Width; - background.Height = bounds.Height; - background.FillColor = "aliceBlue"; + textBody.Paragraphs[1].HorizontalAlignment = RenderItems.Shared.TextAlignment.Center; + textBody.Paragraphs[1].AddText("\r\n What fun, what fun!"); - baseGroup.Bounds.Width = bounds.Width; - baseGroup.Bounds.Height = bounds.Height; + //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(); - var textBody = new SvgTextBodyRenderItem(baseGroup.Bounds, true); - var paragraph = textBody.AddParagraph("Hello"); + 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, 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(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 + Assert.AreEqual(26.85546875d, textBody.Paragraphs[1].Bounds.Top); + GenerateSvgFile("textBodyAlignCenter", baseGroup.Bounds, baseGroup); + } + + [TestMethod] + public void SvgTextBodyTestRightAlignmentGenerated() + { + var baseGroup = GenerateGroupRenderItem(); + + 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(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); + } + + [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.Height = 500; + + textBody.Bounds.Top = 0; + 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); + } + + [TestMethod] + 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.Height = 500; + + textBody.Bounds.Top = 0; + 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); + } + + private RenderTextbox GenerateTextBox(out GroupRenderItem group) + { + 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 = textBody.AddParagraph(rtItem); + var para2 = textbox.TextBody.AddParagraph(rtItem); - baseGroup.AddChildItem(textBody); - baseGroup.AddChildItem(background); + textbox.Rectangle.FillColor = "#F9F6C4"; - List items = new List() { baseGroup }; - textBody.AppendRenderItems(items); + return textbox; + } - svgShapeRenderer.Render(items); + [TestMethod] + public void BasicTextBox() + { + var textbox = GenerateTextBox(out GroupRenderItem group); + textbox.AppendRenderItems(group.RenderItems); - var svg = sb.ToString(); + double delta = 0.001; + + 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); + } + + [TestMethod] + public void TextBoxWithMargins() + { + var textbox = GenerateTextBox(out GroupRenderItem group); + + 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(125.2, textbox.Width, delta); + Assert.AreEqual(44.979735851287842d, textbox.Height, delta); + + + GenerateSvgFile("MarginTextBox", group.Bounds, group); + } + + [TestMethod] + public void TextBoxWithAllMargins() + { + var textbox = GenerateTextBox(out GroupRenderItem group); + + double delta = 0.001; + + textbox.LeftMargin = 10d; + textbox.TopMargin = 10d; + textbox.RightMargin = 10d; + textbox.BottomMargin = 10d; + + textbox.AppendRenderItems(group.RenderItems); + + //Assert width and height changed by margins + Assert.AreEqual(135.2d, 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(150.2d, textbox.Width, delta); + Assert.AreEqual(69.979735851287842d, textbox.Height, delta); - SaveTextFileToWorkbook("svg\\textBodyStandAlone.svg", svg); + GenerateSvgFile("TextAnchor_TextBox", group.Bounds, group); } } } 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/Shape/ShapeToSvgTests.cs b/src/EPPlus.DrawingRenderer.Tests/Shape/ShapeToSvgTests.cs index 48c1479b8e..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() { @@ -497,6 +556,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 = 0; + 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() { @@ -598,12 +692,32 @@ public void GenerateShapeCenteredParagraph() _currentShape.GetSizeInPixels(out int testWidth, out int testHeight); - var svg = _currentShape.ToSvg(); SaveTextFileToWorkbook("svg\\centeredParagraph.svg", svg); 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() { @@ -637,20 +751,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.Tests/StyleTests.cs b/src/EPPlus.DrawingRenderer.Tests/StyleTests.cs index d98b49f066..89b24c08fd 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 { @@ -22,6 +23,61 @@ 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\\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() { @@ -32,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); @@ -51,18 +116,21 @@ 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.StyleManager.Style.Title.FontReference.Color.SetPresetColor(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; @@ -70,7 +138,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); @@ -87,6 +155,38 @@ 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); + + 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); + } + + } + [TestMethod] public void TextRunIsStyledButNotTitleFont() { 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/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.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 0000000000..8a53d24382 Binary files /dev/null and b/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.snk differ diff --git a/src/EPPlus.DrawingRenderer/Properties/AssemblyInfo.cs b/src/EPPlus.DrawingRenderer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..5ee4e11884 --- /dev/null +++ b/src/EPPlus.DrawingRenderer/Properties/AssemblyInfo.cs @@ -0,0 +1,15 @@ +/************************************************************************************************* + 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 System.Security; + +[assembly: AllowPartiallyTrustedCallers] 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/RenderItems/RenderItem.cs b/src/EPPlus.DrawingRenderer/RenderItems/RenderItem.cs index 9befe6cb01..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; } @@ -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.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 2211eeb9ab..ad2c1494e2 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; @@ -82,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; @@ -143,6 +148,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; @@ -170,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) @@ -229,15 +251,7 @@ protected void ImportLinesAndTextRunsBase(string textIfEmpty) } AddDefaultTextFragment(textIfEmpty); - - Bounds.Left = GetAlignmentHorizontal(HorizontalAlignment); - if (HorizontalAlignment == TextAlignment.Center) - { - _centerAdjustment = GetAlignmentHorizontal(HorizontalAlignment); - } - WrapTextFragmentsAndGenerateTextRuns(); - } protected void WrapTextFragmentsAndGenerateTextRuns() @@ -262,7 +276,9 @@ protected void WrapTextFragmentsAndGenerateTextRuns() widthOfLargestLine = Lines.LargestWidthWithoutSpace; combinedHeight = Lines.GetHeightOfCollection(_lsMultiplier, lineSpacingResult); - SetHorizontalAlignment(widthOfLargestLine); + + Bounds.Width = widthOfLargestLine + RightMargin; + //SetHorizontalAlignment(widthOfLargestLine); int lineIdx = 0; foreach (var line in Lines) @@ -294,7 +310,6 @@ protected void WrapTextFragmentsAndGenerateTextRuns() lineIdx++; } } - Bounds.Width = widthOfLargestLine; Bounds.Height = combinedHeight; } @@ -318,11 +333,11 @@ protected double CalculatePrevWidthBasedOnAlignment(double lineDist) protected void SetHorizontalAlignment(double widthOfLargestLine) { - if (HorizontalAlignment == TextAlignment.Center && AutoSize && _centerAdjustment != null && 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 { @@ -332,15 +347,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..49c3534530 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextBody.cs @@ -105,6 +105,9 @@ public void AppendRenderItems(List renderItems) //TranslationOffset.Top = Bounds.Top; renderItems.Add(this); + + var titleItem = new TitleRenderItem("TextBody group"); + AddChildItem(titleItem); foreach (var item in Paragraphs) { AddChildItem(item); @@ -125,6 +128,70 @@ 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; + } + } + + /// + /// If text is added to the first paragraph without using textbody e.g. Paragraphs[0].AddText() + /// Subsequent paragraphs must be updated + /// + public void RecalculateParagraphs() + { + if(Paragraphs != null && Paragraphs.Count != 0) + { + double lastParagraphBottom = Paragraphs[0].Bounds.Top; + + double smallestLeft = double.MaxValue; + double largestWidth = double.MinValue; + double totalHeight = 0; + + foreach (var paragraph in Paragraphs) + { + paragraph.Bounds.Top = lastParagraphBottom; + lastParagraphBottom = paragraph.Bounds.Bottom; + + smallestLeft = Math.Min(smallestLeft, paragraph.Bounds.Left); + largestWidth = Math.Max(largestWidth, paragraph.Bounds.Width); + totalHeight += paragraph.Bounds.Height; + } + + ContentBounds.Top = Paragraphs[0].Bounds.Top; + ContentBounds.Left = smallestLeft; + ContentBounds.Width = largestWidth; + ContentBounds.Height = totalHeight; + + if (AutoSize) + { + Bounds.Height = totalHeight; + Bounds.Width = ContentBounds.Width; + } + } + } + + /// + /// The total bounds of all paragraphs without margins + /// + protected BoundingBox ContentBounds = new BoundingBox(); + private void AdjustAndAddParagraph(ParagraphRenderItem paragraph) { paragraph.Bounds.Name = $"Container{Paragraphs.Count}"; @@ -147,6 +214,7 @@ private void AdjustAndAddParagraph(ParagraphRenderItem paragraph) } } Paragraphs.Add(paragraph); + RecalculateParagraphs(); } private double GetTopToAddNextParagraphAt() @@ -165,7 +233,7 @@ private double GetTopToAddNextParagraphAt() /// Get the start of text space vertically /// /// - protected double GetAlignmentVertical() + public double GetAlignmentVertical() { double alignmentY = 0; @@ -177,10 +245,13 @@ 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; + if(AutoSize == false) + { + 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.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RenderTextbox.cs index 2cbe96fe21..7939b54198 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,11 +28,13 @@ public RenderTextbox(BoundingBox parent, double maxWidth, double maxHeight) Init(parent, maxWidth, maxHeight); } - //Simplified input - public RenderTextbox(BoundingBox parent, BoundingBox maxBounds) - { - } - RectRenderItem _rectangle =null; + //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 { get @@ -39,83 +43,118 @@ public RectRenderItem Rectangle _rectangle.Bounds.Height = Height; return _rectangle; } + set + { + _rectangle = value; + } + } + + 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 virtual RenderTextBody TextBody {get;set;} 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 { get { - return LeftMargin + (TextBody?.Bounds?.Width ?? 0D) + RightMargin; + return LeftMargin + (TextBody?.Bounds?.Width ?? 0D) + RightMargin + TextBody.Left; } } + 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 { - return TopMargin + (TextBody?.Bounds.Height ?? 0d) + BottomMargin; + return TopMargin + (TextBody?.Bounds.Height ?? 0d) + BottomMargin + TextBody.Top; + } + } + 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; } } - internal double LeftMargin + public double LeftMargin { - get; set; + get { return _marginGroup.Left; } set { _marginGroup.Left = value; } } - internal double TopMargin + public double TopMargin { - get; set; + get { return _marginGroup.Top; } + set { _marginGroup.Top = value; } } - 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 { - return Rectangle.Bounds.Rotation; + return _group.Rotation; } set { - Rectangle.Bounds.Rotation = value; + _group.Rotation = value; } } /// /// 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 +162,7 @@ internal double GetActualWidth() /// Gets the actual right position of the rotated textbox. /// /// - internal double GetActualRight() + public double GetActualRight() { return Left+GetActualWidth(); } @@ -131,7 +170,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,19 +178,72 @@ internal double GetActualHeight() /// Gets the actual right position of the rotated textbox. /// /// - internal double GetActualBottom() + public double GetActualBottom() { return Top + GetActualHeight(); } 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 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; + + var marginTitleItem = new TitleRenderItem("TextBox Margin Group"); + _marginGroup.AddChildItem(marginTitleItem); + + _group.AddChildItem(_marginGroup); + TextBody.AppendRenderItems(_marginGroup.RenderItems); } /// /// How the text is anchored. /// - internal eTextAnchor TextAnchor + public eTextAnchor TextAnchor { get; set; diff --git a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs index 07c5e253e9..cc4df26c5f 100644 --- a/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs +++ b/src/EPPlus.DrawingRenderer/RenderItems/Textbox/RichTextFormatDrawing/RichTextFormatDrawing.cs @@ -101,5 +101,10 @@ public RichTextFormatDrawing(string text, string fontFamily, float size, bool bo StrikeType = eDrawingStrikeType.No; UnderlineType = eDrawingUnderLineType.None; } + + public RichTextFormatDrawing(FontFormatBase defaultParagraphFont) + { + SetFont(defaultParagraphFont); + } } } 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.DrawingRenderer/Svg/IRender.cs b/src/EPPlus.DrawingRenderer/Svg/IRender.cs index b426d81e16..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; } @@ -111,7 +122,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 +158,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 +260,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); @@ -607,7 +618,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.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.DrawingRenderer/Svg/SvgGroupRenderer.cs b/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs index d2216dd0f3..8f416409d6 100644 --- a/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs +++ b/src/EPPlus.DrawingRenderer/Svg/SvgGroupRenderer.cs @@ -20,7 +20,16 @@ public override void Render(GroupRenderItem item) { string combinedTransform = GetCombinedTransformString(item); - OutputStream.Append($""); + //Neccesary as fallback for e.g. DataLabels + string fillPropery = ""; + + //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}\" "; + } + + OutputStream.Append($""); foreach (var childItem in item.RenderItems) { 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/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/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.Tests/Integration/LayoutSystemTests.cs b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs index 68e01cc160..0374c10028 100644 --- a/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs +++ b/src/EPPlus.Fonts.OpenType.Tests/Integration/LayoutSystemTests.cs @@ -382,33 +382,34 @@ public void TestGetSectionNumber2() } [TestMethod] - public void TestSimpleRichText2() + public void TestLayoutSystemMultipleParagraphs() { - //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"); + var paragraphEndSymbol = '\u2029'; - //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; + 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 font2 = new FontFormatBase() + { + Family = "Aptos Narrow", + Size = 11, + SubFamily = FontSubFamily.Italic + }; - //someTextRt.FontData.Family = "Archivo Narrow"; + var fragments = new List() + { + new TextFragment() {Text = lstOfRichText[0], Font = font }, + new TextFragment() {Text = lstOfRichText[1], Font = font2 } + }; - //richRt + var layout = new LayoutSystem(fragments); + Assert.AreEqual(5, layout.GetParagraphSeparatorCount()); } } } 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; } 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); diff --git a/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs b/src/EPPlus.Fonts.OpenType/OpenTypeFontEngine.cs index 511ccd1f5a..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.NotFound; - 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.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.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 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/Core/AutofitHelper.cs b/src/EPPlus/Core/AutofitHelper.cs index c5b2127b8a..098bfa6508 100644 --- a/src/EPPlus/Core/AutofitHelper.cs +++ b/src/EPPlus/Core/AutofitHelper.cs @@ -27,6 +27,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 }; @@ -134,9 +136,19 @@ internal void AutofitColumn(double MinimumWidth, double MaximumWidth) { if (af.Collide(fromRow, col, toRow, col) != eAddressCollition.No) { - var cell = worksheet.Cells[af.Address]; + var cell = worksheet.Cells[af._fromRow, col]; var cellStyleId = styles.CellXfs[cell.StyleID]; currentMaxWidth = GetTextLength(cell, textLengthCache, styles, cellStyleId, normalSize, MaximumWidth, currentMaxWidth); + // Reserve room for the autofilter dropdown arrow. The arrow is a fixed-size UI + // element (~15px at 96 DPI), so it is added as a constant converted to column + // width units (normalSize = width of the normal font's reference char in pixels). + // It is intentionally not affected by AutofitScaleFactor, since the arrow's + // pixel size does not shrink when the user requests tighter text margins. + currentMaxWidth += AutoFilterArrowWidthPixels / normalSize; + if (currentMaxWidth >= MaximumWidth) + { + currentMaxWidth = MaximumWidth; + } } } foreach (var cell in worksheet.Cells[fromRow, col, toRow, col]) diff --git a/src/EPPlus/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/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs b/src/EPPlus/Drawing/Chart/ExcelChartAxisStandard.cs index 4ce29e17e2..7d4174febf 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; @@ -158,6 +159,86 @@ 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 eActualAxisPosition ActualAxisPosition + { + get + { + var ap = AxisPosition; + if (ap == eAxisPosition.Left && LabelPosition == eTickLabelPosition.High) + { + return eActualAxisPosition.Right; + } + else if (ap == eAxisPosition.Bottom) + { + if (LabelPosition == eTickLabelPosition.High) + { + return eActualAxisPosition.Top; + } + else + { + return eActualAxisPosition.Bottom; + } + } + else if (ap == eAxisPosition.Right) + { + if (LabelPosition == eTickLabelPosition.Low) + { + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition != eTickLabelPosition.Low) + { + return eActualAxisPosition.Left; + } + else + { + return eActualAxisPosition.LeftSecond; + } + } + else + { + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Left)?.LabelPosition != eTickLabelPosition.High) + { + return eActualAxisPosition.Right; + } + else + { + return eActualAxisPosition.RightSecond; + } + } + } + else if(ap==eAxisPosition.Top) + { + if (LabelPosition == eTickLabelPosition.Low) + { + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition != eTickLabelPosition.Low) + { + return eActualAxisPosition.Bottom; + } + else + { + return eActualAxisPosition.BottomSecond; + } + } + else + { + if (_chart.Axis.FirstOrDefault(x => x.AxisPosition == eAxisPosition.Bottom)?.LabelPosition != eTickLabelPosition.High) + { + return eActualAxisPosition.Top; + } + else + { + return eActualAxisPosition.TopSecond; + } + } + } + else + { + return (eActualAxisPosition)ap; + } + } + } + /// /// Chart axis title /// public new ExcelChartTitleStandard Title 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/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/Chart/ExcelChartTitle.cs b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs index 1d41c5b709..abc58781fa 100644 --- a/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs +++ b/src/EPPlus/Drawing/Chart/ExcelChartTitle.cs @@ -148,11 +148,12 @@ 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: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(); + + //Set property without updating xml + _textBody.Paragraphs.FirstDefaultRunProperties = DefaultTextBody.Paragraphs.FirstDefaultRunProperties; } return _textBody; } @@ -249,7 +250,28 @@ 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) + //{ + // var para = _richText.Add(""); + // _richText.Remove(para); + //} } private ExcelChartStyleEntry GetStylePart() @@ -389,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"); } @@ -437,9 +482,33 @@ 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) _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); + + //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); + } } } /// @@ -463,6 +532,7 @@ public string DisplayedText { combinedString += address.Text + separator; } + return combinedString; } return LinkedCell.Text; } 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/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/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/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index c181bfb6f3..6f3713f655 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -11,19 +11,18 @@ 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; using OfficeOpenXml.Drawing.OleObject; 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.Packaging; -using OfficeOpenXml.Utils; using OfficeOpenXml.Utils.Drawings; using OfficeOpenXml.Utils.EnumUtils; using OfficeOpenXml.Utils.FileUtils; @@ -34,9 +33,9 @@ Date Author Change using System.Globalization; using System.IO; using System.Linq; -using System.Security.Cryptography; using System.Text; using System.Xml; +using static Microsoft.IO.RecyclableMemoryStreamManager; namespace OfficeOpenXml.Drawing { @@ -348,6 +347,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) { @@ -360,6 +371,10 @@ public virtual string Name pts.SlicerName = value; } } + catch (ArgumentException) + { + throw; + } catch { throw new NotImplementedException(); @@ -543,7 +558,7 @@ public Uri Hyperlink DeleteNode(_hyperLinkPath); if (HypRel != null) { - _drawings._package.ZipPackage.DeletePart(UriHelper.ResolvePartUri(HypRel.SourceUri, HypRel.TargetUri)); + _drawings.Part.DeleteRelationship(HypRel.Id); } } @@ -558,7 +573,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); } @@ -800,7 +815,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) { @@ -830,7 +845,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) @@ -940,13 +955,20 @@ internal void SetPixelTop(double pixels) _doNotAdjust = true; if (CellAnchor == eEditAs.Absolute) { - if (_collectionType == DrawingsCollectionType.Worksheet) + if (_drawings._collectionType == DrawingsCollectionType.Chart) { - Position.Y = (int)(pixels * EMU_PER_PIXEL); + if (From == null) + { + Position.Y = (int)(pixels * EMU_PER_PIXEL); + } + else + { + From.Y = pixels / _drawings._screenHeight; + } } else { - From.Y= (double)pixels/_drawings._screenHeight; + Position.Y = (int)(pixels * EMU_PER_PIXEL); } } else @@ -989,13 +1011,20 @@ internal void SetPixelLeft(double pixels) _doNotAdjust = true; if (CellAnchor == eEditAs.Absolute) { - if (_collectionType == DrawingsCollectionType.Worksheet) + if (_drawings._collectionType == DrawingsCollectionType.Chart) { - Position.X = (int)(pixels * EMU_PER_PIXEL); + if(From==null) + { + Position.X = (int)(pixels * EMU_PER_PIXEL); + } + else + { + From.X = pixels / _drawings._screenWidth; + } } else { - From.X = (double)pixels / _drawings._screenWidth; + Position.X = (int)(pixels * EMU_PER_PIXEL); } } else @@ -1155,7 +1184,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++); @@ -1278,6 +1307,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; } @@ -1549,8 +1583,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) { @@ -1565,12 +1609,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) { @@ -2487,6 +2537,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 _); @@ -2499,24 +2586,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/ExcelGroupShape.cs b/src/EPPlus/Drawing/ExcelGroupShape.cs index a50b3a8a9f..9cb0fc47c1 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(); } @@ -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); @@ -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/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/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/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/Chart/ChartAxisRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartAxisRenderer.cs index 7f3eb4c721..2bbb9fafb4 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,41 +85,29 @@ 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(); } 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; } } 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; } } 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; @@ -203,7 +192,7 @@ private double GetTextWidest(ExcelChartAxisStandard ax) widest = m.Width; } } - return widest.PointToPixel(); + return widest; } public List Values @@ -289,7 +278,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)) @@ -386,7 +375,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); } @@ -398,7 +387,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; } @@ -413,7 +402,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; } @@ -422,7 +411,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; } @@ -437,10 +426,10 @@ 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); + tb.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, Axis.Fill, axisStyle?.FillReference.Color, true); if(widest < tb.Width) { @@ -470,7 +459,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) { @@ -479,12 +468,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; } } @@ -507,6 +497,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 +553,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) { @@ -563,7 +564,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) { @@ -662,27 +663,31 @@ 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)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)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)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)Rectangle.Top - tickMarkWidthInside; @@ -696,7 +701,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; @@ -799,7 +804,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; @@ -837,13 +842,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; } @@ -1096,6 +1101,21 @@ private void AdjustminMaxFromChartObjects(ExcelChartAxisStandard ax, ref double? } } } + if(drawer.SupportsErrorBars && drawer.ErrorBars!=null) + { + 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/ChartDrawingObject.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs index 2e73aeeb69..67b461e6a1 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartDrawingObject.cs @@ -12,20 +12,32 @@ 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; 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 { 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); } @@ -56,7 +68,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; @@ -88,5 +100,28 @@ 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 febf0e50b5..7d53028a61 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartLegendRenderer.cs @@ -11,13 +11,15 @@ Date Author Change 27/11/2025 EPPlus Software AB EPPlus 9 *************************************************************************************************/ using EPPlus.DrawingRenderer.RenderItems; -using EPPlus.Export.ImageRenderer.Svg; +using EPPlus.Export.Utils; 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.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.Style; using System; @@ -54,17 +56,17 @@ 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: 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; @@ -82,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, 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); SetLegendTrendlines(entryWidth, entryHeight, pSls); @@ -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; @@ -150,7 +182,7 @@ private RectRenderItem GetLegendRectangleAndEntrySize(ExcelChartLegend l, out do { widestLine = width + RightMargin; } - width = RightMargin + widest; + width = RightMargin + entryWidth; } else { @@ -249,18 +281,18 @@ 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; } } - 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; @@ -338,20 +375,57 @@ internal DrawingLegendSerie SetLegendSeries(double entryWidth, double entryHeigh break; } } - SeriesIcon.Add(sls); + 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 x.Index == entryIndex); var headerText = tl.GetName(serieIndex); @@ -432,55 +506,78 @@ private void SetTrendlineLegend(ExcelChart ct, int serieIndex, int entryIndex, D private void SetPieLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLegendPosition pos, ExcelChartSerie s, DrawingLegendSerie sls, double entryWidth, double entryHeight, double maxIconLength) { var ps = (ExcelPieChartSerie)s; + pSls = null; + + //Pie chart only cares about series 0 + var series = ct.Series[0]; + var catSeries = series.XSeries; + var catValues = DrawingExtensions.LoadSeriesValues(ct, catSeries, series.NumberLiteralsX, series.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}"); + } + } - var si = GetLineSeriesIcon(ct, ps, pSls, entryWidth, entryHeight); - sls.SeriesIcon = si; + double lastWidth = 0; + double totalWidth = 0; - 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) / 2); + + double tbWidth; + + if(i != catValues.Count -1) + { + tbWidth = Rectangle.Bounds.Width - tbLeft; + } + else + { + tbWidth = Rectangle.Bounds.Width; + } - //Cat values are the header text - //They create a rect marker for each slice + 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(); - //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); - //} + tbWidth = sls.Textbox.Width; - //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; - // } - //} + lastWidth = tbWidth + si.Width; + + totalWidth += tbWidth + si.Width + maxIconLength + MarginIconText; + + 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; + } + + 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; } private void SetLineLegend(ExcelChart ct, int index, DrawingLegendSerie pSls, eLegendPosition pos, ExcelChartSerie s, DrawingLegendSerie sls, double entryWidth, double entryHeight, double maxIconLength) @@ -495,7 +592,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 +638,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); @@ -561,8 +658,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); @@ -579,8 +676,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); @@ -595,15 +692,46 @@ 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); - 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; @@ -619,8 +747,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; } @@ -631,7 +759,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; diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs index ce905cfc0b..b9c0e1f9c5 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartPlotareaRenderer.cs @@ -59,18 +59,19 @@ 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, 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; } 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.BottomSecond); + 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?.TextBox.Width ?? 0D; + } + else + { + if (leftAxis.Title!=null) { left += leftAxis.Title.TextBox.GetActualWidth(); } @@ -138,30 +146,52 @@ private double GetPlotAreaLeft() { left += leftAxis.Rectangle.Width + 1.5; } + if(leftSecondAxis!=null) + { + left += leftSecondAxis.Rectangle.Width; + } } return left; } 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 e31fed1184..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) @@ -112,21 +112,29 @@ 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; + // Rectangle.Left = GetHorizontalLeft(sc); + // break; + //case eActualAxisPosition.RightSecond: + // Rectangle.Top = sc.GetPlotAreaTop(); + // Rectangle.Left = sc.VerticalAxis.Rectangle.Right; + // break; } } @@ -183,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; @@ -191,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 { @@ -210,12 +217,16 @@ 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(); - 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 d91018e3b1..50b96224eb 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/BarColumnChartTypeDrawer.cs @@ -1,18 +1,15 @@ 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.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 @@ -21,12 +18,14 @@ 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>(); _valValues = new List>(); + int serCounter = 0; + foreach (ExcelBarChartSerie serie in chartType.Series) { List valValue,catValue; @@ -35,13 +34,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()) @@ -54,6 +46,7 @@ internal BarColumnChartTypeDrawer(ChartRenderer svgChart, ExcelBarChart chartTyp } CreateTrendlines(chartType, _catValues, _valValues); + CreateErrorBars(chartType, _catValues, _valValues); } internal override void DrawSeries() @@ -71,13 +64,66 @@ 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; + + var isColumn = ((ExcelBarChart)_chartType).IsTypeColumn(); + + 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++) + { + //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); + + if (chartBaseY <= dataPoints[j].Top) + { + //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 + { + //We are a positive column + // _ + // | | Col + // ----- Base-Axis + basePoint.Position = new Vector2(middleRight, dataPoints[j].Bottom); + endPoint.Position = new Vector2(middleRight, dataPoints[j].Top); + } + + serieDataLabels[i].SetDimensions(j, basePoint, endPoint); + } + else + { + var middleHeight = dataPoints[j].Top + (dataPoints[j].Height / 2); + 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); + } + + serieDataLabels[i].SetDimensions(j, basePoint, endPoint); + } + } + + serCounter++; + } } foreach (var tr in Trendlines) @@ -101,6 +147,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); @@ -119,32 +167,44 @@ 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; - double yAxisStart; if (catAx.Axis.Crosses == eCrosses.AutoZero) { - yAxisStart = valAx.GetPositionInPlotarea(valAx.Min <= 0 ? 0D : valAx.Min, true); + chartBaseY = valAx.GetPositionInPlotarea(valAx.Min <= 0 ? 0D : valAx.Min, true); } else if (catAx.Axis.Crosses == eCrosses.Min) { - yAxisStart = valAx.GetPositionInPlotarea(valAx.Min, true); + chartBaseY = valAx.GetPositionInPlotarea(valAx.Min, true); } else { - yAxisStart = valAx.GetPositionInPlotarea(valAx.Max, true); + chartBaseY = valAx.GetPositionInPlotarea(valAx.Max, true); } var isStacked = chartType.IsTypeStacked(); @@ -165,23 +225,38 @@ 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 se = sampleStdDev / Math.Sqrt(n); + double y = _ySerie[i]; + Values.Add(new double[] { y - se, y, y + se }); + } + else + { + var mult = errorbars.Value ?? 1D; + Values.Add(new double[] { avg - sampleStdDev, avg, 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, 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, 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, y + plus }); + } + + break; + } + } + public double GetCustomValue(List l, int i) + { + if(l.Count==1) + { + return l[0]; + } + else if(i GetErrorBarRenderItem(int index, ChartAxisRenderer xAxis, ChartAxisRenderer yAxis, double x, double y, double xPos, double yPos) + { + //var path = new PathRenderItem(ChartRenderer.Bounds); + var l = new List(); + double topValue=0, bottomValue=0; + if (_errorbars.BarType == eErrorBarType.Plus || _errorbars.BarType == eErrorBarType.Both) + { + topValue = Values[index][2]; + } + if(_errorbars.BarType == eErrorBarType.Minus || _errorbars.BarType == eErrorBarType.Both) + { + bottomValue = Values[index][0]; + } + + //if (_errorbars.Direction == eErrorBarDirection.X ) + if(_chartType.IsTypeBar()) + { + var rightPos = yAxis.GetPositionInPlotarea(topValue); + var centerPos = yAxis.GetPositionInPlotarea(Values[index][1]); + 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 = xPos, + X2 = centerPos, + Y2 = xPos + }; + + //Top line + //path.Commands.Add(new PathCommands(PathCommandType.Move, centerPos, yPos, rightPos, yPos)); + var tl = new LineRenderItem(ChartRenderer.Bounds) + { + X1 = centerPos, + Y1 = xPos, + X2 = rightPos, + Y2 = xPos + }; + 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 = xPos - 3, + X2 = leftPos, + 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 = xPos - 3, + X2 = rightPos, + Y2 = xPos + 3 + }; + l.Add(bc); + l.Add(tc); + } + } + else + { + 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)); + 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 l; + } + } +} diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/ChartTypeDrawer.cs index 4911da7fef..5b164ad296 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) + { + if (!(chartType.IsTypeLine() || chartType.IsTypeColumn() || chartType.IsTypeBar())) return; + + var serieIndex = 0; + foreach (ExcelChartSerieWithErrorBars 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 6c6785f842..1c368a47d7 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/LineChartTypeDrawer.cs @@ -6,10 +6,12 @@ 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; using System.Collections.Generic; +using System.Linq; namespace EPPlus.Export.ImageRenderer.Svg.Chart { @@ -20,6 +22,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(); @@ -31,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()) { @@ -48,6 +55,7 @@ internal LineChartTypeDrawer(ChartRenderer svgChart, ExcelLineChart chartType) : } CreateTrendlines(chartType, _xValues, _yValues); + CreateErrorBars(chartType, _xValues, _yValues); } internal override void DrawSeries() { @@ -134,7 +142,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; @@ -162,8 +173,11 @@ private void AddLine(ExcelChart chartType, ExcelLineChartSerie serie, List renderItems) { renderItems.AddRange(ChartAreaRenderItems); diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/PieChartTypeDrawer.cs index 4ac761a78b..9c4ec5975e 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,16 +170,11 @@ 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); 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 @@ -189,6 +184,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 @@ -204,23 +205,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; } } @@ -234,18 +235,23 @@ 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) { - 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); + + var ctrToMid = Slices[j].GetWholeVectorCenterToMid(); + var startPt = new Transform(); + startPt.Parent = innerGroup.Parent; + startPt.Position = innerGroup.Position + (ctrToMid * -1); - serieDataLabels[i].SetParentVector(dlblBounds, j, Slices[j].GetWholeVectorCenterToMid()); + serieDataLabels[i].SetDimensions(j, startPt, innerGroup); } } } @@ -263,8 +269,11 @@ 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); + if(SeriesRenderItems != null && SeriesRenderItems.Count > 0) + { + ChartRenderer.RenderItems.Add(SeriesRenderItems[0]); + } } } } diff --git a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs index fa9ad5c115..4e11687cd4 100644 --- a/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/ChartTypeDrawers/Trendlines/ChartTrendlineRenderer.cs @@ -12,6 +12,8 @@ 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; @@ -47,8 +49,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(); @@ -92,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) @@ -157,7 +137,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) { @@ -171,15 +152,16 @@ private void CreateDatalabel() } labelText += RSquare; } - lbl.TextBody.Paragraphs[0].HorizontalAlignment = OfficeOpenXml.Drawing.eTextAlignment.Center; - DataLabel.ImportParagraph(lbl.TextBody.Paragraphs[0], 0, labelText); + //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; //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 { @@ -206,17 +188,46 @@ private void CreateDatalabel() } - DataLabel.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, _trendline.Label.Fill, Chart.StyleManager.Style.TrendlineLabel.FillReference.Color); + DataLabel.Rectangle.SetDrawingPropertiesFill(ChartRenderer.Theme, _trendline.Label.Fill, Chart.StyleManager.Style.TrendlineLabel.FillReference.Color, true); DataLabel.Rectangle.SetDrawingPropertiesBorder(ChartRenderer.Theme, _trendline.Label.Border, Chart.StyleManager.Style.TrendlineLabel.BorderReference.Color, true, _trendline.Label.Border.Width); DataLabel.Rectangle.SetDrawingPropertiesEffects(ChartRenderer.Theme, _trendline.Label.Effect); } + private void AddLblText(DrawingTextBox lbl, string labelText) + { + int pIx = 0; + var ix = labelText.IndexOf("|ss:"); + + 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); + } + + //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)); + } + + //Should probably be a callback + lbl.TextBody.RecalculateParagraphs(); + lbl.TextBody.Top = lbl.TextBody.GetAlignmentVertical(); + } + 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 +235,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 +250,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++) { @@ -303,8 +319,8 @@ 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}|"; - RSquare = $"R²={r2:N4}"; + Formula = $"y = {intercept:G5}e|ss:{slope:G3}x| "; + RSquare = $"R² = {r2:N4} "; } private void CalculateLogarithmic() { @@ -330,8 +346,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() { @@ -454,9 +470,9 @@ 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}"; + RSquare = $"R² = {r2:N4}"; } private void CalculatePower() @@ -477,10 +493,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:G5}|"; 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() @@ -509,25 +525,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(); @@ -647,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); } @@ -739,24 +755,25 @@ private double GetMonthlyAverageAtPosition(double x) { if (_ma == null) { - CalcMa(); + CalcMa((int)_trendline.Period); } int ix = (int)(x - _trendline.Period + 1); return _ma[ix]; } - private void CalcMa() + private void CalcMa(int period) { _ma= new 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/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/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs b/src/EPPlus/Drawing/Renderer/Chart/DataLabels/ChartSerieDataLabelRenderer.cs index fd3dd52aa4..8b44fceca3 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,19 +24,22 @@ 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; _dlblSerie = dlblSerie; - plotAreaBounds = chart.Plotarea.Rectangle.Bounds; + plotAreaBounds = chart.Plotarea.Group.Bounds; 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) { @@ -47,16 +52,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); } - //} + } } } @@ -102,50 +121,51 @@ 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 SetParentVector(BoundingBox parentPoint, int index, Vector2 startToEndDir) + internal void SetDimensions(int index, Transform basePoint, Transform endPoint) { - _startToEndDir = startToEndDir; - SetParentPoint(parentPoint, index); + if (dataLabels.Count > index) + { + dataLabels[index].SetShapeDimensions(basePoint, endPoint); + } } 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) { var plotAreaGroup = new GroupRenderItem(plotAreaBounds); - if(_dlblSerie.Fill.IsEmpty == false) + 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.SetDrawingPropertiesFill(ChartRenderer.Theme, _dlblSerie.Fill, null); - plotAreaGroup.GroupTransform += $" fill=\"{plotAreaGroup.FillColor}\""; } 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 db3db6cfea..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; @@ -28,6 +27,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) //{ @@ -104,23 +104,32 @@ 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); + 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) { - 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); } + txtBox.TextBody.RecalculateParagraphs(); + if(txtBox.LeftMargin == 0) { txtBox.LeftMargin = defaultMargins.Left; @@ -147,6 +156,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"; + //} + if (dataLabel.Font.IsEmpty == false) { txtBox.TextBody.FontColorString = "#" + dataLabel.Font.Color.ToColorString(); @@ -167,12 +182,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; - Rectangle.Bounds.Left += _manualLayoutOffset.X; - Rectangle.Bounds.Top += _manualLayoutOffset.Y; + _manualLayoutOffset = new Coordinate(rect.Left, rect.Top); + + 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) { @@ -210,10 +233,6 @@ private int GetClosestConnectionPointCoordinateIndex(Coordinate originPoint) return smallestIndex; } - BoundingBox _parentShapeBounds = null; - - - private void SetPositionBasic(BoundingBox point, eLabelPosition basicPosition) { switch (basicPosition) @@ -223,68 +242,192 @@ 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/2) + (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"); } } - internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, Vector2 startToEndDir) + + RectRenderItem originPointRect; + RectRenderItem basePositionRect; + RectRenderItem endPositionRect; + RectRenderItem centerPositionRect; + + + private RectRenderItem GenerateDebugRenderItem(BoundingBox parent, string fillColor) { - Rectangle.Bounds.Parent = parentPoint; - _parentPoint = parentPoint; - _parentShapeBounds = parentShape; + var pointRect = new RectRenderItem(parent); + pointRect.Width = 10d; + pointRect.Height = 10d; + pointRect.FillColor = fillColor; + pointRect.Left = -5d; + pointRect.Top = -5d; + return pointRect; + } - var dataLabelCenter = new Vector2(Rectangle.Bounds.Left, Rectangle.Bounds.Top); - Vector2 startPointDirection = Vector2.Zero; - if ((startToEndDir.X == 0 && startToEndDir.Y == 0) == false) + private void CreateDebugPoints(Transform basePoint, Transform endPoint, Transform centerPoint) + { + 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"; + centerPositionRect = GenerateDebugRenderItem(_parentPoint, "Purple"); + centerPositionRect.Left += centerPoint.LocalPosition.X; + centerPositionRect.Top += centerPoint.LocalPosition.Y; + } + private void SetAdjustedTextBoxPosition(Vector2 direction, bool reverseDirection) + { + if(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); + + //Apply translation to current position + Rectangle.Bounds.Position += directionOnly * txtBoxAdjustVector; + } + + private void SetInOut(Vector2 direction, Vector2 translation, bool reverseDirection) + { + //Translate to the translation point + Rectangle.Bounds.Position += translation; + SetAdjustedTextBoxPosition(direction, reverseDirection); + } + + /// + /// + /// + internal void SetShapeDimensions(Transform basePoint, Transform endPoint) + { + if(basePoint.Parent != endPoint.Parent) { - startPointDirection = startToEndDir / startToEndDir.Length; + throw new InvalidOperationException("basePoint and endPoint have different parents. " + + "Please ensure that they share the same parent"); } - //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) - { + + //--- 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, centerPoint); + //--- + + switch (_labelPosition) + { case eLabelPosition.Center: + Rectangle.Bounds.Left += centerPoint.LocalPosition.X; + Rectangle.Bounds.Top += centerPoint.LocalPosition.Y; + break; + case eLabelPosition.Left: + SetPositionBasic(_parentPoint, _labelPosition); + break; + case eLabelPosition.Right: + SetPositionBasic(_parentPoint, _labelPosition); + break; + case eLabelPosition.Top: + SetPositionBasic(_parentPoint, _labelPosition); + break; + case eLabelPosition.Bottom: + SetPositionBasic(_parentPoint, _labelPosition); + break; + case eLabelPosition.InBase: + SetInOut(endToBaseVector, basePoint.LocalPosition, true); + break; + case eLabelPosition.InEnd: + SetInOut(endToBaseVector, endPoint.LocalPosition, false); + break; + case eLabelPosition.OutEnd: + SetInOut(endToBaseVector, endPoint.LocalPosition, true); + 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? - if ((startToEndDir.X == 0 && startToEndDir.Y == 0) == false) + //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 { - //Half and invert - dataLabelCenter = ((startToEndDir*0.5d) * -1d); + //Set outside end + SetInOut(endToBaseVector, endPoint.LocalPosition, true); } - Rectangle.Bounds.Left += dataLabelCenter.X; - Rectangle.Bounds.Top += dataLabelCenter.Y; + break; + default: + throw new InvalidOperationException($"The datalabel position {_labelPosition} has not been implemented yet"); + } + } + + internal void SetParentPoint(BoundingBox parentPoint) + { + Rectangle.Bounds.Parent = parentPoint; + _parentPoint = parentPoint; + + var dataLabelCenter = new Vector2(Rectangle.Bounds.Left, Rectangle.Bounds.Top); + + switch (_labelPosition) + { + case eLabelPosition.Center: + Rectangle.Bounds.Left += Rectangle.Bounds.Left; + Rectangle.Bounds.Top += Rectangle.Bounds.Top; break; case eLabelPosition.Left: SetPositionBasic(parentPoint, _labelPosition); @@ -299,91 +442,6 @@ internal void SetParentPoint(BoundingBox parentPoint, BoundingBox parentShape, V case eLabelPosition.Bottom: 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); - // } - //} - 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 (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"); } @@ -458,24 +516,50 @@ private void AppendDebugBounds(List renderItems) public override void AppendRenderItems(List renderItems) { var parentPointGroup = new GroupRenderItem(_parentPoint); - renderItems.Add(parentPointGroup); + parentPointGroup.Left = _parentPoint.Left; + parentPointGroup.Top = _parentPoint.Top; var titleItemOrigin = new TitleRenderItem("DataLabel originpoint"); - renderItems.Add(titleItemOrigin); + 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); - parentPointGroup.RenderItems.Add(group); + group.Left = Rectangle.Bounds.Left; + group.Top = Rectangle.Bounds.Top; var titleItem = new TitleRenderItem("DataLabel size adjustment"); - parentPointGroup.RenderItems.Add(titleItem); + group.AddChildItem(titleItem); + + parentPointGroup.RenderItems.Add(group); + + group.RotationPoint = new Graphics.Point(_txtBox.Left + (_txtBox.Width / 2), _txtBox.Top + (_txtBox.Height / 2)); + group.Rotation = CounterRotation; - _txtBox.AppendRenderItems(parentPointGroup.RenderItems); + _txtBox.AppendRenderItems(group.RenderItems); if(_renderConnectionPointLines) { if (_connectionPointLines != null) { - _connectionPointLines.AppendRenderItems(parentPointGroup.RenderItems); + _connectionPointLines.AppendRenderItems(group.RenderItems); } } @@ -487,7 +571,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/DrawingLegendSerie.cs b/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs similarity index 93% rename from src/EPPlus/Drawing/Renderer/DrawingLegendSerie.cs rename to src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs index 4809d8098b..f1aea585d8 100644 --- a/src/EPPlus/Drawing/Renderer/DrawingLegendSerie.cs +++ b/src/EPPlus/Drawing/Renderer/Chart/DrawingLegendSerie.cs @@ -18,9 +18,9 @@ Date Author Change namespace EPPlusImageRenderer.Svg { - internal class DrawingLegendSerie : SvgLegendSeriesIcon + 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/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 82% rename from src/EPPlus/Drawing/Renderer/LineMarkerHelper.cs rename to src/EPPlus/Drawing/Renderer/Chart/LineMarkerHelper.cs index 7a00fbe32c..42949125f1 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 { @@ -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; @@ -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); + 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/Chart/PieSliceRenderItem.cs b/src/EPPlus/Drawing/Renderer/Chart/PieSliceRenderItem.cs index 4570bd81ed..fb58373378 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; @@ -58,11 +57,15 @@ 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; bool ExistWithinRange(double target, double min, double max) { @@ -151,24 +154,12 @@ 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; private double _scaledRadius { get { return _radius * _sliceScaleFactor; } } + private void CalculateExplosionDir() { var transformOriginLocal = new Vector2(_innerGroup.TransformOrigin.X, _innerGroup.TransformOrigin.Y); @@ -182,11 +173,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; @@ -249,6 +235,7 @@ internal void ImportPathData(BoundingBox plotAreaBounds, BoundingBox globalAreaB _slicePath.Commands.Add(lineToStart); _slicePath.Commands.Add(arcCommand); + //Change to != -1 to activate debug items if (position == -1) { //Visualize all points @@ -266,39 +253,59 @@ 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); + DebugItems = new List(); + + //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(); - //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(end); + _debugBoundsPath.Commands.Add(end); + + DebugItems.Add(_debugBoundsPath); + + //Render green dot at circle center + _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); @@ -308,15 +315,21 @@ 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 _innerGroup.AddChildItem(_innerItems); - if (_debugBoundsPath != null) + if (DebugItems != null && DebugItems.Count > 0) { - //adding the debug lines - _innerGroup.AddChildItem(_debugBoundsPath); + foreach(var debugItem in DebugItems) + { + _innerGroup.AddChildItem(debugItem); + } } //The group containing all slices @@ -476,12 +489,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); @@ -519,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; @@ -533,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) { diff --git a/src/EPPlus/Drawing/Renderer/ChartRenderer.cs b/src/EPPlus/Drawing/Renderer/ChartRenderer.cs index e37f6fec9a..194e4b6317 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) { @@ -84,15 +85,25 @@ 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); } 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 (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; @@ -112,12 +123,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) { @@ -125,60 +136,143 @@ private void PlaceHorizontalAxis(ChartAxisRenderer horizontalAxis) 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.Rectangle.Top - horizontalAxis.Rectangle.Height; + horizontalAxis.Rectangle.Top = Plotarea.Group.Top + Plotarea.Rectangle.Height + HorizontalAxis.Rectangle.Height; + horizontalAxis.Line.Y1 = horizontalAxis.Line.Y2 = horizontalAxis.Rectangle.Top; + } + 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 = horizontalAxis.Rectangle.Bottom; + } } if (horizontalAxis.Title != null) { - horizontalAxis.Title.Rectangle.Height = Bounds.Height / 4; - horizontalAxis.Title.Rectangle.Width = horizontalAxis.Rectangle?.Width ?? Plotarea.Rectangle.Width; - //horizontalAxis.Title.InitTextBox(); + 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) + { + 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) { - horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Bottom; + if (SecondHorizontalAxis != null && 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 = horizontalAxis.Rectangle.Bottom; + } + else + { + 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 { - horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Top - horizontalAxis.Title.TextBox.Height; + if (SecondHorizontalAxis.Axis.ActualAxisPosition == eActualAxisPosition.TopSecond) + { + horizontalAxis.Title.TextBox.Top = horizontalAxis.Rectangle.Top - SecondHorizontalAxis.Rectangle.Height - horizontalAxis.Title.TextBox.Height; + } + 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; verticalAxis.Line.Y1 = (float)verticalAxis.Rectangle.Top; verticalAxis.Line.Y2 = (float)verticalAxis.Rectangle.Bottom; - if (verticalAxis.Axis.AxisPosition == 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; } } + 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); @@ -191,7 +285,7 @@ private void PlaceVerticalAxis(ChartAxisRenderer verticalAxis) } else { - verticalAxis.Title.TextBox.Left = verticalAxis.Rectangle.Left - verticalAxis.Title.TextBox.GetActualWidth() - 1.5; + verticalAxis.Title.TextBox.Left = Plotarea.Group.Left - verticalAxis.Rectangle.Width - verticalAxis.Title.TextBox.GetActualWidth() - 1.5; } } else @@ -208,14 +302,16 @@ private void PlaceVerticalAxis(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; - 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, 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); ChartArea = item; } private ChartAxisRenderer GetAxis(bool vertical, int offset = 0) @@ -304,8 +400,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 b99f7e277d..0ef5dde160 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, bool gradientUserSpace = false, ExcelDrawingThemeColorManager nullColor = null) { switch (fill.Style) { @@ -43,19 +43,19 @@ 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, gradientUserSpace, nullColor); 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, bool gradientUserSpaceOnUse, ExcelDrawingThemeColorManager nullColor) { double? opacity = null; switch (fill.Style) { case eFillStyle.NoFill: - if (fill.IsEmpty) + 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, null); } else { @@ -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,29 +75,39 @@ 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; - switch (border.Fill.Style) + if (border == null) { - case eFillStyle.NoFill: - if (border.Fill.IsEmpty) - { + if (hasBorder) + { + item.BorderColor = GetFillColor(theme, null, color, item.BorderColorSource, out opacity, theme.ColorScheme.Dark1); + } + } + else + { + switch (border.Fill.Style) + { + case eFillStyle.NoFill: + if (border.Fill.IsEmpty) + { + item.BorderColor = GetFillColor(theme, border.Fill, color, item.BorderColorSource, out opacity); + } + else + { + item.BorderColor = "none"; + } + break; + case eFillStyle.SolidFill: item.BorderColor = GetFillColor(theme, border.Fill, color, item.BorderColorSource, out opacity); - } - else - { - item.BorderColor = "none"; - } - break; - case eFillStyle.SolidFill: - item.BorderColor = GetFillColor(theme, border.Fill, color, item.BorderColorSource, out opacity); - item.BorderGradientFill = null; - break; - case eFillStyle.GradientFill: - item.BorderGradientFill = new RenderGradientFill(); - item.BorderColor = null; - break; + item.BorderGradientFill = null; + break; + case eFillStyle.GradientFill: + item.BorderGradientFill = new DrawingRenderGradientFill(theme, border.Fill.GradientFill, grandientUserSpaceOnUse); + item.BorderColor = null; + break; + } } if (opacity.HasValue) @@ -107,12 +117,12 @@ internal static void SetDrawingPropertiesBorder(this RenderItem item, ExcelTheme if (hasBorder && item.BorderColorSource != PathFillMode.None) { - item.BorderWidth = border.Width == 0 ? defaultWidth : border.Width; - if (border.LineStyle.HasValue && border.LineStyle != eLineStyle.Solid) + item.BorderWidth = (border?.Width??0D) == 0D ? defaultWidth : border.Width; + if (border!=null && border.LineStyle.HasValue && border.LineStyle != eLineStyle.Solid) { - item.BorderDashArray = GetDashArray(border); + item.BorderDashArray = GetDashArray(border, item.BorderWidth.Value); } - if (border.CompoundLineStyle != eCompoundLineStyle.Single) + if (border != null && border.CompoundLineStyle != eCompoundLineStyle.Single) { item.CompoundLineStyle = (CompoundLineStyle)border.CompoundLineStyle; //TODO:Add support double compound borders. @@ -137,9 +147,9 @@ internal static void SetDrawingPropertiesEffects(this RenderItem item, ExcelThem } } - private static double[] GetDashArray(ExcelDrawingBorder border) + private static double[] GetDashArray(ExcelDrawingBorder border, double width) { - var lw = (int)Math.Round(border.Width * ExcelDrawing.EMU_PER_POINT / ExcelDrawing.EMU_PER_PIXEL); + var lw = (int)Math.Round(width * ExcelDrawing.EMU_PER_POINT / ExcelDrawing.EMU_PER_PIXEL); switch (border.LineStyle) { case eLineStyle.Dot: @@ -166,7 +176,7 @@ private static double[] GetDashArray(ExcelDrawingBorder border) return null; } - private static string GetFillColor(ExcelTheme theme, ExcelDrawingFillBasic fill, ExcelDrawingColorManager styleFillColor, PathFillMode fillColorSource, out double? opacity) + private static string GetFillColor(ExcelTheme theme, ExcelDrawingFillBasic fill, ExcelDrawingColorManager styleFillColor, PathFillMode fillColorSource, out double? opacity, ExcelDrawingThemeColorManager nullColor = null) { opacity = null; if (fillColorSource == PathFillMode.None) @@ -175,20 +185,52 @@ 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(theme.ColorScheme.Accent1); - } - else - { - fc = tc.ColorConverter.GetThemeColor(theme, styleFillColor); - } + //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; + // } + // } + //} } 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/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Fill/DrawingRenderGradientFill.cs index 67e96a4044..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++) @@ -51,6 +51,7 @@ public DrawingRenderGradientFill(ExcelTheme theme, ExcelDrawingGradientFill grad LinearSettings.Angle = gradientFill.LinearSettings.Angle; LinearSettings.Scaled = gradientFill.LinearSettings.Scaled; } + UserSpaceOnUse = userSpaceOnUse; } } } \ No newline at end of file diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingParagraphRenderItem.cs index 6bc07e58c6..eb29a9f132 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,14 +48,14 @@ 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); - + HorizontalAlignment = (TextAlignment)(int)p.HorizontalAlignment; 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 DefaultParagraphFont = new FontFormatBase(p.DefaultRunProperties.GetMeasureFont()); @@ -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) @@ -192,31 +192,31 @@ 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(); } private void ImportAlignment(bool isAutoSize, double maxWidth, double parentWidth) { - if (isAutoSize == false) - { - Bounds.Left = 0; - Bounds.Width = ParentMaxWidth; + //if (isAutoSize == false) + //{ + //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; + //} } private void ImportLineSpacing(eDrawingTextLineSpacing lsType, double lineSpacingValue) diff --git a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBody.cs index 151c7c2d44..a3d5851673 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(); @@ -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) { @@ -78,12 +71,16 @@ public void ImportParagraph(ExcelDrawingParagraph item, double startingY, string } } Paragraphs.Add(paragraph); + 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) { switch (p.HorizontalAlignment) @@ -105,7 +102,25 @@ internal void SetHorizontalAlignmentPosition() 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 +132,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) @@ -128,24 +145,34 @@ 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) { ImportParagraph(paragraph, currentHeight); var addedPara = Paragraphs.Last(); + //addedPara.HorizontalAlignment = defaultAlignment; + currentHeight = addedPara.Bounds.Bottom; largestWidth = Math.Max(largestWidth, addedPara.Bounds.Width); } - foreach (var paragraph in body.Paragraphs) + + if (Paragraphs != null && Paragraphs.Count() > 0 && AutoSize) { - SetHorizontalAlignmentPosition(); + Bounds.Height = currentHeight; } - if (Paragraphs != null && Paragraphs.Count() > 0) + //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) { - Bounds.Height = currentHeight; + SetHorizontalAlignmentPosition(); } Bounds.Top = GetAlignmentVertical(); @@ -176,12 +203,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 9bdb7605d2..ebbafe5446 100644 --- a/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs +++ b/src/EPPlus/Drawing/Renderer/RenderItems/Textbox/DrawingTextBox.cs @@ -22,11 +22,10 @@ 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; + TextBody.MaxHeight = maxHeight; } internal DrawingTextBox(ExcelDrawing drawing, BoundingBox parent, double maxWidth, double maxHeight) : base(parent, maxWidth, maxHeight) @@ -34,185 +33,26 @@ 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); } + DrawingTextBody _textBody; - public new DrawingTextbody TextBody {get;set;} - 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 double LeftMargin - { - get; set; - } - - internal double TopMargin + public DrawingTextBody GetTextBody() { - get; set; + return (DrawingTextBody)TextBody; } - internal double RightMargin + public void SetDrawingTextBody(DrawingTextBody tb) { - get; set; + TextBody = tb; } - internal double BottomMargin - { - get; set; - } - 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; - } + public override RenderTextBody TextBody { get { return _textBody; } set { _textBody = (DrawingTextBody)value; } } - 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) @@ -228,54 +68,12 @@ internal void ImportTextBody(ExcelTextBody body, bool useDefaults = true, ExcelH RightMargin = r; BottomMargin = b; - 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; - - - 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); - //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); + _textBody.ImportTextBodyAndParagraphs(body, horizontalDefault); } 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); 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 diff --git a/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs b/src/EPPlus/Drawing/Style/Text/ExcelDrawingParagraph.cs index b3410d654b..a29a112819 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,6 +46,8 @@ 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); @@ -50,6 +55,28 @@ internal ExcelDrawingParagraph(ExcelDrawingParagraphCollection paragraphs, IPict else { 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); + + // //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(); @@ -79,6 +106,25 @@ internal ExcelDrawingParagraph(ExcelDrawingParagraphCollection paragraphs, IPict } } } + else + { + //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); + + ////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 0ec2e5ecc2..09241b4506 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; //} @@ -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(); @@ -90,15 +90,15 @@ 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) + 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/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, + + } diff --git a/src/EPPlus/EPPlus.csproj b/src/EPPlus/EPPlus.csproj index f4740d94ad..4609b568ac 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 - 8.5.4.0 - 8.5.4.0 - 8.5.4 + 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.5.4 + EPPlus 8.6.1 IMPORTANT NOTICE! From version 5 EPPlus changes the license model using a dual license, Polyform Non Commercial / Commercial license. @@ -26,9 +26,20 @@ 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.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. + + ## Version 8.5.4 + * Minor bug fixes. + ## Version 8.5.3 * Downgraded .NET 8 references incorrectly update to 9.x to 8.x. @@ -560,16 +571,18 @@ * 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.5.4 20260430 Minor bug fixes. 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. 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. /// 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 /// 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/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/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/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/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..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; @@ -27,6 +28,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 +47,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 +79,44 @@ internal void LoadRangeImages(List ranges) ToColumnOff = toColOff }); } + 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); + + _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 + }); + } + 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 + }); + } } } } @@ -114,5 +155,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..74dada4db1 100644 --- a/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs +++ b/src/EPPlus/Export/HtmlExport/Exporters/Internal/CssExporterBase.cs @@ -137,14 +137,23 @@ protected void AddCssRulesToCollection(CssRangeRuleCollection cssTranslator, Htm } } - if (Settings.Pictures.Include == ePictureInclude.Include) + if (Settings.Pictures.Include == ePictureInclude.Include || Settings.Pictures.Include == ePictureInclude.IncludeInCssOnly) { - LoadRangeImages(_ranges._list); + LoadRangeDrawings(_ranges._list); foreach (var p in _rangePictures) { 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 e970079292..997be280a4 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; @@ -50,7 +51,7 @@ public HtmlExporterBaseInternal(HtmlExportSettings settings, ExcelRangeBase rang } } - LoadRangeImages(_ranges._list); + LoadRangeDrawings(_ranges._list); } public HtmlExporterBaseInternal(HtmlExportSettings settings, EPPlusReadOnlyList ranges) @@ -59,7 +60,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 +122,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; @@ -148,12 +150,18 @@ 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); } + if ((Settings.Drawings.Include == ePictureInclude.Include) || (Settings.Drawings.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 +232,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) { @@ -269,11 +278,16 @@ 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); } + 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 +443,35 @@ 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.Img); + 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 = new HTMLElement(HtmlElements.Svg); + child.ElementName = "div"; + child.Content = d.Drawing.ToSvg(); + //var _encodedImage = ImageEncoder.EncodeImage(image, out type); + + //child.AddAttribute("src", $"data:{GetContentType(type.Value)};base64,{_encodedImage}"); + } + else + { + child.AddAttribute("class", $"{settings.StyleClassPrefix}drawing-{drawingName} {settings.StyleClassPrefix}drawing-prop-{drawingName}"); + } + parent._childElements.Add(child); + } + } + protected List _columns = new List(); protected HtmlExportSettings Settings; protected readonly List _mergedCells = new List(); @@ -507,7 +550,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 +774,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/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..d94bfbb869 --- /dev/null +++ b/src/EPPlus/Export/HtmlExport/Settings/HtmlDrawingSettings.cs @@ -0,0 +1,32 @@ +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; + + /// + /// Is absolute by default for charts + /// + public ePicturePosition Position = ePicturePosition.DontSet; + + } +} 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/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs new file mode 100644 index 0000000000..94a2701e32 --- /dev/null +++ b/src/EPPlus/Export/HtmlExport/Translators/CssDrawingPropertiesTranslator.cs @@ -0,0 +1,113 @@ +using EPPlus.Export.Utils; +using EPPlus.Fonts.OpenType.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; + } + 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) + { + 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/CssFillTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssFillTranslator.cs index a9d5a945ea..3da86afff0 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; @@ -70,10 +73,19 @@ 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; + //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); + 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/CssImageTranslator.cs b/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs index 8eff1db2cc..0fc2ec21ec 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/CssImageTranslator.cs @@ -12,15 +12,17 @@ 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; + bool isDrawing = false; public CssImageTranslator(HtmlImage p) { @@ -28,6 +30,16 @@ 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; + isDrawing = true; + } + internal override List GenerateDeclarationList(TranslatorContext context) { AddDeclaration("content", $"url('data:{GetContentType(type.Value)};base64,{_encodedImage}')"); @@ -37,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/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/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs b/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs index fceb1178d5..92e90b0b6b 100644 --- a/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs +++ b/src/EPPlus/Export/HtmlExport/Translators/TranslatorContext.cs @@ -34,6 +34,7 @@ internal class TranslatorContext internal CssExclude Exclude; internal CssExportSettings Settings; internal HtmlPictureSettings Pictures; + internal HtmlDrawingSettings Drawings; private TranslatorBase strategy; @@ -53,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/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index b73b4e4a04..5339a85710 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; @@ -64,6 +65,12 @@ 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["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); @@ -337,7 +344,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(); @@ -374,6 +382,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/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/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 25f1a40600..6dcc60ceff 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/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/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/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/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..7528de0a35 --- /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. \, \- \. \$ \) \<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") + /// 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/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/EPPlus/FormulaParsing/Ranges/EpplusExcelExternalRangeInfo.cs b/src/EPPlus/FormulaParsing/Ranges/EpplusExcelExternalRangeInfo.cs index 882f9edb22..93ce12f1f2 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/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/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/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/RichText/ExcelParagraphCollection.cs b/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs index ef13fff0a6..2b6bb55d34 100644 --- a/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs +++ b/src/EPPlus/Style/RichText/ExcelParagraphCollection.cs @@ -33,44 +33,19 @@ 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" }); - - //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; - + 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" }); _path = path; + 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)); } @@ -152,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); @@ -239,7 +213,8 @@ public string Text { if (_textBody.Paragraphs.Count == 0) { - Add(value); + //_textBody.Paragraphs.Add(value); + Add(value, true); } else { diff --git a/src/EPPlus/Utils/Rendering/DrawingExtensions.cs b/src/EPPlus/Utils/Rendering/DrawingExtensions.cs index 3da7ad5c4f..7d93cfe143 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 @@ -33,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, diff --git a/src/EPPlus/Utils/TypeConversion/ColorConverter.cs b/src/EPPlus/Utils/TypeConversion/ColorConverter.cs index 76f21ce3a1..7f32f6db97 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; 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/Core/Worksheet/AutofitWithSerializedFontMetricsTests.cs b/src/EPPlusTest/Core/Worksheet/AutofitWithSerializedFontMetricsTests.cs index f4a389db74..7e388c0deb 100644 --- a/src/EPPlusTest/Core/Worksheet/AutofitWithSerializedFontMetricsTests.cs +++ b/src/EPPlusTest/Core/Worksheet/AutofitWithSerializedFontMetricsTests.cs @@ -14,7 +14,7 @@ namespace EPPlusTest.Core.Worksheet [TestClass] public class AutofitWithSerializedFontMetricsTests : TestBase { - [DataTestMethod] + [TestMethod] [DataRow("Calibri")] [DataRow("Aptos Narrow")] [DataRow("Aptos Display")] @@ -92,7 +92,7 @@ public void AutofitWithSerializedFonts(string fontFamily) } } - [DataTestMethod, Ignore] + [TestMethod, Ignore] [DataRow("Calibri", 1)] //[DataRow("Calibri Light", 2)] //[DataRow("Arial", 3)] @@ -208,7 +208,7 @@ public void AutofitWithSerializedFonts2(string fontFamily, int run) } } - [DataTestMethod, Ignore] + [TestMethod, Ignore] [DataRow("Calibri", 1)] [DataRow("Arial", 2)] [DataRow("Arial Black", 3)] @@ -332,7 +332,7 @@ public void LoadFontSizeFromResource() } } - [DataTestMethod, Ignore] + [TestMethod, Ignore] [DataRow("Calibri")] [DataRow("Arial")] [DataRow("Times New Roman")] @@ -379,7 +379,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/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 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] 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/Drawing/DrawingTest.cs b/src/EPPlusTest/Drawing/DrawingTest.cs index 7e9d17f85e..6f3de378ee 100644 --- a/src/EPPlusTest/Drawing/DrawingTest.cs +++ b/src/EPPlusTest/Drawing/DrawingTest.cs @@ -1339,5 +1339,62 @@ public void GenerateAndChangeUnderlineShapeTextRunEpplus() SaveAndCleanup(p); } } + [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"); + } + } } } 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/Export/HtmlExport/RangeExporterTests.cs b/src/EPPlusTest/Export/HtmlExport/RangeExporterTests.cs index 8efd80f6f1..7ec061fb6a 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; using System.Linq; using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; @@ -21,7 +22,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"; @@ -29,12 +30,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(); @@ -83,12 +84,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(); @@ -97,7 +98,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); @@ -144,7 +145,6 @@ public async Task ShouldExportHtmlWithMergedCells() var resultAsync = await exporter.GetSinglePageAsync(); SaveAndCleanup(package); Assert.AreEqual(result, resultAsync); - } } [TestMethod] @@ -172,26 +172,121 @@ 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); } } + + [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.Relative; + 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", "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); + 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.Relative; + + 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() { 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); @@ -225,7 +320,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"], @@ -566,11 +661,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]; @@ -586,7 +681,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()"; @@ -607,6 +702,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 diff --git a/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs new file mode 100644 index 0000000000..b54ab3ff04 --- /dev/null +++ b/src/EPPlusTest/Export/HtmlExport/SvgShapeExportTests.cs @@ -0,0 +1,155 @@ +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; + +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"); + 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; + + 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(); + + 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:A8"]); + + //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; + + 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; + + var htmlPage = exporter.GetSinglePage(); + + var file = GetOutputFile("html", "myColChart.html"); + var svgFile = GetOutputFile("html", "myColChartSvg.svg"); + + File.WriteAllText(file.FullName, htmlPage); + File.WriteAllText(svgFile.FullName, chart.ToSvg()); + + SaveAndCleanup(package); + } + } + + [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; + chart.SetPixelWidth(250); + + 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", "colChartCats.html"); + + SaveAndCleanup(package); + + File.WriteAllText(file.FullName, htmlPage); + } + } + } +} 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/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/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/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); + } + } +} 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..babd1c68f8 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() { @@ -64,7 +189,7 @@ public void ApproximateShouldFindDate() } } - [DataTestMethod] + [TestMethod] [DataRow(1, "a")] [DataRow(5, "d")] public void ApproximateShouldFind(int find, string expected) @@ -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/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/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/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/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 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..8a472f2854 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/TextFunctions/UsDollarTests.cs @@ -0,0 +1,103 @@ +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; + + [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 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/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); + } + } + } + } } diff --git a/src/EPPlusTest/Issues/ChartIssues.cs b/src/EPPlusTest/Issues/ChartIssues.cs index 9d5073f548..8051b85384 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'."); + } + } + } } 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); + } + } + } +} diff --git a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs index 552a718c56..3c4db3c2db 100644 --- a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs +++ b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs @@ -163,5 +163,60 @@ 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; + 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_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 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); + } + } } } diff --git a/src/EPPlusTest/Issues/DrawingIssues.cs b/src/EPPlusTest/Issues/DrawingIssues.cs index 576dacc111..4282f09521 100644 --- a/src/EPPlusTest/Issues/DrawingIssues.cs +++ b/src/EPPlusTest/Issues/DrawingIssues.cs @@ -237,6 +237,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/Issues/FormulaCalculationIssues.cs b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs index c29d104049..ca6b0696d7 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); @@ -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; @@ -1646,9 +1645,38 @@ 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.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); + } + } + + [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); + } + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/Issues/WorksheetIssues.cs b/src/EPPlusTest/Issues/WorksheetIssues.cs index 8acd890b2d..adf4fe6a98 100644 --- a/src/EPPlusTest/Issues/WorksheetIssues.cs +++ b/src/EPPlusTest/Issues/WorksheetIssues.cs @@ -160,6 +160,58 @@ public void i1314() package.Dispose(); } } + + [TestMethod] + public void AutofitAutofilterTest() + { + using var package = OpenPackage("AutofitAutofilterTest.xlsx", true); + + // Two sheets with identical data: one with an autofilter, one without. + // After autofit, the filtered columns should be wider than the unfiltered + // ones by the reserved width of the dropdown arrow. + var wsFilter = package.Workbook.Worksheets.Add("WithFilter"); + var wsNoFilter = package.Workbook.Worksheets.Add("NoFilter"); + + foreach (var ws in new[] { wsFilter, wsNoFilter }) + { + // Headers are the widest text in each column - the data below is deliberately + // shorter so the column width is driven by the header (+ the dropdown arrow + // on the filtered sheet). + ws.Cells["A1"].Value = "Department"; + ws.Cells["B1"].Value = "Annual Budget"; + ws.Cells["C1"].Value = "Region Name"; + + ws.Cells["A2"].Value = "Sales"; + ws.Cells["B2"].Value = 1200; + ws.Cells["C2"].Value = "North"; + + ws.Cells["A3"].Value = "IT"; + ws.Cells["B3"].Value = 980; + ws.Cells["C3"].Value = "West"; + + ws.Cells["A4"].Value = "HR"; + ws.Cells["B4"].Value = 540; + ws.Cells["C4"].Value = "East"; + } + + // Only one sheet gets the autofilter. + wsFilter.Cells["A1:C4"].AutoFilter = true; + + wsFilter.Cells["A1:C4"].AutoFitColumns(); + wsNoFilter.Cells["A1:C4"].AutoFitColumns(); + + for (int col = 1; col <= 3; col++) + { + var filterWidth = wsFilter.Column(col).Width; + var noFilterWidth = wsNoFilter.Column(col).Width; + System.Diagnostics.Debug.WriteLine($"Column {col}: filter={filterWidth}, noFilter={noFilterWidth}"); + Assert.IsTrue(filterWidth > noFilterWidth, + $"Column {col}: filtered width ({filterWidth}) should be greater than unfiltered width ({noFilterWidth})."); + } + + SaveAndCleanup(package); + } + [TestMethod] public void i1317() { diff --git a/src/EPPlusTest/LoadFunctions/LoadFromTextTests.cs b/src/EPPlusTest/LoadFunctions/LoadFromTextTests.cs index ce444fed02..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); + } + } } 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)); + } } } diff --git a/src/EPPlusTest/TestBase.cs b/src/EPPlusTest/TestBase.cs index 307b483ee3..6496ca8bc2 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); } 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] diff --git a/src/EPPlusTest/WorkSheetTests.cs b/src/EPPlusTest/WorkSheetTests.cs index a180a24c2e..9f6422f2ed 100644 --- a/src/EPPlusTest/WorkSheetTests.cs +++ b/src/EPPlusTest/WorkSheetTests.cs @@ -2181,6 +2181,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() {