diff --git a/.editorconfig b/.editorconfig index 9416600c2..f2ec7db2c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -root = true +root = true [*] end_of_line = crlf @@ -7,6 +7,7 @@ trim_trailing_whitespace = true # 4 space indentation [*.cs] +charset=utf-8 indent_style = space indent_size = 4 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..4ec7b2d9b --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,21 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-20.04 + tools: + python: "3.9" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optionally declare the Python requirements required to build your docs +python: + install: + - requirements: docs/requirements.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 477b942db..d7ba7f7fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,6 @@ +# Notice +ClosedXML is currently not seeking new contributions, until the worst technical debt and current backlog of PRs are dealt with. + # Developer guidelines The [OpenXML specification](https://www.ecma-international.org/publications/standards/Ecma-376.htm) is a large and complicated beast. In order for ClosedXML, the wrapper around OpenXML, to support all the features, we rely on community contributions. Before opening an issue to request a new feature, we'd like to urge you to try to implement it yourself and log a pull request. @@ -38,6 +41,10 @@ Now, to compare 2 similar, but not exact Excel files: - In Total Commander, you can also navigate to specific files in the left-hand and right-hand panes and select `File > Compare by Content...`. This will open WinMerge directly. - Note that since WinMerge reformats the XML, it does so in a temporary file. If you make changes to the contents of any of the 2 panes in WinMerge and save the file, it will not be saved back into the Excel file. +## Reconciling Test Files + +ClosedXML uses a set of [reference .xlsx files](https://github.com/ClosedXML/ClosedXML/tree/develop/ClosedXML.Tests/Resource) for comparison for some of the unit tests. Sometimes when you update the ClosedXML codebase, e.g. a bugfix, the reference test files maybe become obsolete. When running unit tests and the generated file doesn't match the reference file, you will have to update the reference file. You should do this only after inspecting the differences between the generated and reference files in detail and confirming that each change is indeed the expected behaviour. Check the new files visually (e.g. in Excel) and through XML comparison before overwriting the reference files. + ## Code conventions ClosedXML has a fairly large codebase and we therefore want to keep code revisions as clean and tidy as possible. It is therefore important not to introduce unnecessary whitespace changes in your commits. diff --git a/ClosedXML.Examples/ClosedXML.Examples.csproj b/ClosedXML.Examples/ClosedXML.Examples.csproj index 411f636d9..51c6b53f3 100644 --- a/ClosedXML.Examples/ClosedXML.Examples.csproj +++ b/ClosedXML.Examples/ClosedXML.Examples.csproj @@ -1,7 +1,7 @@  - netcoreapp3.1;net461 + net8.0;net462 Exe @@ -16,7 +16,6 @@ - diff --git a/ClosedXML.Examples/Comments/AddingComments.cs b/ClosedXML.Examples/Comments/AddingComments.cs index d8b87ac9f..2cdba3ada 100644 --- a/ClosedXML.Examples/Comments/AddingComments.cs +++ b/ClosedXML.Examples/Comments/AddingComments.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using ClosedXML.Excel; -using System.IO; using MoreLinq; namespace ClosedXML.Examples diff --git a/ClosedXML.Examples/Comments/EditingComments.cs b/ClosedXML.Examples/Comments/EditingComments.cs deleted file mode 100644 index 37c42d898..000000000 --- a/ClosedXML.Examples/Comments/EditingComments.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using ClosedXML.Excel; -using System.IO; - -namespace ClosedXML.Examples -{ - class EditingComments : IXLExample - { - - public void Create(string filePath) { - - // Exercise(@"path/to/test/resources/comments"); - - } - - public void Exercise(string basePath) - { - - // INCOMPLETE - - var book = new XLWorkbook(Path.Combine(basePath, "EditingComments.xlsx")); - var sheet = book.Worksheet(1); - - // no change - // A1 - - // edit existing comment - sheet.Cell("B3").GetComment().AddNewLine(); - sheet.Cell("B3").GetComment().AddSignature(); - sheet.Cell("B3").GetComment().AddText("more comment"); - - // delete - //sheet.Cell("C1").DeleteComment(); - - // clear contents - sheet.Cell("D3").Clear(XLClearOptions.Contents); - - // new basic - sheet.Cell("E1").GetComment().AddText("non authored comment"); - - // new with author - sheet.Cell("F3").GetComment().AddSignature(); - sheet.Cell("F3").GetComment().AddText("comment from author"); - - // TODO: merge with cells - // TODO: resize with cells - // TODO: visible - - book.SaveAs(Path.Combine(basePath, "EditingComments_modified.xlsx")); - } - } -} diff --git a/ClosedXML.Examples/ConditionalFormatting/ConditionalFormatting.cs b/ClosedXML.Examples/ConditionalFormatting/ConditionalFormatting.cs index 303f5638e..972eb5203 100644 --- a/ClosedXML.Examples/ConditionalFormatting/ConditionalFormatting.cs +++ b/ClosedXML.Examples/ConditionalFormatting/ConditionalFormatting.cs @@ -113,7 +113,7 @@ public void Create(String filePath) var ws = workbook.AddWorksheet("Sheet1"); ws.FirstCell().SetValue("Hello") - .CellBelow().SetValue("") + .CellBelow().SetValue(Blank.Value) .CellBelow().SetValue("") .CellBelow().SetValue("Holl"); @@ -132,7 +132,7 @@ public void Create(String filePath) var ws = workbook.AddWorksheet("Sheet1"); ws.FirstCell().SetValue("Hello") - .CellBelow().SetValue("") + .CellBelow().SetValue(Blank.Value) .CellBelow().SetValue("") .CellBelow().SetValue("Holl"); diff --git a/ClosedXML.Examples/Creating/CreateFiles.cs b/ClosedXML.Examples/Creating/CreateFiles.cs index bcba0a4f8..006d141e7 100644 --- a/ClosedXML.Examples/Creating/CreateFiles.cs +++ b/ClosedXML.Examples/Creating/CreateFiles.cs @@ -28,7 +28,6 @@ public static void CreateAllFiles() new InsertColumns().Create(Path.Combine(path, "InsertColumns.xlsx")); new ColumnCollection().Create(Path.Combine(path, "ColumnCollection.xlsx")); new DataTypes().Create(Path.Combine(path, "DataTypes.xlsx")); - new DataTypesUnderDifferentCulture().Create(Path.Combine(path, "DataTypesUnderDifferentCulture.xlsx")); new MultipleSheets().Create(Path.Combine(path, "MultipleSheets.xlsx")); new RowCollection().Create(Path.Combine(path, "RowCollection.xlsx")); new DefiningRanges().Create(Path.Combine(path, "DefiningRanges.xlsx")); @@ -58,7 +57,7 @@ public static void CreateAllFiles() new Outline().Create(Path.Combine(path, "Outline.xlsx")); new Formulas().Create(Path.Combine(path, "Formulas.xlsx")); new Collections().Create(Path.Combine(path, "Collections.xlsx")); - new NamedRanges().Create(Path.Combine(path, "NamedRanges.xlsx")); + new DefinedNames().Create(Path.Combine(path, "DefinedNames.xlsx")); new CopyingRanges().Create(Path.Combine(path, "CopyingRanges.xlsx")); new BlankCells().Create(Path.Combine(path, "BlankCells.xlsx")); new TwoPages().Create(Path.Combine(path, "TwoPages.xlsx")); diff --git a/ClosedXML.Examples/Delete/DeleteRows.cs b/ClosedXML.Examples/Delete/DeleteRows.cs index 0d572796c..c8dceba2e 100644 --- a/ClosedXML.Examples/Delete/DeleteRows.cs +++ b/ClosedXML.Examples/Delete/DeleteRows.cs @@ -51,8 +51,7 @@ public void Create(String filePath) // Put a value in a few cells foreach (var r in Enumerable.Range(1, 5)) foreach (var c in Enumerable.Range(1, 5)) - ws.Cell(r, c).Value = string.Format("R{0}C{1}", r, c); - + ws.Cell(r, c).Value = $"R{r}C{c}"; var blueRow = ws.Rows(1, 2); var redRow = ws.Row(5); diff --git a/ClosedXML.Examples/Loading/ChangingBasicTable.cs b/ClosedXML.Examples/Loading/ChangingBasicTable.cs index 26918e42c..74f45f8fb 100644 --- a/ClosedXML.Examples/Loading/ChangingBasicTable.cs +++ b/ClosedXML.Examples/Loading/ChangingBasicTable.cs @@ -28,8 +28,7 @@ public void Create(string filePath) foreach (var cell in rngNumbers.Cells()) { string formattedString = cell.GetFormattedString(); - cell.DataType = XLDataType.Text; - cell.Value = formattedString + " Dollars"; + cell.SetValue(formattedString + " Dollars"); } ws.Columns().AdjustToContents(); @@ -45,4 +44,4 @@ public void Create(string filePath) } } } -} \ No newline at end of file +} diff --git a/ClosedXML.Examples/Misc/AddingDataSet.cs b/ClosedXML.Examples/Misc/AddingDataSet.cs index fd6a20d26..6d007a36e 100644 --- a/ClosedXML.Examples/Misc/AddingDataSet.cs +++ b/ClosedXML.Examples/Misc/AddingDataSet.cs @@ -1,7 +1,6 @@ using ClosedXML.Excel; using System; using System.Data; -using System.Linq; namespace ClosedXML.Examples.Misc { diff --git a/ClosedXML.Examples/Misc/CellValues.cs b/ClosedXML.Examples/Misc/CellValues.cs index b87bb466b..33a27596f 100644 --- a/ClosedXML.Examples/Misc/CellValues.cs +++ b/ClosedXML.Examples/Misc/CellValues.cs @@ -28,7 +28,7 @@ public void Create(String filePath) cellDateTime.Style.DateFormat.Format = "yyyy-MMM-dd"; // Extract the date in different ways - DateTime dateTime1 = (DateTime)cellDateTime.Value; + DateTime dateTime1 = cellDateTime.Value; DateTime dateTime2 = cellDateTime.GetDateTime(); DateTime dateTime3 = cellDateTime.GetValue(); String dateTimeString = cellDateTime.GetString(); @@ -96,7 +96,7 @@ public void Create(String filePath) // Extract the string in different ways String string1 = (String)cellString.Value; - String string2 = cellString.GetString(); + String string2 = cellString.GetText(); String string3 = cellString.GetValue(); String stringString = cellString.GetString(); String stringFormattedString = cellString.GetFormattedString(); @@ -116,7 +116,7 @@ public void Create(String filePath) cellTimeSpan.Value = new TimeSpan(1, 2, 31, 45); // Extract the timeSpan in different ways - TimeSpan timeSpan1 = (TimeSpan)cellTimeSpan.Value; + TimeSpan timeSpan1 = cellTimeSpan.Value; TimeSpan timeSpan2 = cellTimeSpan.GetTimeSpan(); TimeSpan timeSpan3 = cellTimeSpan.GetValue(); String timeSpanString = "'" + cellTimeSpan.GetString(); @@ -129,6 +129,26 @@ public void Create(String filePath) ws.Cell(7, 6).Value = timeSpanString; ws.Cell(7, 7).Value = timeSpanFormattedString; + ////////////////////////////////////////////////////////////////// + // XLError + + var cellError = ws.Cell(8, 2); + cellError.Value = XLError.DivisionByZero; + + // Extract the error in different ways + XLError error1 = (XLError)cellError.Value; + XLError error2 = cellError.GetError(); + XLError error3 = cellError.GetValue(); + String errorString = "'" + cellError.GetString(); + String errorFormattedString = "'" + cellError.GetFormattedString(); + + // Set the values back to cells + ws.Cell(8, 3).Value = error1; + ws.Cell(8, 4).Value = error2; + ws.Cell(8, 5).Value = error3; + ws.Cell(8, 6).Value = errorString; + ws.Cell(8, 7).Value = errorFormattedString; + ////////////////////////////////////////////////////////////////// // Do some formatting ws.Columns("B:G").Width = 20; @@ -141,7 +161,32 @@ public void Create(String filePath) ws = workbook.AddWorksheet("Test Whitespace"); ws.FirstCell().Value = "' "; - workbook.SaveAs(filePath); + ws = workbook.AddWorksheet("Errors"); + ws.Cell(2, 2).Value = "Error value"; + ws.Cell(2, 3).Value = "Formula error"; + + ws.Cell(3, 2).Value = XLError.CellReference; + ws.Cell(3, 3).FormulaA1 = "#REF!+1"; + + ws.Cell(4, 2).Value = XLError.IncompatibleValue; + ws.Cell(4, 3).FormulaA1 = "\"TRUE\"*1"; + + ws.Cell(5, 2).Value = XLError.DivisionByZero; + ws.Cell(5, 3).FormulaA1 = "1/0"; + + ws.Cell(6, 2).Value = XLError.NameNotRecognized; + ws.Cell(6, 3).FormulaA1 = "NONEXISTENT.FUNCTION()"; + + ws.Cell(7, 2).Value = XLError.NoValueAvailable; + ws.Cell(7, 3).FormulaA1 = "NA()"; + + ws.Cell(8, 2).Value = XLError.NullValue; + ws.Cell(8, 3).FormulaA1 = "#NULL!+1"; + + ws.Cell(9, 2).Value = XLError.NumberInvalid; + ws.Cell(9, 3).FormulaA1 = "#NUM!+1"; + + workbook.SaveAs(filePath, true, true); } } } diff --git a/ClosedXML.Examples/Misc/Collections.cs b/ClosedXML.Examples/Misc/Collections.cs index 26445830b..f85007d00 100644 --- a/ClosedXML.Examples/Misc/Collections.cs +++ b/ClosedXML.Examples/Misc/Collections.cs @@ -53,7 +53,7 @@ public void Create(String filePath) listOfStrings.Add("Car"); ws.Cell(1, 1).Value = "Strings"; ws.Cell(1, 1).AsRange().AddToNamed("Titles"); - ws.Cell(2, 1).Value = listOfStrings; + ws.Cell(2, 1).InsertData(listOfStrings); // From a list of arrays var listOfArr = new List(); @@ -62,13 +62,13 @@ public void Create(String filePath) listOfArr.Add(new Int32[] { 1, 2, 3, 4, 5, 6 }); ws.Cell(1, 3).Value = "Arrays"; ws.Range(1, 3, 1, 8).Merge().AddToNamed("Titles"); - ws.Cell(2, 3).Value = listOfArr; + ws.Cell(2, 3).InsertData(listOfArr); // From a DataTable var dataTable = GetTable(); ws.Cell(6, 1).Value = "DataTable"; ws.Range(6, 1, 6, 4).Merge().AddToNamed("Titles"); - ws.Cell(7, 1).Value = dataTable; + ws.Cell(7, 1).InsertData(dataTable); // From a query var list = new List(); @@ -83,7 +83,7 @@ public void Create(String filePath) ws.Cell(6, 6).Value = "Query"; ws.Range(6, 6, 6, 8).Merge().AddToNamed("Titles"); - ws.Cell(7, 6).Value = people; + ws.Cell(7, 6).InsertData(people); // Prepare the style for the titles @@ -93,7 +93,7 @@ public void Create(String filePath) titlesStyle.Fill.BackgroundColor = XLColor.Cyan; // Format all titles in one shot - wb.NamedRanges.NamedRange("Titles").Ranges.Style = titlesStyle; + wb.DefinedNames.DefinedName("Titles").Ranges.Style = titlesStyle; ws.Columns().AdjustToContents(); diff --git a/ClosedXML.Examples/Misc/DataTypes.cs b/ClosedXML.Examples/Misc/DataTypes.cs index 7a6b2d031..e0916956f 100644 --- a/ClosedXML.Examples/Misc/DataTypes.cs +++ b/ClosedXML.Examples/Misc/DataTypes.cs @@ -1,45 +1,10 @@ using System; using ClosedXML.Excel; - namespace ClosedXML.Examples.Misc { public class DataTypes : IXLExample { - #region Variables - - // Public - - // Private - - - #endregion - - #region Properties - - // Public - - // Private - - // Override - - - #endregion - - #region Events - - // Public - - // Private - - // Override - - - #endregion - - #region Methods - - // Public public void Create(String filePath) { var workbook = new XLWorkbook(); @@ -68,15 +33,6 @@ public void Create(String filePath) ro++; - ws.Cell(++ro, co).Value = "Decimal Number:"; - ws.Cell(ro, co + 1).Value = 123.45m; - - ws.Cell(++ro, co).Value = "Float Number:"; - ws.Cell(ro, co + 1).Value = 123.45f; - - ws.Cell(++ro, co).Value = "Double Number:"; - ws.Cell(ro, co + 1).Value = 123.45d; - ws.Cell(++ro, co).Value = "Large Double Number:"; ws.Cell(ro, co + 1).Value = 9.999E307d; @@ -86,13 +42,13 @@ public void Create(String filePath) ws.Cell(ro, co + 1).Value = "'Hello World."; ws.Cell(++ro, co).Value = "Date as Text:"; - ws.Cell(ro, co + 1).Value = "'" + new DateTime(2010, 9, 2).ToString(); + ws.Cell(ro, co + 1).Value = "'" + new DateTime(2010, 9, 2); ws.Cell(++ro, co).Value = "DateTime as Text:"; - ws.Cell(ro, co + 1).Value = "'" + new DateTime(2010, 9, 2, 13, 45, 22).ToString(); + ws.Cell(ro, co + 1).Value = "'" + new DateTime(2010, 9, 2, 13, 45, 22); ws.Cell(++ro, co).Value = "Boolean as Text:"; - ws.Cell(ro, co + 1).Value = "'" + true.ToString(); + ws.Cell(ro, co + 1).Value = "'TRUE"; ws.Cell(++ro, co).Value = "Number as Text:"; ws.Cell(ro, co + 1).Value = "'123.45"; @@ -106,85 +62,7 @@ public void Create(String filePath) ws.Cell(ro, co + 1).Style.NumberFormat.Format = "@"; ws.Cell(++ro, co).Value = "TimeSpan as Text:"; - ws.Cell(ro, co + 1).Value = "'" + new TimeSpan(33, 45, 22).ToString(); - - ro++; - - ws.Cell(++ro, co).Value = "Changing Data Types:"; - - ro++; - - ws.Cell(++ro, co).Value = "Date to Text:"; - ws.Cell(ro, co + 1).Value = new DateTime(2010, 9, 2); - ws.Cell(ro, co + 1).DataType = XLDataType.Text; - - ws.Cell(++ro, co).Value = "DateTime to Text:"; - ws.Cell(ro, co + 1).Value = new DateTime(2010, 9, 2, 13, 45, 22); - ws.Cell(ro, co + 1).DataType = XLDataType.Text; - - ws.Cell(++ro, co).Value = "Boolean to Text:"; - ws.Cell(ro, co + 1).Value = true; - ws.Cell(ro, co + 1).DataType = XLDataType.Text; - - ws.Cell(++ro, co).Value = "Number to Text:"; - ws.Cell(ro, co + 1).Value = 123.45; - ws.Cell(ro, co + 1).DataType = XLDataType.Text; - - ws.Cell(++ro, co).Value = "TimeSpan to Text:"; - ws.Cell(ro, co + 1).Value = new TimeSpan(33, 45, 22); - ws.Cell(ro, co + 1).DataType = XLDataType.Text; - - ws.Cell(++ro, co).Value = "Text to Date:"; - ws.Cell(ro, co + 1).Value = "'" + new DateTime(2010, 9, 2).ToString(); - ws.Cell(ro, co + 1).DataType = XLDataType.DateTime; - - ws.Cell(++ro, co).Value = "Text to DateTime:"; - ws.Cell(ro, co + 1).Value = "'" + new DateTime(2010, 9, 2, 13, 45, 22).ToString(); - ws.Cell(ro, co + 1).DataType = XLDataType.DateTime; - - ws.Cell(++ro, co).Value = "Text to Boolean:"; - ws.Cell(ro, co + 1).Value = "'" + true.ToString(); - ws.Cell(ro, co + 1).DataType = XLDataType.Boolean; - - ws.Cell(++ro, co).Value = "Text to Number:"; - ws.Cell(ro, co + 1).Value = "'123.45"; - ws.Cell(ro, co + 1).DataType = XLDataType.Number; - - ws.Cell(++ro, co).Value = "Percentage Text to Number:"; - ws.Cell(ro, co + 1).Value = "'55.12%"; - ws.Cell(ro, co + 1).Style.NumberFormat.SetNumberFormatId((int)XLPredefinedFormat.Number.PercentPrecision2); - ws.Cell(ro, co + 1).DataType = XLDataType.Number; - - ws.Cell(++ro, co).Value = "@ format to Number:"; - ws.Cell(ro, co + 1).Style.NumberFormat.Format = "@"; - ws.Cell(ro, co + 1).Value = 123.45; - ws.Cell(ro, co + 1).DataType = XLDataType.Number; - - ws.Cell(++ro, co).Value = "Text to TimeSpan:"; - ws.Cell(ro, co + 1).Value = "'" + new TimeSpan(33, 45, 22).ToString(); - ws.Cell(ro, co + 1).DataType = XLDataType.TimeSpan; - - ro++; - - ws.Cell(++ro, co).Value = "Formatted Date to Text:"; - ws.Cell(ro, co + 1).Value = new DateTime(2010, 9, 2); - ws.Cell(ro, co + 1).Style.DateFormat.Format = "yyyy-MM-dd"; - ws.Cell(ro, co + 1).DataType = XLDataType.Text; - - ws.Cell(++ro, co).Value = "Formatted Number to Text:"; - ws.Cell(ro, co + 1).Value = 12345.6789; - ws.Cell(ro, co + 1).Style.NumberFormat.Format = "#,##0.00"; - ws.Cell(ro, co + 1).DataType = XLDataType.Text; - - ro++; - - ws.Cell(++ro, co).Value = "Blank Text:"; - ws.Cell(ro, co + 1).Value = 12345.6789; - ws.Cell(ro, co + 1).Style.NumberFormat.Format = "#,##0.00"; - ws.Cell(ro, co + 1).DataType = XLDataType.Text; - ws.Cell(ro, co + 1).Value = ""; - - ro++; + ws.Cell(ro, co + 1).Value = "'" + new TimeSpan(33, 45, 22); // Using inline strings (few users will ever need to use this feature) // @@ -196,32 +74,20 @@ public void Create(String filePath) cell.Value = "Not Shared"; cell.ShareString = false; + ro++; + + ws.Cell(++ro, co).Value = "Error from literal:"; + ws.Cell(ro, co + 1).Value = XLError.IncompatibleValue; + + ws.Cell(++ro, co).Value = "Error from evaluation:"; + ws.Cell(ro, co + 1).FormulaA1 = "1/0"; + // To view all shared strings (all texts in the workbook actually), use the following: // workbook.GetSharedStrings() - ws.Cell(++ro, co) - .SetDataType(XLDataType.Text) - .SetDataType(XLDataType.Boolean) - .SetDataType(XLDataType.DateTime) - .SetDataType(XLDataType.Number) - .SetDataType(XLDataType.TimeSpan) - .SetDataType(XLDataType.Text) - .SetDataType(XLDataType.TimeSpan) - .SetDataType(XLDataType.Number) - .SetDataType(XLDataType.DateTime) - .SetDataType(XLDataType.Boolean) - .SetDataType(XLDataType.Text); - ws.Columns(2, 3).AdjustToContents(); workbook.SaveAs(filePath); } - - // Private - - // Override - - - #endregion } } diff --git a/ClosedXML.Examples/Misc/DataTypesUnderDifferentCulture.cs b/ClosedXML.Examples/Misc/DataTypesUnderDifferentCulture.cs deleted file mode 100644 index 9a1c9ac3a..000000000 --- a/ClosedXML.Examples/Misc/DataTypesUnderDifferentCulture.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using ClosedXML.Excel; -using System.Threading; -using System.Globalization; -using System.IO; - -namespace ClosedXML.Examples.Misc -{ - public class DataTypesUnderDifferentCulture : IXLExample - { - public void Create(string filePath) - { - var backupCulture = Thread.CurrentThread.CurrentCulture; - - // Set thread culture to French, which should format numbers using decimal COMMA - Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("fr-FR"); - - string tempFile = ExampleHelper.GetTempFilePath(filePath); - try - { - new DataTypes().Create(tempFile); - var workbook = new XLWorkbook(tempFile); - workbook.SaveAs(filePath); - } - finally - { - Thread.CurrentThread.CurrentCulture = backupCulture; - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } - } - } - } -} diff --git a/ClosedXML.Examples/Misc/DataValidation.cs b/ClosedXML.Examples/Misc/DataValidation.cs index b7a989f1a..5ad9e0f0b 100644 --- a/ClosedXML.Examples/Misc/DataValidation.cs +++ b/ClosedXML.Examples/Misc/DataValidation.cs @@ -1,7 +1,5 @@ using System; using ClosedXML.Excel; -using System.Globalization; -using System.Threading; namespace ClosedXML.Examples.Misc @@ -131,8 +129,8 @@ public void Create(String filePath) ws.CopyTo(ws.Name + " - Copy"); ws2.CopyTo(ws2.Name + " - Copy"); - wb.AddWorksheet("Copy From Range 1").FirstCell().Value = ws.RangeUsed(XLCellsUsedOptions.All); - wb.AddWorksheet("Copy From Range 2").FirstCell().Value = ws2.RangeUsed(XLCellsUsedOptions.All); + wb.AddWorksheet("Copy From Range 1").FirstCell().CopyFrom(ws.RangeUsed(XLCellsUsedOptions.All)); + wb.AddWorksheet("Copy From Range 2").FirstCell().CopyFrom(ws2.RangeUsed(XLCellsUsedOptions.All)); wb.SaveAs(filePath); } diff --git a/ClosedXML.Examples/Misc/Formulas.cs b/ClosedXML.Examples/Misc/Formulas.cs index ca6a2cba4..f97405214 100644 --- a/ClosedXML.Examples/Misc/Formulas.cs +++ b/ClosedXML.Examples/Misc/Formulas.cs @@ -53,8 +53,8 @@ public virtual void Create(String filePath) // Using an array formula: // Just put the formula between curly braces ws.Cell("A6").Value = "Array Formula: "; - ws.Cell("B6").FormulaA1 = "{A2+A3}"; - ws.Range("C6:D6").FormulaA1 = "{TRANSPOSE(A2:A3)}"; + ws.Range("B6").FormulaArrayA1 = "A2+A3"; + ws.Range("C6:D6").FormulaArrayA1 = "TRANSPOSE(A2:A3)"; ws.Range(1, 1, 1, 7).Style.Fill.BackgroundColor = XLColor.Cyan; ws.Range(1, 1, 1, 7).Style.Font.Bold = true; diff --git a/ClosedXML.Examples/Misc/Hyperlinks.cs b/ClosedXML.Examples/Misc/Hyperlinks.cs index be807800e..a5a3a13b1 100644 --- a/ClosedXML.Examples/Misc/Hyperlinks.cs +++ b/ClosedXML.Examples/Misc/Hyperlinks.cs @@ -94,14 +94,17 @@ public void Create(String filePath) // Hyperlink via formula ws.Cell( ++ro, 1 ) - .SetFormulaA1("=HYPERLINK(\"mailto:test@test.com\", \"Send Email\")"); + .SetFormulaA1("=HYPERLINK(\"mailto:test@test.com\", \"Send Email through formula\")"); + + ws.Cell(++ro, 1) + .SetFormulaA1("=HYPERLINK(\"[Hyperlinks.xlsx]Hyperlinks!B2:C4\", \"Link to range through formula\")"); + + ws.Cell(++ro, 1) + .SetFormulaA1("=HYPERLINK(\"[../Test.xlsx]Sheet1!B2:C4\", \"Link to another file through formula\")"); // List all hyperlinks in a worksheet: var hyperlinksInWorksheet = ws.Hyperlinks; - // List all hyperlinks in a range: - var hyperlinksInRange = ws.Range("A1:A3").Hyperlinks; - // Clearing a cell with a hyperlink ws.Cell(++ro, 1).Value = "ERROR!"; ws.Cell(ro, 1).GetHyperlink().InternalAddress = "A1"; diff --git a/ClosedXML.Examples/Misc/InsertingData.cs b/ClosedXML.Examples/Misc/InsertingData.cs index 8107a32d1..d12488891 100644 --- a/ClosedXML.Examples/Misc/InsertingData.cs +++ b/ClosedXML.Examples/Misc/InsertingData.cs @@ -70,7 +70,7 @@ public void Create(String filePath) titlesStyle.Fill.BackgroundColor = XLColor.Cyan; // Format all titles in one shot - wb.NamedRanges.NamedRange("Titles").Ranges.Style = titlesStyle; + wb.DefinedNames.DefinedName("Titles").Ranges.Style = titlesStyle; ws.Columns().AdjustToContents(); diff --git a/ClosedXML.Examples/ModifyFiles.cs b/ClosedXML.Examples/ModifyFiles.cs deleted file mode 100644 index 019cc61ce..000000000 --- a/ClosedXML.Examples/ModifyFiles.cs +++ /dev/null @@ -1,14 +0,0 @@ -using ClosedXML.Examples.Delete; -using System.IO; - -namespace ClosedXML.Examples -{ - public class ModifyFiles - { - public static void Run() - { - var path = Program.BaseModifiedDirectory; - new DeleteRows().Create(Path.Combine(path, "DeleteRows.xlsx")); - } - } -} \ No newline at end of file diff --git a/ClosedXML.Examples/PivotTables/PivotTables.cs b/ClosedXML.Examples/PivotTables/PivotTables.cs index be2ce66d5..916e74bc9 100644 --- a/ClosedXML.Examples/PivotTables/PivotTables.cs +++ b/ClosedXML.Examples/PivotTables/PivotTables.cs @@ -59,7 +59,6 @@ public void Create(String filePath) var table = ws.Cell(1, 1).InsertTable(pastries, "PastrySalesData", true); ws.Columns().AdjustToContents(); - IXLWorksheet ptSheet; IXLPivotTable pt; @@ -71,7 +70,7 @@ public void Create(String filePath) ptSheet = wb.Worksheets.Add("pvt" + i); // Create the pivot table, using the data from the "PastrySalesData" table - pt = ptSheet.PivotTables.Add("pvt", ptSheet.Cell(1, 1), table.AsRange()); + pt = ptSheet.PivotTables.Add("pvt", ptSheet.Cell(1, 1), table); // The rows in our pivot table will be the names of the pastries if (i == 2) pt.RowLabels.Add(XLConstants.PivotTable.ValuesSentinalLabel); @@ -105,7 +104,7 @@ public void Create(String filePath) #region Different kind of pivot ptSheet = wb.Worksheets.Add("pvtNoColumnLabels"); - pt = ptSheet.PivotTables.Add("pvtNoColumnLabels", ptSheet.Cell(1, 1), table.AsRange()); + pt = ptSheet.PivotTables.Add("pvtNoColumnLabels", ptSheet.Cell(1, 1), table); pt.RowLabels.Add("Name"); pt.RowLabels.Add("Month"); @@ -120,7 +119,7 @@ public void Create(String filePath) #region Pivot table with collapsed fields ptSheet = wb.Worksheets.Add("pvtCollapsedFields"); - pt = ptSheet.PivotTables.Add("pvtCollapsedFields", ptSheet.Cell(1, 1), table.AsRange()); + pt = ptSheet.PivotTables.Add("pvtCollapsedFields", ptSheet.Cell(1, 1), table); pt.RowLabels.Add("Name").SetCollapsed(); pt.RowLabels.Add("Month").SetCollapsed(); @@ -133,7 +132,7 @@ public void Create(String filePath) #region Pivot table with a field both as a value and as a row/column/filter label ptSheet = wb.Worksheets.Add("pvtFieldAsValueAndLabel"); - pt = ptSheet.PivotTables.Add("pvtFieldAsValueAndLabel", ptSheet.Cell(1, 1), table.AsRange()); + pt = ptSheet.PivotTables.Add("pvtFieldAsValueAndLabel", ptSheet.Cell(1, 1), table); pt.RowLabels.Add("Name"); pt.RowLabels.Add("Month"); @@ -147,7 +146,7 @@ public void Create(String filePath) ptSheet = wb.Worksheets.Add("pvtHideSubTotals"); // Create the pivot table, using the data from the "PastrySalesData" table - pt = ptSheet.PivotTables.Add("pvtHidesubTotals", ptSheet.Cell(1, 1), table.AsRange()); + pt = ptSheet.PivotTables.Add("pvtHidesubTotals", ptSheet.Cell(1, 1), table); // The rows in our pivot table will be the names of the pastries pt.RowLabels.Add(XLConstants.PivotTable.ValuesSentinalLabel); @@ -194,11 +193,11 @@ public void Create(String filePath) .AddSelectedValue(new DateTime(2017, 05, 03)); #endregion Pivot Table with filter - + #region Pivot table sorting ptSheet = wb.Worksheets.Add("pvtSort"); - pt = ptSheet.PivotTables.Add("pvtSort", ptSheet.Cell(1, 1), table.AsRange()); + pt = ptSheet.PivotTables.Add("pvtSort", ptSheet.Cell(1, 1), table); pt.RowLabels.Add("Name").SetSort(XLPivotSortType.Ascending); pt.RowLabels.Add("Month").SetSort(XLPivotSortType.Descending); @@ -208,7 +207,7 @@ public void Create(String filePath) pt.SetRowHeaderCaption("Pastry name"); - #endregion Different kind of pivot + #endregion Pivot table sorting #region Pivot Table with integer rows @@ -225,7 +224,7 @@ public void Create(String filePath) pt.Values.Add("NumberOfOrders").SetSummaryFormula(XLPivotSummary.Sum); pt.Values.Add("Quality").SetSummaryFormula(XLPivotSummary.Sum); - #endregion Pivot Table with filter + #endregion Pivot Table with integer rows wb.SaveAs(filePath); } diff --git a/ClosedXML.Examples/Ranges/CopyingRanges.cs b/ClosedXML.Examples/Ranges/CopyingRanges.cs index 7d222224d..f740b2d26 100644 --- a/ClosedXML.Examples/Ranges/CopyingRanges.cs +++ b/ClosedXML.Examples/Ranges/CopyingRanges.cs @@ -21,7 +21,7 @@ public void Create(string filePath) // Copy the table to another worksheet var wsCopy = workbook.Worksheets.Add("Contacts Copy"); - wsCopy.Cell(1, 1).Value = rngData; + wsCopy.Cell(1, 1).CopyFrom(rngData); workbook.SaveAs(filePath); } @@ -34,4 +34,4 @@ public void Create(string filePath) } } } -} \ No newline at end of file +} diff --git a/ClosedXML.Examples/Ranges/CurrentRowColumn.cs b/ClosedXML.Examples/Ranges/CurrentRowColumn.cs index 0dda10784..1c12b3845 100644 --- a/ClosedXML.Examples/Ranges/CurrentRowColumn.cs +++ b/ClosedXML.Examples/Ranges/CurrentRowColumn.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using ClosedXML.Excel; diff --git a/ClosedXML.Examples/Ranges/NamedRanges.cs b/ClosedXML.Examples/Ranges/DefinedNames.cs similarity index 83% rename from ClosedXML.Examples/Ranges/NamedRanges.cs rename to ClosedXML.Examples/Ranges/DefinedNames.cs index a08b161f0..95880dadd 100644 --- a/ClosedXML.Examples/Ranges/NamedRanges.cs +++ b/ClosedXML.Examples/Ranges/DefinedNames.cs @@ -3,11 +3,8 @@ namespace ClosedXML.Examples.Misc { - public class NamedRanges : IXLExample + public class DefinedNames : IXLExample { - #region Methods - - // Public public void Create(String filePath) { var wb = new XLWorkbook(); @@ -28,10 +25,10 @@ public void Create(String filePath) wsData.Range("A2:B4").AddToNamed("PeopleData"); // Default named range scope is Workbook // Create a hidden named range - wb.NamedRanges.Add("Headers", wsData.Range("A1:B1")).Visible = false; + wb.DefinedNames.Add("Headers", wsData.Range("A1:B1")).Visible = false; // Create a hidden named range n worksheet scope - wsData.NamedRanges.Add("HeadersAndData", wsData.Range("A1:B4")).Visible = false; + wsData.DefinedNames.Add("HeadersAndData", wsData.Range("A1:B4")).Visible = false; // Let's use the named range in a formula: wsPresentation.Cell(1, 1).Value = "People Count:"; @@ -46,7 +43,7 @@ public void Create(String filePath) // Copy the data in a named range: wsPresentation.Cell(4, 1).Value = "People Data:"; - wsPresentation.Cell(5, 1).Value = wb.Range("PeopleData"); + wsPresentation.Cell(5, 1).CopyFrom(wb.Range("PeopleData")); ///////////////////////////////////////////////////////////////////////// // For the Excel geeks out there who actually know about @@ -56,12 +53,12 @@ public void Create(String filePath) // The following creates a relative named range pointing to the same row // and one column to the right. For example if the current cell is B4 // relativeRange1 will point to C4. - wsPresentation.NamedRanges.Add("relativeRange1", "Presentation!B1"); + wsPresentation.DefinedNames.Add("relativeRange1", "Presentation!B1"); // The following creates a relative named range pointing to the same row // and one column to the left. For example if the current cell is D2 // relativeRange2 will point to C2. - wb.NamedRanges.Add("relativeRange2", "Presentation!XFD1"); + wb.DefinedNames.Add("relativeRange2", "Presentation!XFD1"); // Explanation: The address of a relative range always starts at A1 // and moves from then on. To get the desired relative range just @@ -75,11 +72,5 @@ public void Create(String filePath) wb.SaveAs(filePath); } - - // Private - - // Override - - #endregion Methods } } diff --git a/ClosedXML.Examples/Ranges/SelectingRanges.cs b/ClosedXML.Examples/Ranges/SelectingRanges.cs index 4e13ce37f..16677d6f8 100644 --- a/ClosedXML.Examples/Ranges/SelectingRanges.cs +++ b/ClosedXML.Examples/Ranges/SelectingRanges.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using ClosedXML.Excel; diff --git a/ClosedXML.Examples/Ranges/SortExample.cs b/ClosedXML.Examples/Ranges/SortExample.cs index 875ce3f3a..bc20c8c59 100644 --- a/ClosedXML.Examples/Ranges/SortExample.cs +++ b/ClosedXML.Examples/Ranges/SortExample.cs @@ -7,40 +7,6 @@ namespace ClosedXML.Examples.Misc { public class SortExample : IXLExample { - #region Variables - - // Public - - // Private - - - #endregion - - #region Properties - - // Public - - // Private - - // Override - - - #endregion - - #region Events - - // Public - - // Private - - // Override - - - #endregion - - #region Methods - - // Public public void Create(String filePath) { var wb = new XLWorkbook(); @@ -138,74 +104,31 @@ public void Create(String filePath) wb.SaveAs(filePath); } - - private void AddTestColumnMixed(IXLWorksheet ws) - { - ws.Cell("A1").SetValue(new DateTime(2011, 1, 30)).Style.Fill.SetBackgroundColor(XLColor.LightGreen); - ws.Cell("A2").SetValue(1.15).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); - ws.Cell("A3").SetValue(new TimeSpan(1, 1, 12, 30)).Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); - ws.Cell("A6").SetValue(9).Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); - ws.Cell("A7").SetValue(new TimeSpan(9, 4, 30)).Style.Fill.SetBackgroundColor(XLColor.IndianRed); - ws.Cell("A8").SetValue(new DateTime(2011, 4, 15)).Style.Fill.SetBackgroundColor(XLColor.DeepPink); - } - private void AddTestColumnNumbers(IXLWorksheet ws) - { - ws.Cell("A1").SetValue(1.30).Style.Fill.SetBackgroundColor(XLColor.LightGreen); - ws.Cell("A2").SetValue(1.15).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); - ws.Cell("A3").SetValue(1230).Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); - ws.Cell("A6").SetValue(9).Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); - ws.Cell("A7").SetValue(4.30).Style.Fill.SetBackgroundColor(XLColor.IndianRed); - ws.Cell("A8").SetValue(4.15).Style.Fill.SetBackgroundColor(XLColor.DeepPink); - } - private void AddTestColumnTimeSpans(IXLWorksheet ws) - { - ws.Cell("A1").SetValue(new TimeSpan(0, 12, 35, 21)).Style.Fill.SetBackgroundColor(XLColor.LightGreen); - ws.Cell("A2").SetValue(new TimeSpan(45, 1, 15)).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); - ws.Cell("A3").SetValue(new TimeSpan(1, 1, 12, 30)).Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); - ws.Cell("A6").SetValue(new TimeSpan(0, 12, 15)).Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); - ws.Cell("A7").SetValue(new TimeSpan(1, 4, 30)).Style.Fill.SetBackgroundColor(XLColor.IndianRed); - ws.Cell("A8").SetValue(new TimeSpan(1, 4, 15)).Style.Fill.SetBackgroundColor(XLColor.DeepPink); - } - private void AddTestColumnDates(IXLWorksheet ws) - { - ws.Cell("A1").SetValue(new DateTime(2011, 1, 30)).Style.Fill.SetBackgroundColor(XLColor.LightGreen); - ws.Cell("A2").SetValue(new DateTime(2011, 1, 15)).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); - ws.Cell("A3").SetValue(new DateTime(2011, 12, 30)).Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); - ws.Cell("A6").SetValue(new DateTime(2011, 12, 15)).Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); - ws.Cell("A7").SetValue(new DateTime(2011, 4, 30)).Style.Fill.SetBackgroundColor(XLColor.IndianRed); - ws.Cell("A8").SetValue(new DateTime(2011, 4, 15)).Style.Fill.SetBackgroundColor(XLColor.DeepPink); - } + private void AddTestColumn(IXLWorksheet ws) { ws.Cell("A1").SetValue("B").Style.Fill.SetBackgroundColor(XLColor.LightGreen); ws.Cell("A2").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("A3").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); + ws.Cell("A4").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkGray); + ws.Cell("A5").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("A6").SetValue("b").Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("A7").SetValue("B").Style.Fill.SetBackgroundColor(XLColor.IndianRed); ws.Cell("A8").SetValue("c").Style.Fill.SetBackgroundColor(XLColor.DeepPink); } + private void AddTestTable(IXLWorksheet ws) { ws.Cell("A1").SetValue("B").Style.Fill.SetBackgroundColor(XLColor.LightGreen); ws.Cell("A2").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("A3").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.BurlyWood); ws.Cell("A4").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); + ws.Cell("A5").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("A6").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("A7").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.IndianRed); ws.Cell("A8").SetValue("B").Style.Fill.SetBackgroundColor(XLColor.DeepPink); - ws.Cell("B1").SetValue("").Style.Fill.SetBackgroundColor(XLColor.LightGreen); + ws.Cell("B1").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.LightGreen); ws.Cell("B2").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("B3").SetValue("B").Style.Fill.SetBackgroundColor(XLColor.BurlyWood); ws.Cell("B4").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkGray); @@ -215,19 +138,13 @@ private void AddTestTable(IXLWorksheet ws) ws.Cell("B8").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.DeepPink); ws.Cell("C1").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.LightGreen); - ws.Cell("C2").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); + ws.Cell("C2").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("C3").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.BurlyWood); ws.Cell("C4").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.DarkGray); ws.Cell("C5").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("C6").SetValue("b").Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("C7").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.IndianRed); - ws.Cell("C8").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DeepPink); + ws.Cell("C8").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DeepPink); } - // Private - - // Override - - - #endregion } } diff --git a/ClosedXML.Examples/Ranges/Sorting.cs b/ClosedXML.Examples/Ranges/Sorting.cs index 95d6d39a2..caafec177 100644 --- a/ClosedXML.Examples/Ranges/Sorting.cs +++ b/ClosedXML.Examples/Ranges/Sorting.cs @@ -169,8 +169,8 @@ private void AddTestColumnMixed(IXLWorksheet ws) ws.Cell("A1").SetValue(new DateTime(2011, 1, 30)).Style.Fill.SetBackgroundColor(XLColor.LightGreen); ws.Cell("A2").SetValue(1.15).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("A3").SetValue(new TimeSpan(1, 1, 12, 30)).Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); + ws.Cell("A4").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkGray); + ws.Cell("A5").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("A6").SetValue(9).Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("A7").SetValue(new TimeSpan(9, 4, 30)).Style.Fill.SetBackgroundColor(XLColor.IndianRed); ws.Cell("A8").SetValue(new DateTime(2011, 4, 15)).Style.Fill.SetBackgroundColor(XLColor.DeepPink); @@ -181,8 +181,8 @@ private void AddTestColumnNumbers(IXLWorksheet ws) ws.Cell("A1").SetValue(1.30).Style.Fill.SetBackgroundColor(XLColor.LightGreen); ws.Cell("A2").SetValue(1.15).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("A3").SetValue(1230).Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); + ws.Cell("A4").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkGray); + ws.Cell("A5").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("A6").SetValue(9).Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("A7").SetValue(4.30).Style.Fill.SetBackgroundColor(XLColor.IndianRed); ws.Cell("A8").SetValue(4.15).Style.Fill.SetBackgroundColor(XLColor.DeepPink); @@ -193,8 +193,8 @@ private void AddTestColumnTimeSpans(IXLWorksheet ws) ws.Cell("A1").SetValue(new TimeSpan(0, 12, 35, 21)).Style.Fill.SetBackgroundColor(XLColor.LightGreen); ws.Cell("A2").SetValue(new TimeSpan(45, 1, 15)).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("A3").SetValue(new TimeSpan(1, 1, 12, 30)).Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); + ws.Cell("A4").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkGray); + ws.Cell("A5").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("A6").SetValue(new TimeSpan(0, 12, 15)).Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("A7").SetValue(new TimeSpan(1, 4, 30)).Style.Fill.SetBackgroundColor(XLColor.IndianRed); ws.Cell("A8").SetValue(new TimeSpan(1, 4, 15)).Style.Fill.SetBackgroundColor(XLColor.DeepPink); @@ -205,8 +205,8 @@ private void AddTestColumnDates(IXLWorksheet ws) ws.Cell("A1").SetValue(new DateTime(2011, 1, 30)).Style.Fill.SetBackgroundColor(XLColor.LightGreen); ws.Cell("A2").SetValue(new DateTime(2011, 1, 15)).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("A3").SetValue(new DateTime(2011, 12, 30)).Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); + ws.Cell("A4").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkGray); + ws.Cell("A5").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("A6").SetValue(new DateTime(2011, 12, 15)).Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("A7").SetValue(new DateTime(2011, 4, 30)).Style.Fill.SetBackgroundColor(XLColor.IndianRed); ws.Cell("A8").SetValue(new DateTime(2011, 4, 15)).Style.Fill.SetBackgroundColor(XLColor.DeepPink); @@ -217,8 +217,8 @@ private void AddTestColumn(IXLWorksheet ws) ws.Cell("A1").SetValue("B").Style.Fill.SetBackgroundColor(XLColor.LightGreen); ws.Cell("A2").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("A3").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.BurlyWood); - ws.Cell("A4").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); + ws.Cell("A4").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkGray); + ws.Cell("A5").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("A6").SetValue("b").Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("A7").SetValue("B").Style.Fill.SetBackgroundColor(XLColor.IndianRed); ws.Cell("A8").SetValue("c").Style.Fill.SetBackgroundColor(XLColor.DeepPink); @@ -230,12 +230,12 @@ private void AddTestTable(IXLWorksheet ws) ws.Cell("A2").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("A3").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.BurlyWood); ws.Cell("A4").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkGray); - ws.Cell("A5").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); + ws.Cell("A5").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("A6").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("A7").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.IndianRed); ws.Cell("A8").SetValue("B").Style.Fill.SetBackgroundColor(XLColor.DeepPink); - ws.Cell("B1").SetValue("").Style.Fill.SetBackgroundColor(XLColor.LightGreen); + ws.Cell("B1").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.LightGreen); ws.Cell("B2").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("B3").SetValue("B").Style.Fill.SetBackgroundColor(XLColor.BurlyWood); ws.Cell("B4").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkGray); @@ -245,13 +245,13 @@ private void AddTestTable(IXLWorksheet ws) ws.Cell("B8").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.DeepPink); ws.Cell("C1").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.LightGreen); - ws.Cell("C2").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); + ws.Cell("C2").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DarkTurquoise); ws.Cell("C3").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.BurlyWood); ws.Cell("C4").SetValue("a").Style.Fill.SetBackgroundColor(XLColor.DarkGray); ws.Cell("C5").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.DarkSalmon); ws.Cell("C6").SetValue("b").Style.Fill.SetBackgroundColor(XLColor.DodgerBlue); ws.Cell("C7").SetValue("A").Style.Fill.SetBackgroundColor(XLColor.IndianRed); - ws.Cell("C8").SetValue("").Style.Fill.SetBackgroundColor(XLColor.DeepPink); + ws.Cell("C8").SetValue(Blank.Value).Style.Fill.SetBackgroundColor(XLColor.DeepPink); } } } diff --git a/ClosedXML.Examples/Ranges/TransposeRangesPlus.cs b/ClosedXML.Examples/Ranges/TransposeRangesPlus.cs index 6846d7398..704a22adc 100644 --- a/ClosedXML.Examples/Ranges/TransposeRangesPlus.cs +++ b/ClosedXML.Examples/Ranges/TransposeRangesPlus.cs @@ -43,4 +43,4 @@ public void Create(string filePath) } } } -} \ No newline at end of file +} diff --git a/ClosedXML.Examples/Ranges/WalkingRanges.cs b/ClosedXML.Examples/Ranges/WalkingRanges.cs index 26d374de1..ccfcb450c 100644 --- a/ClosedXML.Examples/Ranges/WalkingRanges.cs +++ b/ClosedXML.Examples/Ranges/WalkingRanges.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using ClosedXML.Excel; diff --git a/ClosedXML.Examples/Styles/StyleFont.cs b/ClosedXML.Examples/Styles/StyleFont.cs index da9b1e228..01541d101 100644 --- a/ClosedXML.Examples/Styles/StyleFont.cs +++ b/ClosedXML.Examples/Styles/StyleFont.cs @@ -45,6 +45,9 @@ public void Create(String filePath) ws.Cell(++ro, co).Value = "VerticalAlignment - Superscript"; ws.Cell(ro, co).Style.Font.VerticalAlignment = XLFontVerticalTextAlignmentValues.Superscript; + ws.Cell(++ro, co).Value = "FontScheme - Major"; + ws.Cell(ro, co).Style.Font.FontScheme = XLFontScheme.Major; + ws.Column(co).AdjustToContents(); workbook.SaveAs(filePath); diff --git a/ClosedXML.Examples/Styles/StyleNumberFormat.cs b/ClosedXML.Examples/Styles/StyleNumberFormat.cs index 92a1b4999..3abe9fb14 100644 --- a/ClosedXML.Examples/Styles/StyleNumberFormat.cs +++ b/ClosedXML.Examples/Styles/StyleNumberFormat.cs @@ -61,13 +61,13 @@ public void Create(String filePath) var co = 2; var ro = 1; - ws.Cell(++ro, co).Value = "123456.789"; + ws.Cell(++ro, co).Value = 123456.789d; ws.Cell(ro, co).Style.NumberFormat.Format = "$ #,##0.00"; - ws.Cell(++ro, co).Value = "12.345"; + ws.Cell(++ro, co).Value = 12.345d; ws.Cell(ro, co).Style.NumberFormat.Format = "0000"; - ws.Cell(++ro, co).Value = "12.345"; + ws.Cell(++ro, co).Value = 12.345d; ws.Cell(ro, co).Style.NumberFormat.NumberFormatId = 3; ws.Column(co).AdjustToContents(); diff --git a/ClosedXML.Examples/Styles/StyleRowsColumns.cs b/ClosedXML.Examples/Styles/StyleRowsColumns.cs index 1d893e2a1..ba284fee6 100644 --- a/ClosedXML.Examples/Styles/StyleRowsColumns.cs +++ b/ClosedXML.Examples/Styles/StyleRowsColumns.cs @@ -31,4 +31,4 @@ public void Create(String filePath) workbook.SaveAs(filePath); } } -} \ No newline at end of file +} diff --git a/ClosedXML.Examples/Styles/UsingPhonetics.cs b/ClosedXML.Examples/Styles/UsingPhonetics.cs index 6b3736438..8982aa2e4 100644 --- a/ClosedXML.Examples/Styles/UsingPhonetics.cs +++ b/ClosedXML.Examples/Styles/UsingPhonetics.cs @@ -5,11 +5,6 @@ namespace ClosedXML.Examples.Styles { public class UsingPhonetics : IXLExample { - - - #region Methods - - // Public public void Create(String filePath) { var wb = new XLWorkbook(); @@ -26,16 +21,10 @@ public void Create(String filePath) cell.GetRichText().Phonetics.Add("げん", 7, 8); cell.GetRichText().Phonetics.Add("き", 8, 9); - //TODO: I'm looking for someone who understands Japanese to confirm the validity of the above code. + // Must set flag to actually display furigana + cell.ShowPhonetic = true; wb.SaveAs(filePath); } - - // Private - - // Override - - - #endregion } } diff --git a/ClosedXML.Examples/Styles/UsingRichText.cs b/ClosedXML.Examples/Styles/UsingRichText.cs index b258bae73..d9cf6a42e 100644 --- a/ClosedXML.Examples/Styles/UsingRichText.cs +++ b/ClosedXML.Examples/Styles/UsingRichText.cs @@ -5,51 +5,6 @@ namespace ClosedXML.Examples.Styles { public class UsingRichText : IXLExample { - #region Variables - - // Public - - // Private - - - #endregion - - #region Properties - - // Public - - // Private - - // Override - - - #endregion - - #region Constructors - - // Public - - - - // Private - - - #endregion - - #region Events - - // Public - - // Private - - // Override - - - #endregion - - #region Methods - - // Public public void Create(String filePath) { var wb = new XLWorkbook(); @@ -103,19 +58,27 @@ public void Create(String filePath) cell.Style.Fill.SetBackgroundColor(XLColor.Gray); - ws.Cell(5, 2).Value = cell.GetRichText(); // Should copy only rich text, but not background - + ws.Cell(5, 2).GetRichText().CopyFrom(cell.GetRichText()); // Should copy only rich text, but not cell background + + // Parts of the rich text can be associated with a font scheme that changes font, when you switch theme + cell = ws.Cell(6, 1); + cell.Style.Font.FontSize = 25; + cell.GetRichText() + .AddText("Major scheme") + .SetFontName("Arial") + .SetFontScheme(XLFontScheme.Major) + .AddText(" ") + .AddText("Minor scheme") + .SetFontName(@"Times New Roman") + .SetFontScheme(XLFontScheme.Minor) + .AddText(" ") + .AddText("No scheme") + .SetFontName("Cambria") + .SetFontScheme(XLFontScheme.None); ws.Columns().AdjustToContents(); wb.SaveAs(filePath); } - - // Private - - // Override - - - #endregion } } diff --git a/ClosedXML.Examples/Tables/InsertingTables.cs b/ClosedXML.Examples/Tables/InsertingTables.cs index 1e749911f..bcfa825bb 100644 --- a/ClosedXML.Examples/Tables/InsertingTables.cs +++ b/ClosedXML.Examples/Tables/InsertingTables.cs @@ -67,7 +67,7 @@ public void Create(String filePath) titlesStyle.Fill.BackgroundColor = XLColor.Cyan; // Format all titles in one shot - wb.NamedRanges.NamedRange("Titles").Ranges.Style = titlesStyle; + wb.DefinedNames.DefinedName("Titles").Ranges.Style = titlesStyle; ws.Columns().AdjustToContents(); diff --git a/ClosedXML.IO.CodeGen/ClosedXML.IO.CodeGen.csproj b/ClosedXML.IO.CodeGen/ClosedXML.IO.CodeGen.csproj new file mode 100644 index 000000000..8b52a15b3 --- /dev/null +++ b/ClosedXML.IO.CodeGen/ClosedXML.IO.CodeGen.csproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + latest + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/ClosedXML.IO.CodeGen/CodeBuilder.cs b/ClosedXML.IO.CodeGen/CodeBuilder.cs new file mode 100644 index 000000000..0f6c4f6dd --- /dev/null +++ b/ClosedXML.IO.CodeGen/CodeBuilder.cs @@ -0,0 +1,138 @@ +using ClosedXML.IO.CodeGen.Model; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace ClosedXML.IO.CodeGen; + +internal class CodeBuilder +{ + /// + /// C# keywords. The variables with that name must be escaped, e.g. in must be + /// @in. + /// + private static readonly HashSet Keywords = ["in", "out", "ref"]; + + /// + /// Prefix of complex types in XML schema. + /// + private const string CtPrefix = "CT_"; + + private readonly SchemeTypeMap _typeMap; + private readonly StringBuilder _sb; + private int _indentLevel; + + public CodeBuilder(StringBuilder sb, SchemeTypeMap typeMap) + { + _sb = sb; + _typeMap = typeMap; + } + + internal CodeBuilder AddLine(string lineText) + { + AddIndentedLine(lineText); + return this; + } + + internal CodeBuilder OpenBrace() + { + AddIndentedLine("{"); + _indentLevel++; + return this; + } + + internal CodeBuilder CloseBrace() + { + _indentLevel--; + AddIndentedLine("}"); + return this; + } + + internal CodeBuilder Append(string text) + { + _sb.Append(text); + return this; + } + + internal CodeBuilder EndLine() + { + _sb.AppendLine(); + return this; + } + + internal CodeBuilder WriteIndent() + { + AddIndentation(); + return this; + } + + internal CodeBuilder AppendVariable(string variableName) + { + _sb.Append(Keywords.Contains(variableName) ? '@' + variableName : variableName); + return this; + } + + internal CodeBuilder StartMethod(string signaturePattern, string typeName) + { + AddIndentation(); + _sb.Append("private "); + _sb.AppendFormat(signaturePattern, NormalizeCt(typeName)); + _sb.AppendLine(); + return this; + } + + internal string NormalizeCt(string typeName) + { + if (!typeName.StartsWith(CtPrefix)) + throw new ArgumentException("Type isn't a complex type.", nameof(typeName)); + + return typeName[CtPrefix.Length..]; + } + + internal string GetSimpleType(string simpleType) + { + return _typeMap.GetSimpleType(simpleType).CsTypeName; + } + + internal CodeBuilder AppendValue(string simpleType, string value) + { + var mappedValue = _typeMap.GetSimpleType(simpleType).MapValue(value); + _sb.Append(mappedValue); + return this; + } + + internal bool TryGetComplexType(string complexType, [NotNullWhen(true)] out string? csType) + { + return _typeMap.TryGetComplexTypeCsType(complexType, out csType); + } + + internal CodeBuilder AppendComplexType(string typeName) + { + _sb.Append(NormalizeCt(typeName)); + return this; + } + + internal CodeBuilder AppendSimpleTypeMethod(AttributeElement attribute) + { + var codeFragment = _typeMap.GetSimpleTypeMethod(attribute); + return Append(codeFragment); + } + + private void AddIndentedLine(string text) + { + AddIndentation(); + _sb.AppendLine(text); + } + + private void AddIndentation() + { + for (var i = 0; i < _indentLevel; i++) + _sb.Append(" "); + } + + public override string ToString() + { + return _sb.ToString(); + } +} diff --git a/ClosedXML.IO.CodeGen/INode.cs b/ClosedXML.IO.CodeGen/INode.cs new file mode 100644 index 000000000..bf4434cd9 --- /dev/null +++ b/ClosedXML.IO.CodeGen/INode.cs @@ -0,0 +1,9 @@ +namespace ClosedXML.IO.CodeGen; + +/// +/// A node visited by a . +/// +public interface INode +{ + T Accept(IXsdVisitor visitor); +} diff --git a/ClosedXML.IO.CodeGen/IXsdVisitor.cs b/ClosedXML.IO.CodeGen/IXsdVisitor.cs new file mode 100644 index 000000000..bf275309c --- /dev/null +++ b/ClosedXML.IO.CodeGen/IXsdVisitor.cs @@ -0,0 +1,51 @@ +using ClosedXML.IO.CodeGen.Model; +using ClosedXML.IO.CodeGen.Model.Elements; +using ClosedXML.IO.CodeGen.Model.SimpleTypes; +using ClosedXML.IO.CodeGen.Model.TopLevel; + +namespace ClosedXML.IO.CodeGen; + +/// +/// A visitor for visiting nodes and running a code for each type of a node. +/// +/// Type of returned value. +public interface IXsdVisitor +{ + TResult Visit(Schema schema); + + TResult Visit(SimpleType simpleType); + + TResult Visit(SimpleTypeList simpleType); + + TResult Visit(SimpleTypeUnion simpleType); + + TResult Visit(ElementDefinition elementDefinition); + + TResult Visit(GroupDefinition groupDefinition); + + TResult Visit(AttributeGroupDefinition attributeGroupDefinition); + + TResult Visit(ComplexTypeSequence complexType); + + TResult Visit(ComplexTypeChoice complexType); + + TResult Visit(ComplexTypeSimpleContent complexType); + + TResult Visit(ComplexTypeElement complexType); + + TResult Visit(AttributeElement attributeElement); + + TResult Visit(AttributeGroupReference attributeGroupReference); + + TResult Visit(Sequence sequence); + + TResult Visit(Choice choice); + + TResult Visit(ElementType elementType); + + TResult Visit(ElementReference elementReference); + + TResult Visit(GroupReference groupReference); + + TResult Visit(Any any); +} diff --git a/ClosedXML.IO.CodeGen/Model/AttributeElement.cs b/ClosedXML.IO.CodeGen/Model/AttributeElement.cs new file mode 100644 index 000000000..0ce063026 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/AttributeElement.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; + +namespace ClosedXML.IO.CodeGen.Model; + +/// +/// ]]> inside ]]> or ]]> +/// +/// +/// ]]> +/// +/// +public class AttributeElement : INode +{ + /// + /// Name is technically optional in ref attribute: + /// + /// ]]> + /// + /// + public required string? Name { get; set; } + + public required string? RefName { get; set; } + + public required string? Type { get; set; } + + public AttributeUseType Use { get; set; } + + public string? DefaultValue { get; set; } + + internal bool IsOptional => Use is AttributeUseType.Default or AttributeUseType.Optional; + + private bool CanBeNull => IsOptional && DefaultValue is null; + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } + + internal Variable Generate(CodeBuilder code) + { + Debug.Assert(Name is not null); + Debug.Assert(Type is not null); + code.WriteIndent().Append("var ").AppendVariable(Name).Append(" = ").AppendSimpleTypeMethod(this); + if (DefaultValue is not null) + code.Append(" ?? ").AppendValue(Type, DefaultValue); + + code.Append(";").EndLine(); + + var csType = CanBeNull ? code.GetSimpleType(Type) + '?' : code.GetSimpleType(Type); + return new Variable(csType, Name); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/AttributeUseType.cs b/ClosedXML.IO.CodeGen/Model/AttributeUseType.cs new file mode 100644 index 000000000..d161798b6 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/AttributeUseType.cs @@ -0,0 +1,14 @@ +namespace ClosedXML.IO.CodeGen.Model; + +/// +/// Is attribute optional or does it always has to be specified? +/// +public enum AttributeUseType +{ + /// + /// Default is . + /// + Default, + Optional, + Required +} diff --git a/ClosedXML.IO.CodeGen/Model/Elements/Any.cs b/ClosedXML.IO.CodeGen/Model/Elements/Any.cs new file mode 100644 index 000000000..1419bb8ef --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Elements/Any.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.Elements; + +/// +/// ]]> inside ]]> (through +/// ]]> or ]]>). +/// +/// ]]> +/// +/// +public class Any : IElementGroup +{ + public List Children { get; } = []; + + public required ProcessContents ProcessContent { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/Elements/AttributeGroupReference.cs b/ClosedXML.IO.CodeGen/Model/Elements/AttributeGroupReference.cs new file mode 100644 index 000000000..ab13bd94d --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Elements/AttributeGroupReference.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using ClosedXML.IO.CodeGen.Model.TopLevel; + +namespace ClosedXML.IO.CodeGen.Model.Elements; + +/// +/// An ]]> inside a ]]>. +/// +/// +/// +/// +/// +/// ]]> +/// +/// +public class AttributeGroupReference : ILeafElement +{ + public List Children { get; } = []; + + /// + /// Name of referenced attribute group (). + /// + public required string RefName { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/Elements/Choice.cs b/ClosedXML.IO.CodeGen/Model/Elements/Choice.cs new file mode 100644 index 000000000..90e941ec6 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Elements/Choice.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.Elements; + +/// +/// ]]> inside ]]>. +/// +public class Choice : IElementGroup +{ + public required List Children { get; init; } = []; + + public required Occurrences Occurrences { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/Elements/ElementReference.cs b/ClosedXML.IO.CodeGen/Model/Elements/ElementReference.cs new file mode 100644 index 000000000..f44b453e7 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Elements/ElementReference.cs @@ -0,0 +1,29 @@ +using ClosedXML.IO.CodeGen.Model.TopLevel; +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.Elements; + +/// +/// ]]> inside ]]>. +/// +/// +/// ]]> +/// +/// +public class ElementReference : ILeafElement +{ + public List Children { get; } = []; + + /// + /// Name of referenced element in the element definition (). + /// + public required string RefName { get; init; } + + public required Occurrences Occurrences { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/Elements/ElementType.cs b/ClosedXML.IO.CodeGen/Model/Elements/ElementType.cs new file mode 100644 index 000000000..147a0160f --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Elements/ElementType.cs @@ -0,0 +1,113 @@ +using System; +using ClosedXML.IO.CodeGen.Model.TopLevel; +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.Elements; + +/// +/// ]]> inside ]]> +/// (either ]]> or ]]>). +/// +/// +/// ]]> +/// +/// +public class ElementType : IElementGroup +{ + public List Children { get; } = []; + + /// + /// Name of the element in XML. + /// + public required string Name { get; init; } + + /// + /// A reference to a . + /// + public required string TypeName { get; init; } + + public required Occurrences Occurrences { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } + + internal Variable? Generate(CodeBuilder code, string namespaceField) + { + Variable? variable = null; + var typeName = code.NormalizeCt(TypeName); + var elementParseCall = $"Parse{typeName}(\"{Name}\")"; + var openArgs = $"\"{Name}\", {namespaceField}"; + var min = Occurrences.Min ?? 1; + var max = Occurrences.Max ?? 1; + + if (min == 1 && max == 1) + { + code.AddLine($"_reader.Open({openArgs});"); + code.WriteIndent(); + if (code.TryGetComplexType(TypeName, out var csType)) + { + variable = new Variable(csType, Name); + code.Append("var ").AppendVariable(Name).Append(" = "); + } + code.Append(elementParseCall).Append(";").EndLine(); + } + else if (min == 0 && max == 1) + { + if (code.TryGetComplexType(TypeName, out var csType)) + { + csType += "?"; + variable = new Variable(csType, Name); + code.WriteIndent().Append(csType).Append(" ").AppendVariable(Name).Append(" = default;").EndLine(); + code.AddLine($"if (_reader.TryOpen({openArgs}))"); + code.OpenBrace(); + code.WriteIndent().AppendVariable(Name).Append(" = ").Append(elementParseCall).Append(";").EndLine(); + code.CloseBrace(); + } + else + { + code.AddLine($"if (_reader.TryOpen({openArgs}))"); + code.OpenBrace(); + code.WriteIndent().Append(elementParseCall).Append(";").EndLine(); + code.CloseBrace(); + } + } + else if (min == 0 && max == int.MaxValue) + { + if (code.TryGetComplexType(TypeName, out var csType)) + { + csType = $"List<{csType}>"; + variable = new Variable(csType, Name); + code.WriteIndent().Append("var ").AppendVariable(variable.Name).Append($" = new {csType}();").EndLine(); + code.AddLine($"while (_reader.TryOpen({openArgs}))"); + code.OpenBrace(); + code.WriteIndent().AppendVariable(variable.Name).Append($".Add({elementParseCall})").Append(";").EndLine(); + code.CloseBrace(); + } + else + { + code.AddLine($"while (_reader.TryOpen({openArgs}))"); + code.OpenBrace(); + code.WriteIndent().Append(elementParseCall).Append(";").EndLine(); + code.CloseBrace(); + } + } + else if (min == 1 && max == int.MaxValue) + { + code.AddLine($"_reader.Open({openArgs});"); + code.AddLine("do"); + code.OpenBrace(); + code.WriteIndent().Append(elementParseCall).Append(";").EndLine(); + code.CloseBrace(); + code.AddLine($"while (_reader.TryOpen({openArgs}));"); + } + else + { + throw new NotSupportedException($"Unexpected occurence range {min}-{max}."); + } + + return variable; + } +} diff --git a/ClosedXML.IO.CodeGen/Model/Elements/GroupReference.cs b/ClosedXML.IO.CodeGen/Model/Elements/GroupReference.cs new file mode 100644 index 000000000..75490d43d --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Elements/GroupReference.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using ClosedXML.IO.CodeGen.Model.TopLevel; + +namespace ClosedXML.IO.CodeGen.Model.Elements; + +/// +/// ]]> inside ]]>. +/// +public class GroupReference : ILeafElement +{ + public List Children { get; } = []; + + /// + /// A reference to the element (). + /// + public required string RefName { get; init; } + + public required Occurrences Occurrences { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/Elements/IElementGroup.cs b/ClosedXML.IO.CodeGen/Model/Elements/IElementGroup.cs new file mode 100644 index 000000000..cf8c249ad --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Elements/IElementGroup.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.Elements; + +/// +/// A node in a complex type element tree. +/// +public interface IElementGroup: INode +{ + /// + /// Children elements. + /// + public List Children { get; } +} diff --git a/ClosedXML.IO.CodeGen/Model/Elements/ILeafElement.cs b/ClosedXML.IO.CodeGen/Model/Elements/ILeafElement.cs new file mode 100644 index 000000000..9c9f8eb4b --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Elements/ILeafElement.cs @@ -0,0 +1,6 @@ +namespace ClosedXML.IO.CodeGen.Model.Elements; + +/// +/// A marker interface for a leaf node. +/// +public interface ILeafElement : IElementGroup; diff --git a/ClosedXML.IO.CodeGen/Model/Elements/Sequence.cs b/ClosedXML.IO.CodeGen/Model/Elements/Sequence.cs new file mode 100644 index 000000000..f9aa46957 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Elements/Sequence.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.Elements; + +/// +/// ]]> inside ]]>. +/// +public class Sequence : IElementGroup +{ + public required List Children { get; init; } = []; + + public required Occurrences Occurrences { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/ElementsCount.cs b/ClosedXML.IO.CodeGen/Model/ElementsCount.cs new file mode 100644 index 000000000..c9c57f34b --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/ElementsCount.cs @@ -0,0 +1,9 @@ +namespace ClosedXML.IO.CodeGen.Model; + +internal enum ElementsCount +{ + ZeroToOne, + OneToOne, + ZeroToMany, + OneToMany, +} diff --git a/ClosedXML.IO.CodeGen/Model/ImportElement.cs b/ClosedXML.IO.CodeGen/Model/ImportElement.cs new file mode 100644 index 000000000..ea4039fef --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/ImportElement.cs @@ -0,0 +1,14 @@ +namespace ClosedXML.IO.CodeGen.Model; + +/// +/// Representation of import element in xsd file. +/// +/// ]]> +/// +public class ImportElement +{ + public required string Namespace { get; init; } + + public required string SchemaLocation { get; init; } +} diff --git a/ClosedXML.IO.CodeGen/Model/Occurrences.cs b/ClosedXML.IO.CodeGen/Model/Occurrences.cs new file mode 100644 index 000000000..4cb8ca53b --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Occurrences.cs @@ -0,0 +1,23 @@ +using System; + +namespace ClosedXML.IO.CodeGen.Model; + +public readonly record struct Occurrences(int? Min, int? Max) +{ + internal ElementsCount Elements + { + get + { + var min = Min ?? 1; + var max = Max ?? 1; + return (min, max) switch + { + (0, 1) => ElementsCount.ZeroToOne, + (0, int.MaxValue) => ElementsCount.ZeroToMany, + (1, 1) => ElementsCount.OneToOne, + (1, int.MaxValue) => ElementsCount.OneToMany, + _ => throw new NotSupportedException() + }; + } + } +}; diff --git a/ClosedXML.IO.CodeGen/Model/ProcessContents.cs b/ClosedXML.IO.CodeGen/Model/ProcessContents.cs new file mode 100644 index 000000000..b555a0be7 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/ProcessContents.cs @@ -0,0 +1,25 @@ +namespace ClosedXML.IO.CodeGen.Model; + +/// +/// How should XML processor process content of any element from xsd. +/// +public enum ProcessContents +{ + /// + /// Default value, has same meaning as . + /// + Default, + + /// + /// All elements should be validated against schema. This should be used when content + /// should only contain known schema. + /// + Strict, + + /// + /// Validation should only be performed only on elements found in schema. If there is no + /// schema, there is no error. That means some elements may belong to known schema, but + /// unknown ones shouldn't cause errors. + /// + Lax +} diff --git a/ClosedXML.IO.CodeGen/Model/Schema.cs b/ClosedXML.IO.CodeGen/Model/Schema.cs new file mode 100644 index 000000000..a7020ed21 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Schema.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using ClosedXML.IO.CodeGen.Model.TopLevel; + +namespace ClosedXML.IO.CodeGen.Model; + +/// +/// A representation of a one XSD file. +/// +public class Schema : INode +{ + /// + /// Imports in the file. + /// + public List Imports { get; } = []; + + /// + /// One of xsd:attributeGroup, xsd:complexType, xsd:element or xsd:simpleType. + /// + public List Entries { get; } = []; + + T INode.Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } + + internal bool TryGetComplexType(string complexTypeName, [NotNullWhen(true)] out ComplexType? complexType) + { + complexType = Entries.OfType().SingleOrDefault(x => x.Name == complexTypeName); + return complexType is not null; + } +} diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/ISimpleType.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/ISimpleType.cs new file mode 100644 index 000000000..7589ff2bc --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/ISimpleType.cs @@ -0,0 +1,9 @@ +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +/// +/// A marker interface for types inside ]]>. +/// +public interface ISimpleType : INode +{ + string Name { get; } +} diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/IValueRestriction.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/IValueRestriction.cs new file mode 100644 index 000000000..956c09419 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/IValueRestriction.cs @@ -0,0 +1,3 @@ +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +public interface IValueRestriction; diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictEnumeration.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictEnumeration.cs new file mode 100644 index 000000000..ced72e513 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictEnumeration.cs @@ -0,0 +1,15 @@ +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +/// +/// Value of can be the . +/// +/// +/// +/// +/// +/// ]]> +/// +/// +/// Allowed value of simple type. +public record RestrictEnumeration(string Value) : IValueRestriction; diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictLength.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictLength.cs new file mode 100644 index 000000000..a3d29fcd2 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictLength.cs @@ -0,0 +1,13 @@ +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +/// +/// Length of a must have a specified length. +/// +/// +/// +/// +/// ]]> +/// +/// +public record RestrictLength(int Value) : IValueRestriction; diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictMaxInclusive.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictMaxInclusive.cs new file mode 100644 index 000000000..d40c11261 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictMaxInclusive.cs @@ -0,0 +1,13 @@ +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +/// +/// Numerical value must be at most the specified value. +/// +/// +/// +/// +/// ]]> +/// +/// +public record RestrictMaxInclusive(int Value) : IValueRestriction; diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictMinInclusive.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictMinInclusive.cs new file mode 100644 index 000000000..4fa76e694 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/RestrictMinInclusive.cs @@ -0,0 +1,14 @@ +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +/// +/// Numerical value must be at least the specified value. +/// +/// +/// +/// +/// +/// ]]> +/// +/// +public record RestrictMinInclusive(int Value) : IValueRestriction; diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/Restriction.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/Restriction.cs new file mode 100644 index 000000000..d327bc97c --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/Restriction.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +/// +/// A definition of a restriction of a through a base type and additional +/// restrictions of possible values. +/// +public class Restriction +{ + /// + /// Name of a base type, e.g., xsd:string. + /// + public required string BaseTypeName {get; init; } + + /// + /// Additional restrictions on possible values. + /// + public required List ValueRestrictions { get; init; } +} diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/SimpleType.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/SimpleType.cs new file mode 100644 index 000000000..a531d7381 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/SimpleType.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +/// +/// ]]> inside ]]>. The allowed values +/// are restricted by . +/// +/// +/// +/// +/// +/// +/// +/// ]]> +/// +/// +public class SimpleType : ISimpleType +{ + public required string Name { get; init; } + + public required string BaseTypeName { get; init; } + + /// + /// Conditions the value must satisfy. + /// + public required List Restrictions { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/SimpleTypeList.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/SimpleTypeList.cs new file mode 100644 index 000000000..54b1d4159 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/SimpleTypeList.cs @@ -0,0 +1,24 @@ +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +/// +/// ]]> inside ]]>. List items are +/// separated by a space. +/// +/// +/// +/// +/// ]]> +/// +/// +public class SimpleTypeList : ISimpleType +{ + public required string Name { get; init; } + + public required string ItemType { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/SimpleTypes/SimpleTypeUnion.cs b/ClosedXML.IO.CodeGen/Model/SimpleTypes/SimpleTypeUnion.cs new file mode 100644 index 000000000..40e20c74e --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/SimpleTypes/SimpleTypeUnion.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.SimpleTypes; + +public class SimpleTypeUnion : ISimpleType +{ + public required string Name { get; init; } + + public required List RestrictionsUnion { get; init; } = []; + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/TopLevel/AttributeGroupDefinition.cs b/ClosedXML.IO.CodeGen/Model/TopLevel/AttributeGroupDefinition.cs new file mode 100644 index 000000000..8c316347f --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/TopLevel/AttributeGroupDefinition.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.TopLevel; + +/// +/// ]]> inside ]]>. +/// +/// +/// +/// +/// +/// +/// ]]> +/// +/// +public class AttributeGroupDefinition : IReferencable, INode +{ + /// + /// Name of the the attribute group type. + /// + public required string Name { get; init; } + + public required List Attributes { get; init; } = []; + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexType.cs b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexType.cs new file mode 100644 index 000000000..80f008076 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexType.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using ClosedXML.IO.CodeGen.Model.Elements; + +namespace ClosedXML.IO.CodeGen.Model.TopLevel; + +/// +/// Base class for nodes representing a ]]>. +/// +public abstract class ComplexType : IReferencable +{ + /// + /// Name of the complex type. + /// + public required string Name { get; set; } + + public List> Attributes { get; set; } = []; + + /// + /// Can text be freely interspersed with elements? Only used when complexType contains + /// any. + /// + public required bool? Mixed { get; init; } + + internal void Generate(CodeBuilder code, string namespaceField) + { + var attributeVariables = new List(); + + // Get return type of Parse* method + if (!code.TryGetComplexType(Name, out var csReturnType)) + csReturnType = "void"; + + code.StartMethod($"{csReturnType} Parse{{0}}(string elementName)", Name); + code.OpenBrace(); + foreach (var oneOfAttribute in Attributes) + { + if (oneOfAttribute.TryPickT1(out var attribute, out var attributeGroup)) + { + var attributeVariable = attribute.Generate(code); + attributeVariables.Add(attributeVariable); + } + else + { + throw new NotImplementedException($"Attribute group ({attributeGroup.RefName}) not yet implemented."); + } + } + + var elementVariables = GenerateParseMethod(code, namespaceField); + List dataVariables = [.. elementVariables, .. attributeVariables]; + + if (csReturnType == "void") + { + code.WriteIndent(); + AppendCallListener(code, dataVariables); + code.CloseBrace(); + + AddPartialMethodSignature(code, Name, dataVariables); + } + else + { + code.WriteIndent().Append("return "); + AppendCallListener(code, dataVariables); + code.CloseBrace(); + } + } + + internal abstract List GenerateParseMethod(CodeBuilder code, string namespaceField); + + private void AppendCallListener(CodeBuilder code, IReadOnlyList arguments) + { + code.Append("On").AppendComplexType(Name).Append("Parsed("); + var isFirst = true; + foreach (var variable in arguments) + { + if (!isFirst) + code.Append(", "); + + code.AppendVariable(variable.Name); + isFirst = false; + } + + code.Append(");").EndLine(); + } + + private void AddPartialMethodSignature(CodeBuilder code, string typeName, IReadOnlyList parameters) + { + code.EndLine(); + code.WriteIndent().Append("partial void On").AppendComplexType(typeName).Append("Parsed("); + + var isFirst = true; + foreach (var parameter in parameters) + { + if (!isFirst) + code.Append(", "); + + code.Append(parameter.Type).Append(" ").AppendVariable(parameter.Name); + isFirst = false; + } + + code.Append(");").EndLine(); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeChoice.cs b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeChoice.cs new file mode 100644 index 000000000..1de500252 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeChoice.cs @@ -0,0 +1,132 @@ +using ClosedXML.IO.CodeGen.Model.Elements; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.IO.CodeGen.Model.TopLevel; + +/// +/// ]]> that has ]]> as an element. +/// The type is inside ]]>. +/// +/// +/// +/// +/// +/// +/// +/// +/// ]]> +/// +/// +public class ComplexTypeChoice : ComplexType, INode +{ + public required Choice Choice { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } + + internal override List GenerateParseMethod(CodeBuilder code, string namespaceField) + { + var choicesCount = DetermineChoicesCount(); + + if (choicesCount == ElementsCount.ZeroToOne) + { + // The problem in 0..1 is what to do when nothing is selected. The lister approach doesn't really detect that + // The best choice for 0..1 is a variable for each choice and pass all possible choices to the hook. + + // Create a variable declarations, one variable for each choice. The values will be passed to the hook. + var variables = new List(); + foreach (var child in Choice.Children) + { + var element = (ElementType)child; + if (code.TryGetComplexType(element.TypeName, out var csType)) + { + csType += '?'; + code.WriteIndent().Append(csType).Append(" ").AppendVariable(element.Name).Append(" = null;").EndLine(); + variables.Add(new Variable(csType, element.Name)); + } + } + + var isFirst = true; + foreach (var child in Choice.Children) + { + var element = (ElementType)child; + code.WriteIndent().Append(!isFirst ? "else " : "").Append($"if (_reader.TryOpen(\"{element.Name}\", {namespaceField}))").EndLine(); + code.OpenBrace(); + code.WriteIndent(); + if (code.TryGetComplexType(element.TypeName, out _)) + code.AppendVariable(element.Name).Append(" = "); + + code.Append($"Parse{code.NormalizeCt(element.TypeName)}(\"{element.Name}\");").EndLine(); + code.CloseBrace(); + isFirst = false; + } + + code.AddLine($"_reader.Close(elementName, {namespaceField});"); + return variables; + } + + if (choicesCount == ElementsCount.OneToMany) + { + code.AddLine("do"); + code.OpenBrace(); + var isFirst = true; + foreach (var child in Choice.Children) + { + var element = (ElementType)child; + var joiner = isFirst ? string.Empty : "else "; + isFirst = false; + + code.AddLine($"{joiner}if (_reader.TryOpen(\"{element.Name}\", {namespaceField}))"); + code.OpenBrace(); + code.AddLine($"Parse{code.NormalizeCt(element.TypeName)}(\"{element.Name}\");"); + code.CloseBrace(); + } + + code.AddLine("else"); + code.OpenBrace(); + code.AddLine("throw PartStructureException.ExpectedChoiceElementNotFound(_reader);"); + code.CloseBrace(); + code.CloseBrace(); + code.AddLine($"while (!_reader.TryClose(elementName, {namespaceField}));"); + } + else + { + throw new NotImplementedException("Choice element count range is not implemented."); + } + + return []; + } + + private ElementsCount DetermineChoicesCount() + { + // OOXML XSD is not very consistent with how it defines choices, so normalize + // the choice to few selected patterns we can implement. Minimum of patterns + // means simpler and more consistent hooks. + var min = Choice.Occurrences.Min ?? 1; + var max = Choice.Occurrences.Max ?? 1; + + var allChoicesSame = Choice.Children.All(x => x is ElementType) && + Choice.Children.Cast().Select(x => x.Occurrences.Elements).Distinct().Count() == 1; + + ElementsCount? choicesElements = allChoicesSame ? Choice.Children.Cast().First().Occurrences.Elements : null; + + // This is pretty ugly, but technically valid XSD. Select one choice from choices + // that are all optional... Used for CT_Fill and few others. + if (min == 1 && max == 1 && choicesElements == ElementsCount.ZeroToOne) + { + return ElementsCount.ZeroToOne; + } + + if (min == 1 && max == int.MaxValue && choicesElements == ElementsCount.OneToOne) + { + return ElementsCount.OneToMany; + } + + throw new NotImplementedException($"Unknown code pattern for choice {Name}"); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeElement.cs b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeElement.cs new file mode 100644 index 000000000..49b5f6e74 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeElement.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.TopLevel; + +/// +/// ]]> inside ]]>. It doesn't have +/// any elements, only attributes. +/// +public class ComplexTypeElement : ComplexType, INode +{ + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } + + internal override List GenerateParseMethod(CodeBuilder code, string namespaceField) + { + code.AddLine($"_reader.Close(elementName, {namespaceField});"); + return []; + } +} diff --git a/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeSequence.cs b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeSequence.cs new file mode 100644 index 000000000..d18c98b22 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeSequence.cs @@ -0,0 +1,60 @@ +using ClosedXML.IO.CodeGen.Model.Elements; +using System; +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.TopLevel; + +/// +/// ]]> that has ]]> as an element. +/// The type is inside ]]>. +/// +/// +/// +/// +/// +/// +/// +/// +/// ]]> +/// +/// +public class ComplexTypeSequence : ComplexType, INode +{ + public required Sequence Sequence { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } + + internal override List GenerateParseMethod(CodeBuilder code, string namespaceField) + { + var dataVariables = new List(); + var min = Sequence.Occurrences.Min ?? 1; + var max = Sequence.Occurrences.Max ?? 1; + if (min == 1 && max == 1) + { + foreach (var element in Sequence.Children) + { + if (element is ElementType elementType) + { + var variable = elementType.Generate(code, namespaceField); + if (variable is not null) + dataVariables.Add(variable); + } + else + { + throw new NotImplementedException("Only element type is implemented for a sequence."); + } + } + } + else + { + throw new NotImplementedException("Only simple sequence is implemented."); + } + + code.AddLine($"_reader.Close(elementName, {namespaceField});"); + return dataVariables; + } +} diff --git a/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeSimpleContent.cs b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeSimpleContent.cs new file mode 100644 index 000000000..d6052625b --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/TopLevel/ComplexTypeSimpleContent.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace ClosedXML.IO.CodeGen.Model.TopLevel; + +/// +/// ]]> that has ]]> as an element. +/// The type is inside ]]>. +/// +/// +/// +/// +/// +/// +/// +/// ]]> +/// +public class ComplexTypeSimpleContent : ComplexType, INode +{ + public required string BaseTypeName { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } + + internal override List GenerateParseMethod(CodeBuilder code, string namespaceField) + { + throw new System.NotImplementedException(); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/TopLevel/ElementDefinition.cs b/ClosedXML.IO.CodeGen/Model/TopLevel/ElementDefinition.cs new file mode 100644 index 000000000..dc3563b3d --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/TopLevel/ElementDefinition.cs @@ -0,0 +1,29 @@ +using ClosedXML.IO.CodeGen.Model.Elements; + +namespace ClosedXML.IO.CodeGen.Model.TopLevel; + +/// +/// ]]> inside ]]>. +/// +/// +/// ]]> +/// +/// +public class ElementDefinition : IReferencable, INode +{ + /// + /// Name of the element. Referenced by . + /// + public required string Name { get; init; } + + /// + /// The type name of the element. + /// + public required string TypeName { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/TopLevel/GroupDefinition.cs b/ClosedXML.IO.CodeGen/Model/TopLevel/GroupDefinition.cs new file mode 100644 index 000000000..5a2fd8cea --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/TopLevel/GroupDefinition.cs @@ -0,0 +1,27 @@ +using ClosedXML.IO.CodeGen.Model.Elements; + +namespace ClosedXML.IO.CodeGen.Model.TopLevel; + +/// +/// ]]> inside ]]>. +/// +/// +/// +/// +/// +/// +/// ]]> +/// +/// +public class GroupDefinition : IReferencable, INode +{ + public required string Name { get; init; } + + public required IElementGroup Content { get; init; } + + public T Accept(IXsdVisitor visitor) + { + return visitor.Visit(this); + } +} diff --git a/ClosedXML.IO.CodeGen/Model/TopLevel/IReferencable.cs b/ClosedXML.IO.CodeGen/Model/TopLevel/IReferencable.cs new file mode 100644 index 000000000..642c76a43 --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/TopLevel/IReferencable.cs @@ -0,0 +1,12 @@ +namespace ClosedXML.IO.CodeGen.Model.TopLevel; + +/// +/// A marker interface for elements that can be referenced from other parts of XSD. +/// +internal interface IReferencable +{ + /// + /// The name used by other to reference this element. + /// + string Name { get; } +} diff --git a/ClosedXML.IO.CodeGen/Model/Variable.cs b/ClosedXML.IO.CodeGen/Model/Variable.cs new file mode 100644 index 000000000..032907eeb --- /dev/null +++ b/ClosedXML.IO.CodeGen/Model/Variable.cs @@ -0,0 +1,8 @@ +namespace ClosedXML.IO.CodeGen.Model; + +/// +/// A variable in the generated code. +/// +/// C# type of variable, includes nullability. +/// Name of variable (unescaped). +internal record Variable(string Type, string Name); diff --git a/ClosedXML.IO.CodeGen/OneOf.cs b/ClosedXML.IO.CodeGen/OneOf.cs new file mode 100644 index 000000000..3dee9837f --- /dev/null +++ b/ClosedXML.IO.CodeGen/OneOf.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace ClosedXML.IO.CodeGen; + +public readonly record struct OneOf +{ + private readonly T1? _t1; + private readonly T2? _t2; + + private OneOf(T1? t1, T2? t2) + { + _t1 = t1; + _t2 = t2; + } + + public static implicit operator OneOf(T1 value) => new(value, default); + public static implicit operator OneOf(T2 value) => new(default, value); + + public bool TryPickT1([NotNullWhen(true)] out T1? t1, [NotNullWhen(false)] out T2? t2) + { + t1 = _t1!; + t2 = _t2!; + return !EqualityComparer.Default.Equals(_t1, default); + } +} diff --git a/ClosedXML.IO.CodeGen/ParserGenerator.cs b/ClosedXML.IO.CodeGen/ParserGenerator.cs new file mode 100644 index 000000000..c824a417d --- /dev/null +++ b/ClosedXML.IO.CodeGen/ParserGenerator.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Text; +using ClosedXML.IO.CodeGen.Model; + +namespace ClosedXML.IO.CodeGen; + +internal class ParserGenerator +{ + private readonly Schema _schema; + private readonly string _readerName; + private readonly string _namespaceField; + private readonly List _parseMethods = new(); + private readonly SchemeTypeMap _typeMap; + private readonly List _usings = new(); + private string _targetNamespace = "ClosedXML.Excel.IO"; + + internal ParserGenerator(Schema schema, SchemeTypeMap typeMap, string readerField, string nsVariable) + { + _schema = schema; + _typeMap = typeMap; + _readerName = readerField; + _namespaceField = nsVariable; + } + + public ParserGenerator WithNamespace(string targetNamespace) + { + _targetNamespace = targetNamespace; + return this; + } + + public ParserGenerator AddUsing(string usingNamespace) + { + _usings.Add(usingNamespace); + return this; + } + + /// + /// Generate Parse* method for a complex type. + /// + /// Name of a complex type. + public ParserGenerator AddParseMethod(string complexTypeName) + { + _parseMethods.Add(complexTypeName); + return this; + } + + /// + /// Generate code from the configuration and a XML schema. + /// + /// Generated source code. + public string Generate() + { + var code = new CodeBuilder(new StringBuilder(), _typeMap); + code.AddLine("#nullable enable"); + code.EndLine(); + foreach (var usingNs in _usings) + code.AddLine($"using {usingNs};"); + + code.EndLine(); + code.AddLine($"namespace {_targetNamespace};"); + code.EndLine(); + code.AddLine($"internal partial class {_readerName}"); + code.OpenBrace(); + + if (_parseMethods.Count > 0) + GenerateParseMethod(code, _parseMethods[0]); + + foreach (var parseMethod in _parseMethods[1..]) + { + code.EndLine(); + GenerateParseMethod(code, parseMethod); + } + + code.CloseBrace(); + return code.ToString(); + } + + private void GenerateParseMethod(CodeBuilder code, string complexTypeName) + { + if (!_schema.TryGetComplexType(complexTypeName, out var complexType)) + throw new InvalidOperationException($"Complex type '{complexTypeName}' not found."); + + complexType.Generate(code, _namespaceField); + } +} diff --git a/ClosedXML.IO.CodeGen/Program.cs b/ClosedXML.IO.CodeGen/Program.cs new file mode 100644 index 000000000..1a9373a4e --- /dev/null +++ b/ClosedXML.IO.CodeGen/Program.cs @@ -0,0 +1,189 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using ClosedXML.IO.CodeGen.Model; +using ClosedXML.IO.CodeGen.XsdParser; + +namespace ClosedXML.IO.CodeGen; + +public class Program +{ + public static void Main(string[] args) + { + if (args.Length != 2) + { + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine($" {Process.GetCurrentProcess().ProcessName}.exe name-of-ooxml.xsd command"); + Console.Error.WriteLine(); + return; + } + + var schemaPath = args[0]; + using var fileStream = File.OpenRead(schemaPath); + using var reader = new XmlTreeReader(fileStream, new XsdEnumMapper(), false); + var parser = new XsdSchemaParser(); + + var schema = parser.ParseSchema(reader); + + Console.Out.WriteLine($"Schema {schemaPath} successfully parsed."); + + var command = args[1]; + switch (command) + { + case "copy": + var sb = new StringBuilder(); + var visitor = new XsdCopyVisitor(sb); + visitor.Visit(schema); + var outputFile = Path.ChangeExtension(schemaPath, "copy"); + File.WriteAllText(outputFile, sb.ToString()); + Console.WriteLine($"Wrote copy to {outputFile}"); + break; + + case "styles": + GenerateStylesReader(schema); + break; + + case "cache-records": + GenerateCacheRecords(schema); + break; + + default: + Console.WriteLine($"Unknown command '{command}'"); + break; + } + + Console.ReadKey(); + } + + private static void GenerateStylesReader(Schema schema) + { + var typeMap = new SchemeTypeMap() + .AddPrimitiveTypes() + .AddSimpleType(new SimpleTypeMapping + { + Name = "ST_NumFmtId", + CsTypeName = "uint", + RequiredTemplate = "_reader.GetUInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalUInt(\"{0}\")" + }) + .AddSimpleType(new SimpleTypeMapping + { + Name = "ST_FontId", + CsTypeName = "uint", + RequiredTemplate = "_reader.GetUInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalUInt(\"{0}\")" + }) + .AddSimpleType(new SimpleTypeMapping + { + Name = "ST_FillId", + CsTypeName = "uint", + RequiredTemplate = "_reader.GetUInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalUInt(\"{0}\")" + }) + .AddSimpleType(new SimpleTypeMapping + { + Name = "ST_BorderId", + CsTypeName = "uint", + RequiredTemplate = "_reader.GetUInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalUInt(\"{0}\")" + }) + .AddSimpleType(new SimpleTypeMapping + { + Name = "ST_CellStyleXfId", + CsTypeName = "uint", + RequiredTemplate = "_reader.GetUInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalUInt(\"{0}\")" + }) + .AddSimpleType(new SimpleTypeMapping + { + Name = "ST_TextRotation", + CsTypeName = "uint", + RequiredTemplate = "_reader.GetUInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalUInt(\"{0}\")" + }) + .AddSimpleType(new SimpleTypeMapping + { + Name = "ST_DxfId", + CsTypeName = "uint", + RequiredTemplate = "_reader.GetUInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalUInt(\"{0}\")" + }) + .AddSimpleTypeEnum("ST_PatternType", "XLFillPatternValues") + .AddSimpleTypeEnum("ST_GradientType", "XLGradientType", "linear", "XLGradientType.Linear") + .AddSimpleTypeEnum("ST_BorderStyle", "XLBorderStyleValues", "none", "XLBorderStyleValues.None") + .AddSimpleTypeEnum("ST_HorizontalAlignment", "XLAlignmentHorizontalValues") + .AddSimpleTypeEnum("ST_VerticalAlignment", "XLAlignmentVerticalValues", "bottom", "XLAlignmentVerticalValues.Bottom") + .AddSimpleTypeEnum("ST_TableStyleType", "XLTableStyleType") + .AddComplexTypeMapping("CT_Color", "XLColor") + //.AddComplexTypeMapping("CT_GradientStop", "(double Value, XLColor Color)") + //.AddComplexTypeMapping("CT_BorderPr", "XLBorderProperty") + ; + + var stylesReaderGenerator = new ParserGenerator(schema, typeMap, "StylesReader", "_ns") + .AddUsing("System.Collections.Generic") + .AddUsing("ClosedXML.IO") + .AddUsing("ClosedXML.Excel.Formatting") + .AddParseMethod("CT_Stylesheet") + .AddParseMethod("CT_NumFmts") + .AddParseMethod("CT_NumFmt") + .AddParseMethod("CT_Fonts") + // AddParseMethod("CT_Font") + .AddParseMethod("CT_Fills") + .AddParseMethod("CT_Fill") + .AddParseMethod("CT_PatternFill") + .AddParseMethod("CT_GradientFill") + .AddParseMethod("CT_GradientStop") + .AddParseMethod("CT_Borders") + .AddParseMethod("CT_Border") + .AddParseMethod("CT_BorderPr") + .AddParseMethod("CT_CellStyleXfs") + .AddParseMethod("CT_Xf") + .AddParseMethod("CT_CellAlignment") + .AddParseMethod("CT_CellProtection") + //.AddParseMethod("CT_CellXfs") + .AddParseMethod("CT_CellStyles") + .AddParseMethod("CT_CellStyle") + .AddParseMethod("CT_Dxfs") + .AddParseMethod("CT_Dxf") + .AddParseMethod("CT_TableStyles") + .AddParseMethod("CT_TableStyle") + .AddParseMethod("CT_TableStyleElement") + .AddParseMethod("CT_Colors") + .AddParseMethod("CT_IndexedColors") + .AddParseMethod("CT_MRUColors") + .AddParseMethod("CT_RgbColor") + ; + + var stylesReaderSource = stylesReaderGenerator.Generate(); + Console.WriteLine(stylesReaderSource); + } + + private static void GenerateCacheRecords(Schema schema) + { + var typeMap = new SchemeTypeMap() + .AddPrimitiveTypes(); + + var cacheRecordsGenerator = new ParserGenerator(schema, typeMap, "PivotCacheRecordsReader", "_ns") + .WithNamespace("ClosedXML.Excel.IO") + .AddUsing("System.Collections.Generic") + .AddUsing("ClosedXML.IO") + + // CT_PivotCacheRecords - hand-coded + .AddParseMethod("CT_Record") + .AddParseMethod("CT_Missing") + .AddParseMethod("CT_Number") + .AddParseMethod("CT_Boolean") + .AddParseMethod("CT_Error") + .AddParseMethod("CT_String") + .AddParseMethod("CT_DateTime") + .AddParseMethod("CT_Index") + .AddParseMethod("CT_X") + .AddParseMethod("CT_Tuples") + .AddParseMethod("CT_Tuple") + ; + + var cacheRecordsSource = cacheRecordsGenerator.Generate(); + Console.WriteLine(cacheRecordsSource); + } +} diff --git a/ClosedXML.IO.CodeGen/README.md b/ClosedXML.IO.CodeGen/README.md new file mode 100644 index 000000000..692f30dea --- /dev/null +++ b/ClosedXML.IO.CodeGen/README.md @@ -0,0 +1,151 @@ +# Overview + +The goal is to create a generator that will use XSD of OOXML and it will generate parsing logic that includes data extraction and to load extracted data into ClosedXML internal structures. + +The data loading part might need to do custom logic that has to be incorporated into the generated parser. There might also be some validation, not just data combination logic. + +## Requirements + +Generator must +* Be able to generate parsing logic for XSD that extracts data +* Must be able to combine extracted data from generated parser and custom logic/validation +* Must be able to be regenerate parsing code without loss of hand-coded validation/translation logic +* Must be configurable, some parts might be completely generated, some might use hand-coded parser +* Use forward only XML parser `XmlTreeParser` +* Avoid a separate intermediate structure creation +* Must support only XSD features found in OOXML schema, nothing extra needed + +## Rationale + +Current OpenXML SDK is an intermediate representation that loads each part into memory. That has several problems, the major one is performance, both cpu and memory consumption. OpenXML SDK loads whole part into memory and ClosedXML then reads it and sets internal structures and then the whole parsed XML tree is disposed of. That is slow and memory intensive. + +To solve it, we will use our custom parser that is +* forward only +* will handle ISO-29500-3 (Markup Compatibility and Extensibility) +* is designed to be hand-coded + +We want to avoid intermediate representation, because that is what we already have. I could try to make one that is more optimal, but I don't see benefit. It would just be extra layer and extra work. + +It's inevitable that there will be bugs in the generated code. Bugs must be fixed and fixed everywhere. Therefore regeneration of code without affecting the hand logic is crucial. + +# Validation during parsing + +The generated code parses the expected schema and validates that the XML conforms to the schema. If the XML doesn't match the schema, the generated parser will throw an exception. + +This property ensures that when a hook is called, we can be certain that the XML processed up to that point was valid. This is the key difference between a CodeGen parser and a classic hand-coded `XmlReader` parser, as shown in this example: + +```csharp +// Classic XmlReader hand-coded parser. No explicit validation, requires to +// supply schema to XmlReader and set XmlReaderSettings.ValidationType to +// ValidationType.Schema. +while (reader.Read()) { + if (reader.IsStartElement()) { + if (reader.Name == "numFmts") + // Do something + } else if (reader.Name == "fonts") + // Do something else + } else if (reader.Name == "fills") { + // ... + } + } +} +```` + +Of course CodeGen parser is limited in other ways, but for purposes of OOXML it is a better choice. + +# Usage + +In order to generate a parser, it is necessary to + +* register simple types to the `SchemeTypeMap` +* define type mapping for complex types to C# types (optional) +* define which complex types to generate generated parser + +## Simple Type Mapping + +Simple type is an XML value type used in the attributes. It defines mapping between XML type and C# type. In most cases, we use primitie types in C#, but any type can be used. Use `AddSimpleType` to register a mapping. + +The mapping must contain at least one code fragment template that will map the attribute value to the C# value. There are two possible template: +* RequiredTemplate - a code fragment that maps XML attribute value to C# value (attribute name is supplied thorugh `{0}`). It should should throw an exception when attribute can't be mapped to the value or is missing. +* OptionalTemplate - a code fragment that maps XML attribute value to C# value (attribute name is supplied thorugh `{0}`). The fragment is used when attribute is optional and should return `null` when the attribute is missing. + +```csharp +var typeMap = new SchemeTypeMap() + // Adds some very common simple type mapping used in basically every reader + .AddPrimitiveTypes() + .AddSimpleType(new SimpleTypeMapping + { + Name = "ST_NumFmtId", + CsTypeName = "uint", + RequiredTemplate = "_reader.GetUInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalUInt(\"{0}\")" + }) +``` + +There are few other methods for mapping enum, all start with `AddSimpleType*`. + +If CodeGen can't find mapping for a type, it will throw an exception during generation. + +## Complex Type Mapping + +CodeGen expects that there is a parsing method for each type (e.g. `ParseFont` for type `CT_Font`). By default, the generator expects that each parsing method returns `void`. It can be useful instead to return a value and use the returned value in parent `Parse*` method. + +In order to do that, use `AddComplexTypeMapping` in `SchemeTypeMap` to define this mapping. + +The mapping doesn't mean that the method will be generated, but that other `Parse*` method will expect that `Parse*` method for the type returns a value. + +```csharp +typeMap + // Code generator will expect that method ParseColor will return XLColor. + // The actual ParseColor method is not generated, it is hand-coded, but + // the generated ParseGradientFill will expect that called ParseColor returns + // XLColor value. + .AddComplexTypeMapping("CT_Color", "XLColor") + // Code generator will expect that method ParseGradientStop will return + // a named tuple. In this case, the method ParseGradientStop is generated by + // CodeGen and developer thus has to hand-code hook with following signature + // private (double Value, XLColor Color) OnGradientStopParsed(XLColor color, double position) + // that will perform construct the return value. + .AddComplexTypeMapping("CT_GradientStop", "(double Value, XLColor Color)") + +new ParserGenerator(schema, typeMap, "Demo", "_ns") + .AddParseMethod("CT_GradientFill") + .AddParseMethod("CT_GradientStop"); +``` + +This is an example of generated methods in current incarantion: +```csharp +private void ParseGradientFill(string elementName) +{ + // Other attributes omitted for brevity + var type = _reader.GetOptionalEnum("type") ?? XLGradientType.Linear; + + // Because generator knows that ParseGradientStop should return value and that it can contain a sequence here, it stores values in a list + var stop = new List<(double Value, XLColor Color)>(); + while (_reader.TryOpen("stop", _ns)) + { + stop.Add(ParseGradientStop("stop")); + } + _reader.Close(elementName, _ns); + + // Extracted valkus are supplied to the partial hook. The ParseGradientFill doesn't have a hook and thus doesn't return a value. + OnGradientFillParsed(stop, type, degree, left, right, top, bottom); +} + +private (double Value, XLColor Color) ParseGradientStop(string elementName) +{ + var position = _reader.GetDouble("position"); + _reader.Open("color", _ns); + var color = ParseColor("color"); + _reader.Close(elementName, _ns); + + // Generated code calls hand-coded hook in a separate partial class + return OnGradientStopParsed(color, position); +} +``` + +The mapping allows to use a composition for some elements. Without this feature, it would be necessary to store `GradientStop` values in some private property and use them later in the `OnGradientFillParsed`. That pattern is feasible, but hard to read. + +## Hooks + +Each generated `Parse*` method calls a hook method once it is completely parsed and all values of the element (attributes and mapped complex types) are passed to the hook. The hook method is generally a partial method and thus doesn't have to be implemented (unless it is a hook for mapped complex type). diff --git a/ClosedXML.IO.CodeGen/SchemeTypeMap.cs b/ClosedXML.IO.CodeGen/SchemeTypeMap.cs new file mode 100644 index 000000000..72ce29d76 --- /dev/null +++ b/ClosedXML.IO.CodeGen/SchemeTypeMap.cs @@ -0,0 +1,127 @@ +using ClosedXML.IO.CodeGen.Model; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace ClosedXML.IO.CodeGen; + +internal class SchemeTypeMap +{ + /// + /// Simple type map. The key is an XML simple name, the value is info about how to work with it in the C# code. + /// + private readonly Dictionary _simpleTypeMap = new(); + + /// + /// Map of XML complex type name to C# type (as a text). If there isn't a record in the map, + /// complex type isn't mapped to C# type and returns void. + /// + private readonly Dictionary _complexTypeMap = new(); + + internal SchemeTypeMap AddComplexTypeMapping(string typeName, string cSharpType) + { + _complexTypeMap.Add(typeName, cSharpType); + return this; + } + + internal SchemeTypeMap AddSimpleType(SimpleTypeMapping simpleType) + { + _simpleTypeMap.Add(simpleType.Name, simpleType); + return this; + } + + internal SchemeTypeMap AddSimpleTypeEnum(string simpleType, string csTypeName, string xmlValue, string csValue) + { + return AddSimpleTypeEnum(simpleType, csTypeName, new() { { xmlValue, csValue } }); + } + + internal SchemeTypeMap AddSimpleTypeEnum(string simpleType, string csTypeName, Dictionary? valuesMap = null) + { + return AddSimpleType(new SimpleTypeMapping + { + Name = simpleType, + CsTypeName = csTypeName, + RequiredTemplate = $"_reader.GetEnum<{csTypeName}>(\"{{0}}\")", + OptionalTemplate = $"_reader.GetOptionalEnum<{csTypeName}>(\"{{0}}\")", + MapValue = xmlName => valuesMap?[xmlName] ?? throw new InvalidOperationException($"The XML value {xmlName} is not mapped to {csTypeName}.") + }); + } + + internal SimpleTypeMapping GetSimpleType(string typeName) + { + return _simpleTypeMap[typeName]; + } + + internal string GetSimpleTypeMethod(AttributeElement attribute) + { + var simpleTypeName = attribute.Type ?? throw new InvalidOperationException(); + var simpleType = _simpleTypeMap[simpleTypeName]; + var expressionTemplate = attribute.IsOptional ? simpleType.OptionalTemplate : simpleType.RequiredTemplate; + return string.Format(expressionTemplate, attribute.Name); + } + + internal bool TryGetComplexTypeCsType(string complexType, [NotNullWhen(true)] out string? csType) + { + return _complexTypeMap.TryGetValue(complexType, out csType); + } + + public SchemeTypeMap AddPrimitiveTypes() + { + AddSimpleType(new SimpleTypeMapping + { + Name = "xsd:boolean", + CsTypeName = "bool", + RequiredTemplate = "_reader.GetBool(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalBool(\"{0}\")" + }); + AddSimpleType(new SimpleTypeMapping + { + Name = "xsd:int", + CsTypeName = "int", + RequiredTemplate = "_reader.GetInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalInt(\"{0}\")" + }); + AddSimpleType(new SimpleTypeMapping + { + Name = "xsd:unsignedInt", + CsTypeName = "uint", + RequiredTemplate = "_reader.GetUInt(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalUInt(\"{0}\")" + }); + AddSimpleType(new SimpleTypeMapping + { + Name = "xsd:double", + CsTypeName = "double", + RequiredTemplate = "_reader.GetDouble(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalDouble(\"{0}\")" + }); + AddSimpleType(new SimpleTypeMapping + { + Name = "s:ST_Xstring", + CsTypeName = "string", + RequiredTemplate = "_reader.GetXString(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalXString(\"{0}\")" + }); + AddSimpleType(new SimpleTypeMapping + { + Name = "xsd:string", + CsTypeName = "string", + RequiredTemplate = "_reader.GetString(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalString(\"{0}\")" + }); + AddSimpleType(new SimpleTypeMapping + { + Name = "xsd:dateTime", + CsTypeName = "System.DateTime", + RequiredTemplate = "_reader.GetDateTime(\"{0}\")", + OptionalTemplate = "_reader.GetOptionalDateTime(\"{0}\")" + }); + AddSimpleType(new SimpleTypeMapping + { + Name = "ST_UnsignedIntHex", + CsTypeName = "uint", + OptionalTemplate = "_reader.GetOptionalUIntHex(\"{0}\")" + }); + return this; + } +} diff --git a/ClosedXML.IO.CodeGen/SimpleTypeMapping.cs b/ClosedXML.IO.CodeGen/SimpleTypeMapping.cs new file mode 100644 index 000000000..25831b035 --- /dev/null +++ b/ClosedXML.IO.CodeGen/SimpleTypeMapping.cs @@ -0,0 +1,42 @@ +using System; + +namespace ClosedXML.IO.CodeGen; + +internal record SimpleTypeMapping +{ + private readonly string? _requiredTemplate; + private readonly string? _optionalTemplate; + + /// + /// Name of the simple type in the XML. + /// + public required string Name { get; init; } + + /// + /// Name of the mapped C# type. + /// + public required string CsTypeName { get; init; } + + /// + /// C# code template for getting a value from a required attribute. The name of attribute is in the string as {0}. + /// + public string RequiredTemplate + { + get => _requiredTemplate ?? throw new InvalidOperationException($"Required template not defined for {Name}."); + init => _requiredTemplate = value; + } + + /// + /// C# code template for getting a value from an optional attribute. The name of attribute is in the string as {0}. + /// + public string OptionalTemplate + { + get => _optionalTemplate ?? throw new InvalidOperationException($"Optional template not defined for {Name}."); + init => _optionalTemplate = value; + } + + /// + /// Map values from XML default value to C# value. + /// + public Func MapValue { get; init; } = x => x; +} diff --git a/ClosedXML.IO.CodeGen/Unit.cs b/ClosedXML.IO.CodeGen/Unit.cs new file mode 100644 index 000000000..3c756af8a --- /dev/null +++ b/ClosedXML.IO.CodeGen/Unit.cs @@ -0,0 +1,9 @@ +namespace ClosedXML.IO.CodeGen; + +/// +/// A type to use in generics instead of void. +/// +internal struct Unit +{ + public static readonly Unit Value = new(); +}; diff --git a/ClosedXML.IO.CodeGen/XsdCopyVisitor.cs b/ClosedXML.IO.CodeGen/XsdCopyVisitor.cs new file mode 100644 index 000000000..6c90ca55f --- /dev/null +++ b/ClosedXML.IO.CodeGen/XsdCopyVisitor.cs @@ -0,0 +1,327 @@ +using System; +using System.Diagnostics; +using System.Text; +using ClosedXML.IO.CodeGen.Model; +using ClosedXML.IO.CodeGen.Model.Elements; +using ClosedXML.IO.CodeGen.Model.SimpleTypes; +using ClosedXML.IO.CodeGen.Model.TopLevel; + +namespace ClosedXML.IO.CodeGen; + +internal class XsdCopyVisitor : IXsdVisitor +{ + private readonly StringBuilder _sb; + private int _indent = 0; + + public XsdCopyVisitor(StringBuilder sb) + { + _sb = sb; + } + + public Unit Visit(Schema schema) + { + _sb.AppendLine(""); + AppendElement(""" + + """); + foreach (var import in schema.Imports) + { + AppendElement($""); + } + + foreach (var entry in schema.Entries) + { + switch (entry) + { + case ComplexTypeSequence ct: + ct.Accept(this); + break; + case ComplexTypeChoice ct: + ct.Accept(this); + break; + case ComplexTypeSimpleContent ct: + ct.Accept(this); + break; + case ComplexTypeElement ct: + ct.Accept(this); + break; + case SimpleType simpleType: + simpleType.Accept(this); + break; + case SimpleTypeList simpleType: + simpleType.Accept(this); + break; + case SimpleTypeUnion simpleType: + simpleType.Accept(this); + break; + case ElementDefinition elementDefinition: + elementDefinition.Accept(this); + break; + case GroupDefinition groupDefinition: + groupDefinition.Accept(this); + break; + case AttributeGroupDefinition attributeGroupDefinition: + attributeGroupDefinition.Accept(this); + break; + default: + throw new UnreachableException(); + } + } + + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(ComplexTypeSequence complexType) + { + var element = $""); + return Unit.Value; + } + + public Unit Visit(ComplexTypeChoice complexType) + { + AppendElement($""); + complexType.Choice.Accept(this); + WriteAttributes(complexType); + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(ComplexTypeSimpleContent complexType) + { + AppendElement($""); + AppendElement(""); + AppendElement($""); + WriteAttributes(complexType); + AppendElement(""); + AppendElement(""); + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(ComplexTypeElement complexType) + { + AppendElement($""); + WriteAttributes(complexType); + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(AttributeGroupReference attributeGroupReference) + { + AppendElement($""); + return Unit.Value; + } + + public Unit Visit(SimpleType simpleType) + { + AppendElement($""); + AppendElement($""); + + foreach (var restriction in simpleType.Restrictions) + AppendElement(GetValueRestrictionElement(restriction)); + + AppendElement(""); + AppendElement(""); + return Unit.Value; + } + + private static string GetValueRestrictionElement(IValueRestriction restriction) + { + var restrictionElement = restriction switch + { + RestrictEnumeration enumeration => $"", + RestrictLength length => $"", + RestrictMinInclusive minInclusive => $"", + RestrictMaxInclusive maxInclusive => $"", + _ => throw new UnreachableException() + }; + return restrictionElement; + } + + public Unit Visit(SimpleTypeList simpleType) + { + AppendElement($""); + AppendElement($""); + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(SimpleTypeUnion simpleType) + { + AppendElement($""); + AppendElement(""); + foreach (var restrictionUnion in simpleType.RestrictionsUnion) + { + AppendElement(""); + AppendElement($""); + foreach (var valueRestriction in restrictionUnion.ValueRestrictions) + { + AppendElement(GetValueRestrictionElement(valueRestriction)); + } + + AppendElement(""); + AppendElement(""); + } + + AppendElement(""); + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(GroupReference attributeGroupReference) + { + AppendElement($""); + return Unit.Value; + } + + public Unit Visit(Any any) + { + var element = " string.Empty, + ProcessContents.Strict => " processContents=\"strict\"", + ProcessContents.Lax => " processContents=\"lax\"", + _ => throw new UnreachableException(), + }; + element += "/>"; + AppendElement(element); + return Unit.Value; + } + + public Unit Visit(Choice choice) + { + AppendElement($""); + foreach (var element in choice.Children) + { + element.Accept(this); + } + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(ElementType elementType) + { + var element = $""); + foreach (var element in sequence.Children) + { + element.Accept(this); + } + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(ElementReference elementReference) + { + AppendElement($""); + return Unit.Value; + } + + public Unit Visit(ElementDefinition elementDefinition) + { + AppendElement($""); + return Unit.Value; + } + + public Unit Visit(GroupDefinition groupDefinition) + { + AppendElement($""); + groupDefinition.Content.Accept(this); + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(AttributeGroupDefinition attributeGroupDefinition) + { + AppendElement($""); + foreach (var attribute in attributeGroupDefinition.Attributes) + attribute.Accept(this); + + AppendElement(""); + return Unit.Value; + } + + public Unit Visit(AttributeElement attributeElement) + { + var element = "", comparison); + if (isClose) + _indent -= 2; + } +} diff --git a/ClosedXML.IO.CodeGen/XsdParser/XsdEnumMapper.cs b/ClosedXML.IO.CodeGen/XsdParser/XsdEnumMapper.cs new file mode 100644 index 000000000..bd5fec2f6 --- /dev/null +++ b/ClosedXML.IO.CodeGen/XsdParser/XsdEnumMapper.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using ClosedXML.IO.CodeGen.Model; + +namespace ClosedXML.IO.CodeGen.XsdParser; + +/// +/// Mapper for enums found in XSD of OOXML. +/// +public class XsdEnumMapper : IEnumMapper +{ + private readonly Dictionary _textToEnumMaps = new(); + + public XsdEnumMapper() + { + AddMaps(); + } + + public bool TryGetEnum(string text, out TEnum enumValue) + where TEnum : struct, Enum + { + var enumMap = (Dictionary)_textToEnumMaps[typeof(TEnum)]; + return enumMap.TryGetValue(text, out enumValue); + } + + private void AddMaps() + { + AddMap(new Dictionary + { + { "optional", AttributeUseType.Optional }, + { "required", AttributeUseType.Required } + }); + AddMap(new Dictionary + { + { "strict", ProcessContents.Strict }, + { "lax", ProcessContents.Lax } + }); + } + + private void AddMap(Dictionary enumMap) + where TEnum : struct, Enum + { + _textToEnumMaps.Add(typeof(TEnum), enumMap); + } +} diff --git a/ClosedXML.IO.CodeGen/XsdSchemaParser.cs b/ClosedXML.IO.CodeGen/XsdSchemaParser.cs new file mode 100644 index 000000000..c1e7ae08a --- /dev/null +++ b/ClosedXML.IO.CodeGen/XsdSchemaParser.cs @@ -0,0 +1,468 @@ +using System.Collections.Generic; +using ClosedXML.IO.CodeGen.Model; +using ClosedXML.IO.CodeGen.Model.Elements; +using ClosedXML.IO.CodeGen.Model.SimpleTypes; +using ClosedXML.IO.CodeGen.Model.TopLevel; + +namespace ClosedXML.IO.CodeGen; + +/// +/// Parser to parse XSD of OOXML. It doesn't have to support anythings not found in the official XSD. +/// +public class XsdSchemaParser +{ + /// + /// XSD namespace. + /// + private const string XsdNs = "http://www.w3.org/2001/XMLSchema"; + + public Schema ParseSchema(XmlTreeReader reader) + { + var file = new Schema(); + + reader.Open("schema", XsdNs); + + // Read imports + while (reader.TryOpen("import", XsdNs)) + { + var ns = reader.GetString("namespace"); + var schemaLocation = reader.GetString("schemaLocation"); + reader.Close("import", XsdNs); + + file.Imports.Add(new ImportElement + { + Namespace = ns, + SchemaLocation = schemaLocation + }); + } + + while (!reader.TryClose("schema", XsdNs)) + { + if (reader.TryOpen("complexType", XsdNs)) + { + var complexType = ParseComplexType(reader); + file.Entries.Add(complexType); + } + else if (reader.TryOpen("simpleType", XsdNs)) + { + var simpleType = ParseSimpleType(reader); + file.Entries.Add(simpleType); + } + else if (reader.TryOpen("element", XsdNs)) + { + var name = reader.GetString("name"); + var typeName = reader.GetString("type"); + reader.Close("element", XsdNs); + + file.Entries.Add(new ElementDefinition + { + Name = name, + TypeName = typeName + }); + } + else if (reader.TryOpen("group", XsdNs)) + { + var name = reader.GetString("name"); + var elementGroup = ParseElementsGroup(reader); + reader.Close("group", XsdNs); + + file.Entries.Add(new GroupDefinition + { + Name = name, + Content = elementGroup + }); + } + else if (reader.TryOpen("attributeGroup", XsdNs)) + { + var attributeGroup = ParseAttributeGroupDefinition(reader); + file.Entries.Add(attributeGroup); + } + else + { + throw PartStructureException.ExpectedChoiceElementNotFound(reader); + } + } + + return file; + } + + private static ComplexType ParseComplexType(XmlTreeReader reader) + { + var name = reader.GetString("name"); + var mixed = reader.GetOptionalBool("mixed"); + if (reader.TryOpen("sequence", XsdNs)) + { + var occurrences = GetOccursAttributes(reader); + var elements = new List(); + do + { + var element = ParseElementsGroup(reader); + elements.Add(element); + } while (!reader.TryClose("sequence", XsdNs)); + + var attributes = ParseComplexTypeAttributes(reader); + + return new ComplexTypeSequence + { + Name = name, + Attributes = attributes, + Mixed = mixed, + Sequence = new Sequence + { + Children = elements, + Occurrences = occurrences + } + }; + } + + if (reader.TryOpen("choice", XsdNs)) + { + var occurrences = GetOccursAttributes(reader); + var choices = new List(); + do + { + var elementGroup = ParseElementsGroup(reader); + choices.Add(elementGroup); + } while (!reader.TryClose("choice", XsdNs)); + + var attributes = ParseComplexTypeAttributes(reader); + + return new ComplexTypeChoice + { + Name = name, + Attributes = attributes, + Mixed = mixed, + Choice = new Choice + { + Children = choices, + Occurrences = occurrences + } + }; + } + + if (reader.TryOpen("simpleContent", XsdNs)) + { + // simpleContent can't have attributes like complexType. It has them only in tag. + var (baseTypeName, extensionAttributes) = ParseSimpleContent(reader); + reader.Close("complexType", XsdNs); + + return new ComplexTypeSimpleContent + { + Name = name, + Attributes = extensionAttributes, + Mixed = mixed, + BaseTypeName = baseTypeName + }; + } + + // Complex type that consists only from attributes + var attr = ParseComplexTypeAttributes(reader); + return new ComplexTypeElement + { + Name = name, + Attributes = attr, + Mixed = mixed, + }; + } + + private static ISimpleType ParseSimpleType(XmlTreeReader reader) + { + var simpleTypeName = reader.GetString("name"); + if (reader.TryOpen("restriction", XsdNs)) + { + var restriction = ParseRestriction(reader); + reader.Close("simpleType", XsdNs); + + return new SimpleType + { + Name = simpleTypeName, + BaseTypeName = restriction.BaseTypeName, + Restrictions = restriction.ValueRestrictions, + }; + } + + if (reader.TryOpen("list", XsdNs)) + { + var itemType = reader.GetString("itemType"); + reader.Close("list", XsdNs); + reader.Close("simpleType", XsdNs); + + return new SimpleTypeList + { + Name = simpleTypeName, + ItemType = itemType + }; + } + + if (reader.TryOpen("union", XsdNs)) + { + var unionRestrictions = new List(); + while (reader.TryOpen("simpleType", XsdNs)) + { + reader.Open("restriction", XsdNs); + var restriction = ParseRestriction(reader); + reader.Close("simpleType", XsdNs); + + unionRestrictions.Add(restriction); + } + + reader.Close("union", XsdNs); + reader.Close("simpleType", XsdNs); + + return new SimpleTypeUnion + { + Name = simpleTypeName, + RestrictionsUnion = unionRestrictions + }; + } + + throw PartStructureException.ExpectedChoiceElementNotFound(reader); + } + + private static Restriction ParseRestriction(XmlTreeReader reader) + { + var baseType = reader.GetString("base"); + var valueRestrictions = new List(); + + while (!reader.TryClose("restriction", XsdNs)) + { + if (reader.TryOpen("enumeration", XsdNs)) + { + var value = reader.GetString("value"); + valueRestrictions.Add(new RestrictEnumeration(value)); + reader.Close("enumeration", XsdNs); + } + else if (reader.TryOpen("length", XsdNs)) + { + var length = reader.GetInt("value"); + valueRestrictions.Add(new RestrictLength(length)); + reader.Close("length", XsdNs); + } + else if (reader.TryOpen("minInclusive", XsdNs)) + { + var minInclusive = reader.GetInt("value"); + valueRestrictions.Add(new RestrictMinInclusive(minInclusive)); + reader.Close("minInclusive", XsdNs); + } + else if (reader.TryOpen("maxInclusive", XsdNs)) + { + var maxInclusive = reader.GetInt("value"); + valueRestrictions.Add(new RestrictMaxInclusive(maxInclusive)); + reader.Close("maxInclusive", XsdNs); + } + else + { + throw PartStructureException.ExpectedChoiceElementNotFound(reader); + } + } + + return new Restriction + { + BaseTypeName = baseType, + ValueRestrictions = valueRestrictions + }; + } + + private static AttributeGroupDefinition ParseAttributeGroupDefinition(XmlTreeReader reader) + { + var name = reader.GetString("name"); + var attributes = new List(); + + while (reader.TryOpen("attribute", XsdNs)) + { + var attribute = ParseAttribute(reader); + attributes.Add(attribute); + } + + reader.Close("attributeGroup", XsdNs); + + return new AttributeGroupDefinition + { + Name = name, + Attributes = attributes + }; + } + + private static (string Base, List> Attributes) ParseSimpleContent(XmlTreeReader reader) + { + reader.Open("extension", XsdNs); + var baseTypeName = reader.GetString("base"); + var extensionAttributes = new List>(); + + while (!reader.TryClose("extension", XsdNs)) + { + reader.Open("attribute", XsdNs); + var name = reader.GetString("name"); + var type = reader.GetString("type"); + var use = reader.GetOptionalEnum("use") ?? AttributeUseType.Default; + var defaultValue = reader.GetOptionalString("default"); + reader.Close("attribute", XsdNs); + var attribute = new AttributeElement + { + Name = name, + Type = type, + Use = use, + DefaultValue = defaultValue, + RefName = null + }; + extensionAttributes.Add(attribute); + } + + reader.Close("simpleContent", XsdNs); + + return (baseTypeName, extensionAttributes); + } + + private static List> ParseComplexTypeAttributes(XmlTreeReader reader) + { + var attributes = new List>(); + + while (!reader.TryClose("complexType", XsdNs)) + { + if (reader.TryOpen("attribute", XsdNs)) + { + var attribute = ParseAttribute(reader); + attributes.Add(attribute); + } + else if (reader.TryOpen("attributeGroup", XsdNs)) + { + var refName = reader.GetString("ref"); + reader.Close("attributeGroup", XsdNs); + attributes.Add(new AttributeGroupReference + { + RefName = refName + }); + } + else + { + throw PartStructureException.ExpectedChoiceElementNotFound(reader); + } + } + + return attributes; + } + + private static AttributeElement ParseAttribute(XmlTreeReader reader) + { + var name = reader.GetOptionalString("name"); + var type = reader.GetOptionalString("type"); + var refName = reader.GetOptionalString("ref"); + var defaultValue = reader.GetOptionalString("default"); + var use = reader.GetOptionalEnum("use") ?? AttributeUseType.Default; + reader.Close("attribute", XsdNs); + + return new AttributeElement + { + Name = name, + RefName = refName, + Type = type, + Use = use, + DefaultValue = defaultValue + }; + } + + private static IElementGroup ParseElementsGroup(XmlTreeReader reader) + { + if (reader.TryOpen("sequence", XsdNs)) + { + var occurs = GetOccursAttributes(reader); + var elements = new List(); + do + { + var element = ParseElementsGroup(reader); + elements.Add(element); + } while (!reader.TryClose("sequence", XsdNs)); + + return new Sequence + { + Children = elements, + Occurrences = occurs + }; + } + + if (reader.TryOpen("choice", XsdNs)) + { + var occurs = GetOccursAttributes(reader); + var choices = new List(); + do + { + var choice = ParseElementsGroup(reader); + choices.Add(choice); + } while (!reader.TryClose("choice", XsdNs)); + + return new Choice + { + Children = choices, + Occurrences = occurs + }; + } + + if (reader.TryOpen("element", XsdNs)) + { + var occurrences = GetOccursAttributes(reader); + + var refName = reader.GetOptionalString("ref"); + if (refName is not null) + { + reader.Close("element", XsdNs); + + return new ElementReference + { + RefName = refName, + Occurrences = occurrences + }; + } + + // name, type, min/maxOccurs + var name = reader.GetString("name"); + var type = reader.GetString("type"); + reader.Close("element", XsdNs); + + return new ElementType + { + Name = name, + TypeName = type, + Occurrences = occurrences + }; + } + + if (reader.TryOpen("group", XsdNs)) + { + var refName = reader.GetOptionalString("ref"); + var occurrences = GetOccursAttributes(reader); + + // Element group reference + if (refName is not null) + { + reader.Close("group", XsdNs); + return new GroupReference + { + RefName = refName, + Occurrences = occurrences + }; + } + + throw PartStructureException.InvalidAttributeValue(); + } + + if (reader.TryOpen("any", XsdNs)) + { + var processContents = reader.GetOptionalEnum("processContents") ?? ProcessContents.Default; + reader.Close("any", XsdNs); + + return new Any + { + ProcessContent = processContents + }; + } + + throw PartStructureException.ExpectedChoiceElementNotFound(reader); + } + + private static Occurrences GetOccursAttributes(XmlTreeReader reader) + { + var minOccurs = reader.GetOptionalInt("minOccurs") ?? null; + var maxOccurs = reader.GetOptionalString("maxOccurs") == "unbounded" ? int.MaxValue : reader.GetOptionalInt("maxOccurs") ?? null; + return new Occurrences(minOccurs, maxOccurs); + } +} diff --git a/ClosedXML.IO/ClosedXML.IO.csproj b/ClosedXML.IO/ClosedXML.IO.csproj new file mode 100644 index 000000000..3fd3eb71f --- /dev/null +++ b/ClosedXML.IO/ClosedXML.IO.csproj @@ -0,0 +1,23 @@ + + + + 11.0 + netstandard2.0;netstandard2.1 + + annotations + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/ClosedXML.IO/IEnumMapper.cs b/ClosedXML.IO/IEnumMapper.cs new file mode 100644 index 000000000..9f1b4bf22 --- /dev/null +++ b/ClosedXML.IO/IEnumMapper.cs @@ -0,0 +1,19 @@ +using System; + +namespace ClosedXML.IO; + +/// +/// A mapper from a text name in XML to actual enum value. Used by . +/// +public interface IEnumMapper +{ + /// + /// Try to get a concrete enum value for a passed text. + /// + /// Type of expected enum. + /// Text value of enum. Comparison is case-sensitive. + /// Output enum value. + /// True if enum was found for passed text value, false otherwise. + bool TryGetEnum(string text, out TEnum enumValue) + where TEnum : struct, Enum; +} diff --git a/ClosedXML.IO/PartStructureException.cs b/ClosedXML.IO/PartStructureException.cs new file mode 100644 index 000000000..aa363704e --- /dev/null +++ b/ClosedXML.IO/PartStructureException.cs @@ -0,0 +1,154 @@ +using System; + +namespace ClosedXML.IO; + +/// +/// An exception thrown from parser when there is a problem with data in XML. +/// The exception messages are rather generic and not very helpful, but they +/// aren't supposed to be. If this exception is thrown, there is either +/// a problem with producer of a workbook or ClosedXML. Both should do +/// investigation based on a the file causing an error. +/// +public class PartStructureException : Exception +{ + private PartStructureException(string message, XmlTreeReader? reader = null) + : base(BuildMessage(message, reader)) + { + } + + /// + /// Create a new exception with info that some element that should be present in a workbook + /// is missing. + /// + public static Exception ExpectedElementNotFound() + { + return new PartStructureException($"The structure of XML expected a certain kind of element, but it isn't there."); + } + + /// + /// Create a new exception with info that some element that should be present in a workbook + /// is missing. + /// + /// optional info about what element is missing. + /// XML reader at the position of the error. + public static Exception ExpectedElementNotFound(string missingElementDesc, XmlTreeReader? reader = null) + { + return new PartStructureException($"The structure of XML expected a certain kind of element, but it isn't there ({missingElementDesc}).", reader); + } + + /// + /// XML should be one of several elements, but none of them is found. Instead, there is an + /// unexpected element. + /// + public static Exception ExpectedChoiceElementNotFound(XmlTreeReader reader) + { + return new PartStructureException($"The structure of XML expected an element from choice of several, but found {reader.ElementName} instead.", reader); + } + + /// + /// XML should contain a certain number of elements to be valid, but expected number of + /// elements is different from expected one. + /// + public static Exception IncorrectElementsCount() + { + return new PartStructureException("There is a problem with element structure in XML, the number of elements found is not what was expected."); + } + + /// + /// XML element should contain some children or attributes and it doesn't. + /// + /// Name of the element. + public static Exception IncorrectElementFormat(string elementName) + { + return new PartStructureException($"The element '{elementName}' doesn't have or misses child elements/attributes that are required by constrains of the workbook."); + } + + /// + /// XML shouldn't contain an element at that point, but there is an element. This is more + /// generic version of . That one is where there are + /// some choices and one should be there, this is generic error that element was found where it + /// shouldn't be. + /// + /// Name of found element. + public static Exception UnexpectedElementFound(string elementName) + { + return new PartStructureException($"At this point, there shouldn't be element '{elementName}', but it is present."); + } + + /// + /// XML must contain a specific element, but doesn't. + /// + /// Name of element that should be there. + public static Exception RequiredElementIsMissing(string elementName) + { + return new PartStructureException($"The XML schema requires an element '{elementName}', but is is not present."); + } + + /// + public static Exception MissingAttribute() + { + return new PartStructureException("XML doesn't contain a required attribute."); + } + + /// + public static Exception MissingAttribute(string attributeName) + { + return new PartStructureException($"XML doesn't contain a required attribute '{attributeName}'."); + } + + /// + /// XML element must contain an attribute (generally because other element in XML), but that + /// attribute is not in the element. + /// + /// Name of attribute. + /// Reader to provide info about place where error happened. + public static Exception MissingAttribute(string attributeName, XmlTreeReader reader) + { + var message = $"XML doesn't contain a required attribute '{attributeName}'."; + return new PartStructureException(message, reader); + } + + /// + public static Exception InvalidAttributeFormat() + { + return new PartStructureException("The attribute has a value in an incorrect format."); + } + + /// + /// Attribute value should have some kind of format (e.g. number or an enum value) and it + /// doesn't. + /// + /// Value of the attribute. + public static Exception InvalidAttributeFormat(string attributeValue) + { + return new PartStructureException($"The attribute has a value ('{attributeValue}') in an incorrect format."); + } + + /// + public static Exception InvalidAttributeValue() + { + return new PartStructureException("The value of attribute doesn't make sense with the rest of data of a workbook (e.g. reference that doesn't exist)."); + } + + /// + /// The attribute value doesn't make sense when taken in context of whole XML document. That is + /// different from , format is a syntactic problem, this + /// is a semantic problem. + /// + /// The attribute value, not a name. + public static Exception InvalidAttributeValue(string attributeValue) + { + return new PartStructureException($"The value of attribute '{attributeValue}' is not valid value for the attribute."); + } + + + private static string BuildMessage(string message, XmlTreeReader? reader) + { + if (reader is not null && reader.TryGetLineInfo(out var lineInfo)) + { + message += $" Line:{lineInfo.LineNumber}, Position:{lineInfo.LinePosition}."; + } + + return message; + } +} diff --git a/ClosedXML.IO/XStringConvert.cs b/ClosedXML.IO/XStringConvert.cs new file mode 100644 index 000000000..37d486cf8 --- /dev/null +++ b/ClosedXML.IO/XStringConvert.cs @@ -0,0 +1,135 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Xml; + +namespace ClosedXML.IO; + +/// +/// Class to deal with encoding and decoding XString. XString decoding doesn't depend on the source +/// encoding. XString decoding decodes from unicode codepoints (regardless if UTF-8 or UTF-16) +/// and the decoder replaces XString patterns with decoded codepoints. +/// +public class XStringConvert +{ + /// + /// Decode an XString to normal string. + /// + /// + /// There is a similar method in dotnet . That one however + /// also accepts uppercase X (_X????_) which shouldn't be decoded. Hexadecimal digits are + /// case insensitive, the x marker is not (#1154). + /// Another issue is that accepts 8 hex digits (e.g., + /// _xD83DDE43_). That is also not valid for XString. + /// + /// Test that might contain XString encoded characters. + /// Decoded string. + [return: NotNullIfNotNull(nameof(text))] + public static string? Decode(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return text; + + // This method is on a hotpath and shouldn't allocate unless necessary. + // Do lazy initialization, there might not be any pattern at all. + StringBuilder? sb = null; + + // An index of next character after last encountered XString pattern. + // Initial value is 0 because text from that index on is copied. + var prevSliceNextCharIndex = 0; + var textSpan = text.AsSpan(); + for (var i = textSpan.IndexOf('_'); i >= 0 && i < text.Length - 6; i = text.IndexOf('_', i + 1)) + { + if (IsPattern(textSpan, i)) + { + sb ??= new StringBuilder(text.Length); + + // Append text from last XString splice + sb.Append(text, prevSliceNextCharIndex, i - prevSliceNextCharIndex); + + // Get hex digits from from _xABCD_ patterns. Polyfill doesn't have allocation-free + // API, so just decode the hex number. + var codepoint = GetHexValue(textSpan.Slice(i + 2, 4)); + + sb.Append((char)codepoint); + + // Move from opening '_' to closing '_' because we have effectively read all that + // The loop will add + 1 and moves to the next char of text. + i += 6; + prevSliceNextCharIndex = i + 1; + } + } + + // Not even one pattern was actually replaced -> return original text + if (sb is null) + return text; + + sb.Append(text, prevSliceNextCharIndex, text.Length - prevSliceNextCharIndex); + return sb.ToString(); + + // Does the XString pattern starts at index i? + static bool IsPattern(ReadOnlySpan input, int i) + { + // Reorder to ensure simplest tests are checked first + return i + 6 < input.Length && + input[i] == '_' && + input[i + 1] == 'x' && + input[i + 6] == '_' && + IsHex(input[i + 2]) && + IsHex(input[i + 3]) && + IsHex(input[i + 4]) && + IsHex(input[i + 5]); + } + + } + + internal static bool TryGetHexValue(ReadOnlySpan text, out uint value) + { + foreach (var c in text) + { + if (!IsHex(c)) + { + value = 0; + return false; + } + } + + value = GetHexValue(text); + return true; + } + + private static uint GetHexValue(ReadOnlySpan text) + { + if (text.Length > 8) + throw new ArgumentException(); + + var codepoint = 0u; + foreach (var c in text) + { + var hexDigit = (uint)GetHex(c); + codepoint = (codepoint * 16) + hexDigit; + } + + return codepoint; + } + + private static bool IsHex(char c) + { + return c is >= '0' and <= '9' || + c is >= 'A' and <= 'F' || + c is >= 'a' and <= 'f'; + } + + // We already know that c passed the IsHex method. + private static int GetHex(char c) + { + return c switch + { + >= 'A' and <= 'F' => c - 'A' + 10, + >= 'a' and <= 'f' => c - 'a' + 10, + >= '0' and <= '9' => c - '0', + _ => throw new UnreachableException() + }; + } +} diff --git a/ClosedXML.IO/XmlTreeReader.cs b/ClosedXML.IO/XmlTreeReader.cs new file mode 100644 index 000000000..61cc51965 --- /dev/null +++ b/ClosedXML.IO/XmlTreeReader.cs @@ -0,0 +1,528 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Xml; + +namespace ClosedXML.IO; + +/// +/// +/// Reader that expects that XML document consists of elements in a tree-like fashion. XML +/// shouldn't be mixed, text should be only in the leaves (e.g. <f>ABS(A1)*A2</f>). +/// +/// +/// The schema of XML should be mostly by elements, with choices and sequences. +/// +/// +/// In case of some specialities or mixed content, use XDocument.Load(_reader.ReadSubtree()) +/// and parse the result. +/// +/// +/// All Get* methods read values from attributes of current element. +/// +/// +/// The reader is always at either start element or end element. Any API that moves the reader will +/// end on either start or end element. The empty elements (e.g. <br/>) behave same way as +/// non-empty elements (that is different from that checks +/// ). +/// The reader element is used for one of two purposes: +/// +/// +/// Element is being processed, i.e. parser logic has correctly identified the element (by +/// name) and will use parser logic to extract data from element (mostly by reading attributes, +/// potentially content if it is a leaf). +/// +/// +/// Element is used as a lookahead. The parsing logic is using it to determine how to parse the rest +/// of document. Example: +/// Stylesheet parser has processed element ]]> from ]]>). +/// It reads next element ]]> as a lookahead. The parsing logic now has to +/// determine what to do. Font could have additional properties (e.g. ]]>) or the +/// schema could end. Parser will use reader.TryOpen("b") to check if there is a bold +/// element. If there isn't, it uses reader.TryClose("font") to check that font +/// should close. If neither is true, XML doesn't match expected schema and parser will likely +/// throw an exception. +/// +/// +/// +/// +/// +/// +/// +/// When adding API, use tell, don't ask principle. Asking generally inherently requires +/// allocation of a new string, but passing a string to a method doesn't. +/// +/// Example: +/// +/// reader.IsStartElement("font") vs reader.LocalName == "font". The former methods +/// re-uses interned string. If reader is optimized, it can check everything on stack, without any +/// new allocations. The second comparison basically requires a new string allocation for the +/// getter. +/// +/// +/// +/// Another example would be instead of getting string +/// and parsing it ourselves. Allocations do matter when parsing hundreds of MBs. +/// +/// +public sealed class XmlTreeReader : IDisposable +{ + private static readonly XmlReaderSettings Settings = new() + { + IgnoreComments = true, + Async = false, + DtdProcessing = DtdProcessing.Prohibit, + IgnoreWhitespace = true, + CloseInput = true + }; + + /// + /// The XmlReader that holds current element. The current node should always be either + /// or . + /// + private readonly XmlReader _reader; + + private readonly IEnumMapper _enumMapper; + + /// + /// + /// An abstraction to deal with empty elements. If current element is an empty element + /// (regardless of whether in processing or lookup mode), this property determines if + /// the element is interpreted as starting element or ending element. + /// + /// + /// The property is set for every element to make everything easier. + /// + /// + private bool _isStart = true; + + /// + /// What is current state of parser: + /// + /// + /// false + /// + /// Current element is being processed. we can get value of attributes. + /// + /// + /// + /// true + /// + /// Current element is not being processed. The only thing we are interested in is a name and + /// open/close. We are using it to determine how to parse the remainder of the file. + /// Trying to get attribute value will throw. + /// + /// + /// + /// + private bool _inLookup = true; + + public XmlTreeReader(Stream stream, IEnumMapper enumMapper, bool suppressFormatErrors) + { + _reader = XmlReader.Create(stream, Settings); + _enumMapper = enumMapper; + SuppressFormatErrors = suppressFormatErrors; + } + + public XmlTreeReader(XmlReader reader, IEnumMapper enumMapper, bool suppressFormatErrors) + { + _reader = reader; + _enumMapper = enumMapper; + SuppressFormatErrors = suppressFormatErrors; + } + + /// + /// Get name of current element (lookup/processing). It includes an alias for ns. + /// + internal string ElementName => _reader.Name; + + /// + /// Should unparseable attributes be treated as errors and throw exception are just return null? + /// Excel generally ignores unparseable attributes. + /// + internal bool SuppressFormatErrors { get; } + + /// + /// Read next element. Check lookup element is . If it is, open the + /// element and return true. Otherwise, return false (element doesn't change). + /// + public bool TryOpen(string localName, string namespaceUri) + { + MoveToStart(); + ThrowWhenReaderNotOnElement(); + SwitchToLookup(); + + if (_isStart && _reader.LocalName == localName && _reader.NamespaceURI == namespaceUri) + { + // Element has been opened, so it should be processed. + SwitchToProcessing(); + return true; + } + + return false; + } + + // Throws when it is on closing elements of incorrect type + public bool TryClose(string localName, string namespaceUri) + { + ThrowWhenReaderNotOnElement(); + SwitchToLookup(); + + if (_isStart || _reader.LocalName != localName || _reader.NamespaceURI != namespaceUri) + return false; + + // Element has been closed, so it should be processed. Though closing elements are not + // really processed, but we don't want to switch back to lookup. We just want to mark it + // as "done." Be lazy, e.g. last element of a document doesn't have next element. If + // parsing logic needs further elements, it will read them when they are needed. + SwitchToProcessing(); + return true; + } + + /// + /// Assert that we are at the element with . Doesn't move anywhere. + /// + public void Open(string localName, string namespaceUri) + { + if (!TryOpen(localName, namespaceUri)) + throw PartStructureException.ExpectedElementNotFound($"Expected opening element '{localName}', but reader is currently on {(_isStart ? "opening" : "closing")} '{_reader.Name}'."); + } + + /// + /// Close the next unprocessed node. If the node doesn't match the , + /// throw an exception. + /// + public void Close(string localName, string namespaceUri) + { + if (!TryClose(localName, namespaceUri)) + throw PartStructureException.ExpectedElementNotFound($"Expected closing element '{localName}', but reader is currently on {(_isStart ? "opening" : "closing")} '{_reader.Name}'.", this); + } + + /// + /// Skip subtree that start on the current element. After subtree is read, the reader is + /// on an ending element of a subtree in a processed state. + /// + /// Reader isn't on opening element. + public void Skip(string elementName) + { + ThrowOnNonStartElement(); + var startDepth = _reader.Depth; + do + { + MoveToNextElement(); + } while (_isStart || _reader.Depth > startDepth); + + _inLookup = false; + } + + /// + /// Read the content of current element. Ends in a lookup state on the end element. + /// + /// The content contains elements. + public string GetContent() + { + ThrowOnNonStartElement(); + if (_reader.IsEmptyElement) + { + _inLookup = true; + _isStart = false; + return string.Empty; + } + + // ReadElementContentAsString reads beyond closing element. Make your own reader. + var value = string.Empty; + while (ReadNode() is { } nodeType && nodeType != XmlNodeType.EndElement) + { + // All unspecified nodes should be skipped. It is either comments, processing + // instructions or something that shouldn't ever happen (e.g. attribute). + switch (nodeType) + { + case XmlNodeType.Text: + case XmlNodeType.Whitespace: + case XmlNodeType.SignificantWhitespace: + case XmlNodeType.CDATA: + if (value.Length == 0) + value = _reader.Value; + else + value += _reader.Value; + break; + case XmlNodeType.EntityReference: + value += _reader.Name; // Does it even work? I have no idea how to get this node. + break; + case XmlNodeType.Element: + throw PartStructureException.UnexpectedElementFound(_reader.LocalName); // No child elements allowed + } + } + + _inLookup = true; + _isStart = false; + return value; + } + + public bool? GetOptionalBool(string attributeName) + { + ThrowOnNonStartElement(); + bool? result = null; + if (_reader.MoveToAttribute(attributeName)) + { + try + { + result = _reader.ReadContentAsBoolean(); + } + catch (XmlException e) when (e.InnerException is FormatException) + { + if (!SuppressFormatErrors) + throw; + } + } + + _reader.MoveToElement(); + return result; + } + + public int? GetOptionalInt(string attributeName) + { + ThrowOnNonStartElement(); + _reader.MoveToAttribute(attributeName); + int? number = null; + if (_reader.MoveToAttribute(attributeName)) + { + try + { + number = _reader.ReadContentAsInt(); + } + catch (OverflowException) + { + if (!SuppressFormatErrors) + throw; + } + catch (XmlException e) when (e.InnerException is FormatException) + { + if (!SuppressFormatErrors) + throw; + } + } + + _reader.MoveToElement(); + return number; + } + + public uint? GetOptionalUInt(string attributeName) + { + ThrowOnNonStartElement(); + long? number = null; + if (_reader.MoveToAttribute(attributeName)) + { + try + { + number = _reader.ReadContentAsLong(); + } + catch (OverflowException) + { + if (!SuppressFormatErrors) + throw; + } + catch (XmlException e) when (e.InnerException is FormatException) + { + if (!SuppressFormatErrors) + throw; + } + } + + if (number is < 0 or > uint.MaxValue) + { + if (!SuppressFormatErrors) + throw PartStructureException.InvalidAttributeFormat(_reader.ReadContentAsString()); + + number = null; + } + + _reader.MoveToElement(); + return number is not null ? (uint)number : null; + } + + public double? GetOptionalDouble(string attributeName) + { + ThrowOnNonStartElement(); + double? number = null; + if (_reader.MoveToAttribute(attributeName)) + { + try + { + number = _reader.ReadContentAsDouble(); + } + catch (OverflowException) + { + if (!SuppressFormatErrors) + throw; + } + catch (XmlException e) when (e.InnerException is FormatException) + { + if (!SuppressFormatErrors) + throw; + } + } + + if (number is not null && (double.IsNaN(number.Value) || double.IsInfinity(number.Value))) + { + if (!SuppressFormatErrors) + throw PartStructureException.InvalidAttributeFormat(_reader.ReadContentAsString()); + + number = null; + } + + _reader.MoveToElement(); + return number; + } + + /// + /// Try to read xsd:dateTime from an attribute of current element. + /// + /// Name of the attribute. + /// Read datetime or null if attribute is not present. + public DateTime? GetOptionalDateTime(string attributeName) + { + ThrowOnNonStartElement(); + DateTime? dateTime = null; + if (_reader.MoveToAttribute(attributeName)) + { + try + { + dateTime = _reader.ReadContentAsDateTime(); + } + catch (XmlException e) when (e.InnerException is FormatException) + { + if (!SuppressFormatErrors) + throw; + } + } + + _reader.MoveToElement(); + return dateTime; + } + + public string? GetOptionalString(string attributeName) + { + ThrowOnNonStartElement(); + return _reader.GetAttribute(attributeName); + } + + public TEnum? GetOptionalEnum(string attributeName) + where TEnum : struct, Enum + { + ThrowOnNonStartElement(); + var enumString = _reader.MoveToAttribute(attributeName) ? _reader.ReadContentAsString() : null; + _reader.MoveToElement(); + + if (enumString is null) + return null; + + if (!_enumMapper.TryGetEnum(enumString, out var enumValue)) + { + if (!SuppressFormatErrors) + throw PartStructureException.InvalidAttributeFormat(enumString); + + return null; + } + + return enumValue; + } + + public void Dispose() + { + _reader.Dispose(); + } + + internal bool TryGetLineInfo([NotNullWhen(true)] out IXmlLineInfo? lineInfo) + { + if (_reader is IXmlLineInfo readerInfo && readerInfo.HasLineInfo()) + { + lineInfo = readerInfo; + return true; + } + + lineInfo = null; + return false; + } + + private void SwitchToProcessing() + { + if (_inLookup) + _inLookup = false; + } + + private void SwitchToLookup() + { + ThrowWhenReaderNotOnElement(); + + // When switching to lookup, current node and all its attributes should have already been processed. + if (_inLookup) + return; + + // Read next element. + MoveToNextElement(); + _inLookup = true; + } + + /// + /// Move from current opening/closing element to next opening/closing element. + /// + private void MoveToNextElement() + { + if (_isStart && _reader.IsEmptyElement) + { + _isStart = false; + return; + } + + while (ReadNode() is { } nodeType) + { + // The only allowed node type is element or end of element. All other types should + // either be skipped (e.g. text) or are errors. + if (nodeType is XmlNodeType.Element) + { + _isStart = true; + return; + } + + if (nodeType is XmlNodeType.EndElement) + { + _isStart = false; + return; + } + + // All other nodes should be skipped: + // * The possible nodes (Text, Comment, CDATA, SignificantWhitespace, + // ProcessingInstruction) should be skipped, because they are not elements. Excel + // also skips text that is between nodes where it is not valid, without error. + // * Other node types are disallowed by usage semantic (Document, None, XmlDeclaration) + // or XmlReader setting (DTD). + // * Attribute should never be encountered because it is after element. + } + } + + private XmlNodeType? ReadNode() + { + return _reader.Read() ? _reader.NodeType : null; + } + + private void MoveToStart() + { + if (_reader.NodeType == XmlNodeType.None) + { + _reader.MoveToContent(); + } + } + + private void ThrowWhenReaderNotOnElement() + { + if (_reader.NodeType is not XmlNodeType.Element and not XmlNodeType.EndElement) + throw new InvalidOperationException("XML reader is not on start or end note."); + } + + private void ThrowOnNonStartElement() + { + if (_reader.NodeType != XmlNodeType.Element || !_isStart || _inLookup) + throw new InvalidOperationException("To read content/attribute, the reader must be on start element and in processing state."); + } +} diff --git a/ClosedXML.IO/XmlTreeReaderExtensions.cs b/ClosedXML.IO/XmlTreeReaderExtensions.cs new file mode 100644 index 000000000..4ef5ec141 --- /dev/null +++ b/ClosedXML.IO/XmlTreeReaderExtensions.cs @@ -0,0 +1,101 @@ +using System; + +namespace ClosedXML.IO; + +/// +/// Extension methods to make reading from simpler and keep the reader slim. +/// +public static class XmlTreeReaderExtensions +{ + public static bool GetBool(this XmlTreeReader reader, string attributeName) + { + return reader.GetOptionalBool(attributeName) ?? throw PartStructureException.MissingAttribute(attributeName, reader); + } + + public static int GetInt(this XmlTreeReader reader, string attributeName) + { + return reader.GetOptionalInt(attributeName) ?? throw PartStructureException.MissingAttribute(attributeName, reader); + } + + public static uint GetUInt(this XmlTreeReader reader, string attributeName) + { + return reader.GetOptionalUInt(attributeName) ?? throw PartStructureException.MissingAttribute(attributeName, reader); + } + + public static int? GetOptionalUintAsInt(this XmlTreeReader reader, string attributeName) + { + return checked((int?)reader.GetOptionalUInt(attributeName)); + } + + public static double GetDouble(this XmlTreeReader reader, string attributeName) + { + return reader.GetOptionalDouble(attributeName) ?? throw PartStructureException.MissingAttribute(attributeName, reader); + } + + public static string GetString(this XmlTreeReader reader, string attributeName) + { + return reader.GetOptionalString(attributeName) ?? throw PartStructureException.MissingAttribute(attributeName, reader); + } + + public static TEnum GetEnum(this XmlTreeReader reader, string attributeName) + where TEnum : struct, Enum + { + return reader.GetOptionalEnum(attributeName) ?? throw PartStructureException.MissingAttribute(attributeName, reader); + } + + public static TEnum GetOptionalEnum(this XmlTreeReader reader, string attributeName, TEnum defaultValue) + where TEnum : struct, Enum + { + return reader.GetOptionalEnum(attributeName) ?? defaultValue; + } + + public static string GetXString(this XmlTreeReader reader, string attributeName) + { + return GetOptionalXString(reader, attributeName) ?? throw PartStructureException.MissingAttribute(attributeName, reader); + } + + public static string? GetOptionalXString(this XmlTreeReader reader, string attributeName) + { + var text = reader.GetOptionalString(attributeName); + return XStringConvert.Decode(text); + } + + /// + /// Get an attribute with ST_UnsignedIntHex content. + /// + public static uint? GetOptionalUIntHex(this XmlTreeReader reader, string attributeName) + { + // XmlReader has ReadContentAsBinHex, but it also requires allocation, so we can just do it + // as extension method without polluting reader. + var hexString = reader.GetOptionalString(attributeName); + if (hexString is null) + return null; + + if (hexString.Length != 8 || !XStringConvert.TryGetHexValue(hexString.AsSpan(), out var number)) + { + if (!reader.SuppressFormatErrors) + throw PartStructureException.InvalidAttributeFormat(hexString); + + return null; + } + + return number; + } + + /// + /// Read xsd:dateTime attribute. + /// + public static DateTime GetDateTime(this XmlTreeReader reader, string attributeName) + { + return reader.GetOptionalDateTime(attributeName) ?? throw PartStructureException.MissingAttribute(attributeName, reader); + } + + /// + /// Get count for various collections in parts. + /// + public static int GetCount(this XmlTreeReader reader) + { + var count = reader.GetOptionalUInt("count") ?? 1; + return checked((int)count); + } +} diff --git a/ClosedXML.Sandbox/ClosedXML.Sandbox.csproj b/ClosedXML.Sandbox/ClosedXML.Sandbox.csproj index 376082806..381932e19 100644 --- a/ClosedXML.Sandbox/ClosedXML.Sandbox.csproj +++ b/ClosedXML.Sandbox/ClosedXML.Sandbox.csproj @@ -1,7 +1,7 @@  - netcoreapp3.1;net461 + net8.0;net462 Exe diff --git a/ClosedXML.Sandbox/OneRow.cs b/ClosedXML.Sandbox/OneRow.cs index 0293934f5..ce8c253a7 100644 --- a/ClosedXML.Sandbox/OneRow.cs +++ b/ClosedXML.Sandbox/OneRow.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Web; using System.ComponentModel.DataAnnotations; namespace ClosedXML.Sandbox diff --git a/ClosedXML.Tests/ClosedXML.Tests.csproj b/ClosedXML.Tests/ClosedXML.Tests.csproj index 2a2137b58..af60e16b6 100644 --- a/ClosedXML.Tests/ClosedXML.Tests.csproj +++ b/ClosedXML.Tests/ClosedXML.Tests.csproj @@ -1,7 +1,8 @@  - netcoreapp3.1;net461 + 11.0 + net8.0;net462 false false $(NoWarn);NU1605;CS1591;CS1658;CS1584; @@ -10,15 +11,17 @@ - + + + - + - - + + @@ -30,4 +33,9 @@ + + + <_Parameter1>en-US + + diff --git a/ClosedXML.Tests/Examples/MiscTests.cs b/ClosedXML.Tests/Examples/MiscTests.cs index 9fe326d06..aad66e07b 100644 --- a/ClosedXML.Tests/Examples/MiscTests.cs +++ b/ClosedXML.Tests/Examples/MiscTests.cs @@ -52,7 +52,7 @@ public void BlankCells() [Test] public void CellValues() { - TestHelper.RunTestExample(@"Misc\CellValues.xlsx"); + TestHelper.RunTestExample(@"Misc\CellValues.xlsx", true); } [Test] @@ -79,12 +79,6 @@ public void DataTypes() TestHelper.RunTestExample(@"Misc\DataTypes.xlsx"); } - [Test] - public void DataTypesUnderDifferentCulture() - { - TestHelper.RunTestExample(@"Misc\DataTypesUnderDifferentCulture.xlsx"); - } - [Test] public void DataValidation() { diff --git a/ClosedXML.Tests/Examples/RangesTests.cs b/ClosedXML.Tests/Examples/RangesTests.cs index 4b2225a37..2d68bebbe 100644 --- a/ClosedXML.Tests/Examples/RangesTests.cs +++ b/ClosedXML.Tests/Examples/RangesTests.cs @@ -57,9 +57,9 @@ public void MultipleRanges() } [Test] - public void NamedRanges() + public void DefinedNames() { - TestHelper.RunTestExample(@"Ranges\NamedRanges.xlsx"); + TestHelper.RunTestExample(@"Ranges\DefinedNames.xlsx"); } [Test] diff --git a/ClosedXML.Tests/Excel/AutoFilters/AutoFilterTester.cs b/ClosedXML.Tests/Excel/AutoFilters/AutoFilterTester.cs new file mode 100644 index 000000000..27c85086e --- /dev/null +++ b/ClosedXML.Tests/Excel/AutoFilters/AutoFilterTester.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.AutoFilters +{ + internal class AutoFilterTester + { + private readonly Action _setFilter; + private readonly List<(XLCellValue Value, Action SetStyle, bool ExpectedVisibility)> _values = new(); + + internal AutoFilterTester(Action setFilter) + { + _setFilter = setFilter; + } + + internal AutoFilterTester Add(XLCellValue value, bool shouldBeVisible) + { + return Add(value, static (IXLStyle _) => { }, shouldBeVisible); + } + + internal AutoFilterTester Add(XLCellValue value, Action setNumberFormat, bool shouldBeVisible) + { + _values.Add((value, s => setNumberFormat(s.NumberFormat), shouldBeVisible)); + return this; + } + + internal AutoFilterTester Add(XLCellValue value, Action setStyle, bool shouldBeVisible) + { + _values.Add((value, setStyle, shouldBeVisible)); + return this; + } + + internal AutoFilterTester AddTrue(params XLCellValue[] values) + { + foreach (var value in values) + Add(value, true); + + return this; + } + + internal AutoFilterTester AddFalse(params XLCellValue[] values) + { + foreach (var value in values) + Add(value, false); + + return this; + } + + internal void AssertVisibility() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = "Data"; + for (var i = 0; i < _values.Count; ++i) + { + var cell = ws.Cell(i + 2, 1); + cell.Value = _values[i].Value; + _values[i].SetStyle(cell.Style); + } + + var autoFilter = ws.Range(1, 1, _values.Count + 1, 1).SetAutoFilter(); + _setFilter(autoFilter.Column(1)); + + for (var i = 0; i < _values.Count; ++i) + { + var row = i + 2; + var value = ws.Cell(row, 1).CachedValue; + var formattedString = ((XLCell)ws.Cell(row, 1)).GetFormattedString(value); + var actualVisible = !ws.Row(row).IsHidden; + var expectedVisibility = _values[i].ExpectedVisibility; + Assert.AreEqual(expectedVisibility, actualVisible, $"Visibility differs at index {i} for value {value} (formatted '{formattedString}')"); + } + } + } +} diff --git a/ClosedXML.Tests/Excel/AutoFilters/AutoFilterTests.cs b/ClosedXML.Tests/Excel/AutoFilters/AutoFilterTests.cs index 067e727f7..28380b12a 100644 --- a/ClosedXML.Tests/Excel/AutoFilters/AutoFilterTests.cs +++ b/ClosedXML.Tests/Excel/AutoFilters/AutoFilterTests.cs @@ -51,7 +51,7 @@ public void AutoFilterSortWhenNotInFirstRow() .CellBelow().SetValue("Carlos") .CellBelow().SetValue("Dominic"); ws.RangeUsed().SetAutoFilter().Sort(); - Assert.AreEqual("Carlos", ws.Cell(4, 3).GetString()); + Assert.AreEqual("Carlos", ws.Cell(4, 3).GetText()); } } @@ -192,7 +192,7 @@ public void AutoFilterVisibleRows() autoFilter.Column(1).AddFilter("Carlos"); - Assert.AreEqual("Carlos", ws.Cell(5, 3).GetString()); + Assert.AreEqual("Carlos", ws.Cell(5, 3).GetText()); Assert.AreEqual(2, autoFilter.VisibleRows.Count()); Assert.AreEqual(3, autoFilter.VisibleRows.First().WorksheetRow().RowNumber()); Assert.AreEqual(5, autoFilter.VisibleRows.Last().WorksheetRow().RowNumber()); @@ -237,8 +237,14 @@ public void CanLoadAutoFilterWithThousandsSeparator() { // Set thread culture to French, which should format numbers using a space as thousands separator var culture = CultureInfo.CreateSpecificCulture("fr-FR"); - // but use a period instead of a comma as for decimal separator - culture.NumberFormat.CurrencyDecimalSeparator = "."; + + // The value in sheet that will be compared with autofilter value is a number + // `10000`. That number will be formatted using culture to `10 000.00` thanks to + // modified properties of culture - period instead of a comma for decimal separator + // and space as group separator. The formatted number will thus match with the + // filter value. + culture.NumberFormat.NumberDecimalSeparator = "."; + culture.NumberFormat.NumberGroupSeparator = " "; Thread.CurrentThread.CurrentCulture = culture; @@ -246,7 +252,10 @@ public void CanLoadAutoFilterWithThousandsSeparator() using (var wb = new XLWorkbook(stream)) { var ws = wb.Worksheets.First(); - Assert.AreEqual(10000, (ws.AutoFilter as XLAutoFilter).Filters.First().Value.First().Value); + + // Regular filter compares values as strings, doesn't convert to XLCellValue, + // so the value is read from the file as a text despite looking like a number. + Assert.AreEqual("10 000.00", ((XLAutoFilter)ws.AutoFilter).Column(1).Single().Value); Assert.AreEqual(2, ws.AutoFilter.VisibleRows.Count()); ws.AutoFilter.Reapply(); @@ -259,7 +268,7 @@ public void CanLoadAutoFilterWithThousandsSeparator() using (var wb = new XLWorkbook(stream)) { var ws = wb.Worksheets.First(); - Assert.AreEqual("10 000.00", (ws.AutoFilter as XLAutoFilter).Filters.First().Value.First().Value); + Assert.AreEqual("10 000.00", ((XLAutoFilter)ws.AutoFilter).Column(1).Single().Value); var v = ws.AutoFilter.VisibleRows.Select(r => r.FirstCell().Value).ToList(); Assert.AreEqual(2, ws.AutoFilter.VisibleRows.Count()); @@ -273,5 +282,96 @@ public void CanLoadAutoFilterWithThousandsSeparator() Thread.CurrentThread.CurrentCulture = backupCulture; } } + + [Test] + public void Issue1917NotContainsFilter() + { + using (var ms = new MemoryStream()) + { + using (var wb = new XLWorkbook()) + { + var ws = wb.Worksheets.Add("Test"); + ws.Cell(1, 1).SetValue("StringCol"); + + for (var i = 0; i < 5; i++) + { + ws.Cell(i + 2, 1).SetValue($"String{i}"); + } + + var autoFilter = ws.RangeUsed() + .SetAutoFilter(); + + autoFilter.Column(1).NotContains("String3"); + Assert.AreEqual(1, autoFilter.HiddenRows.Count()); + + wb.SaveAs(ms); + } + + ms.Position = 0; + using (var wb = new XLWorkbook(ms)) + { + var ws = wb.Worksheets.Worksheet("Test"); + var autoFilter = ws.AutoFilter; + + autoFilter.Reapply(); + Assert.AreEqual(1, autoFilter.HiddenRows.Count()); + } + } + } + + [Test] + [TestCase("ends")] + [TestCase("begins")] + [TestCase("equal")] + [TestCase("contains")] + public void NotStringFilter(string type) + { + using (var ms = new MemoryStream()) + { + using (var wb = new XLWorkbook()) + { + var ws = wb.Worksheets.Add("Test"); + ws.Cell(1, 1).SetValue("StringCol"); + + for (var i = 0; i < 5; i++) + { + ws.Cell(i + 2, 1).SetValue($"{i}-String{i}"); + } + + ws.Columns().AdjustToContents(); + var autoFilter = ws.RangeUsed() + .SetAutoFilter(); + + switch (type) + { + case "ends": + autoFilter.Column(1).NotEndsWith("3"); + break; + case "begins": + autoFilter.Column(1).NotBeginsWith("3"); + break; + case "equal": + autoFilter.Column(1).NotEqualTo("3-String3"); + break; + case "contains": + autoFilter.Column(1).NotContains("3-"); + break; + } + Assert.AreEqual(1, autoFilter.HiddenRows.Count()); + + wb.SaveAs(ms); + } + + ms.Position = 0; + using (var wb = new XLWorkbook(ms)) + { + var ws = wb.Worksheets.Worksheet("Test"); + var autoFilter = ws.AutoFilter; + + autoFilter.Reapply(); + Assert.AreEqual(1, autoFilter.HiddenRows.Count()); + } + } + } } } diff --git a/ClosedXML.Tests/Excel/AutoFilters/CustomFilterTests.cs b/ClosedXML.Tests/Excel/AutoFilters/CustomFilterTests.cs new file mode 100644 index 000000000..8dc9979fd --- /dev/null +++ b/ClosedXML.Tests/Excel/AutoFilters/CustomFilterTests.cs @@ -0,0 +1,226 @@ +using System; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.AutoFilters +{ + /// + /// Equal/NotEqual operators in custom filter are wildcard filters, *NOT* comparator filter. + /// LessThan/EqualOrLessThan/EqualOrGreaterThan/GreaterThan are comparator filters. + /// + [TestFixture] + public class CustomFilterTests + { + [Test] + public void EqualOrLessThan_with_logical_compares_against_values_of_same_type() + { + new AutoFilterTester(f => f.EqualOrLessThan(true)) + .AddTrue(false, true) + .AddFalse(Blank.Value, 1, "FALSE", "TRUE", XLError.NullValue) + .AssertVisibility(); + } + + [Test] + public void EqualOrLessThan_with_number_compares_against_values_of_same_type() + { + WithOneAndOtherTypes(f => f.EqualOrLessThan(1)) + .Add(0.9, true) + .Add(1.1, false) + .AssertVisibility(); + } + + [Test] + public void EqualOrLessThan_with_text_compares_against_values_of_same_type() + { + new AutoFilterTester(f => f.EqualOrLessThan("b")) + .AddTrue("", "A", "b", "B") + .AddFalse("C", Blank.Value, 1, false, XLError.NullValue) + .AssertVisibility(); + } + + [Test] + public void EqualOrLessThan_with_error_compares_against_numeric_types_of_error() + { + new AutoFilterTester(f => f.EqualOrLessThan(XLError.CellReference)) + .AddTrue(XLError.NullValue, XLError.IncompatibleValue, XLError.CellReference) + .AddFalse(XLError.NameNotRecognized, 1, "#NULL!", "Test", "", true, false, Blank.Value) + .AssertVisibility(); + } + + [Test] + public void LessThan_with_logical_compares_against_values_of_same_type() + { + new AutoFilterTester(f => f.LessThan(true)) + .AddTrue(false) + .AddFalse(true, -1, Blank.Value, 1, "FALSE", "TRUE", XLError.NullValue) + .AssertVisibility(); + } + + [Test] + public void LessThan_with_number_compares_against_values_of_same_type() + { + WithOneAndOtherTypes(f => f.LessThan(2)) + .Add(1.1, true) + .Add(2, false) + .AssertVisibility(); + } + + [Test] + public void LessThan_with_text_compares_against_values_of_same_type() + { + new AutoFilterTester(f => f.LessThan("b")) + .AddTrue("", "A") + .AddFalse("b", "B", "C", Blank.Value, 1, false, XLError.NullValue) + .AssertVisibility(); + } + + [Test] + public void LessThan_with_error_compares_against_numeric_types_of_error() + { + new AutoFilterTester(f => f.LessThan(XLError.CellReference)) + .AddTrue(XLError.NullValue, XLError.IncompatibleValue) + .AddFalse(XLError.CellReference, XLError.NameNotRecognized, 1, "#NULL!", "Test", "", true, false, Blank.Value) + .AssertVisibility(); + } + + [Test] + public void GreaterThan_with_logical_compares_against_values_of_same_type() + { + new AutoFilterTester(f => f.GreaterThan(false)) + .AddTrue(true) + .AddFalse(false, -1, Blank.Value, 1, "FALSE", "TRUE", XLError.NullValue) + .AssertVisibility(); + } + + [Test] + public void GreaterThan_with_number_compares_against_values_of_same_type() + { + WithOneAndOtherTypes(f => f.GreaterThan(0)) + .Add(0.1, true) + .AddFalse(-0.1, -1) + .AssertVisibility(); + } + + [Test] + public void GreaterThan_with_text_compares_against_values_of_same_type() + { + new AutoFilterTester(f => f.GreaterThan("b")) + .AddTrue("C", "c") + .AddFalse("", "A", "b", "B", Blank.Value, 1, false, XLError.NullValue) + .AssertVisibility(); + } + + [Test] + public void GreaterThan_with_error_compares_against_numeric_types_of_error() + { + new AutoFilterTester(f => f.GreaterThan(XLError.CellReference)) + .AddTrue(XLError.NameNotRecognized, XLError.NumberInvalid, XLError.NoValueAvailable) + .AddFalse(XLError.CellReference, XLError.IncompatibleValue, XLError.NullValue, 1, "#NULL!", "Test", "", true, false, Blank.Value) + .AssertVisibility(); + } + + [Test] + public void EqualOrGreaterThan_with_logical_compares_against_values_of_same_type() + { + new AutoFilterTester(f => f.EqualOrGreaterThan(false)) + .AddTrue(false, true) + .AddFalse(-1, 0, Blank.Value, 1, "FALSE", "TRUE", XLError.NullValue) + .AssertVisibility(); + } + + [Test] + public void EqualOrGreaterThan_with_number_compares_against_values_of_same_type() + { + WithOneAndOtherTypes(f => f.EqualOrGreaterThan(1)) + .Add(0.9, false) + .Add(1.1, true) + .AssertVisibility(); + } + + [Test] + public void EqualOrGreaterThan_with_text_compares_against_values_of_same_type() + { + new AutoFilterTester(f => f.EqualOrGreaterThan("b")) + .AddTrue("b", "B", "Ba", "C", "c") + .AddFalse("", "A", Blank.Value, 1, false, XLError.NullValue) + .AssertVisibility(); + } + + [Test] + public void EqualOrGreaterThan_with_error_compares_against_numeric_types_of_error() + { + new AutoFilterTester(f => f.EqualOrGreaterThan(XLError.CellReference)) + .AddTrue(XLError.CellReference, XLError.NameNotRecognized, XLError.NumberInvalid, XLError.NoValueAvailable) + .AddFalse(XLError.IncompatibleValue, XLError.NullValue, 1, "#NULL!", "Test", "", true, false, Blank.Value) + .AssertVisibility(); + } + + [Test] + public void Equal_uses_wildcard_matching_for_patterns_against_text_only() + { + new AutoFilterTester(f => f.EqualTo("1*0")) + .AddTrue("1.0", "1 and 0") + .AddFalse(1, "A", "B", 2, XLError.DivisionByZero, true, false) + .Add(1, nf => nf.SetFormat("1.0"), false) + .Add(1, nf => nf.SetNumberFormatId((int)XLPredefinedFormat.Number.Precision2), false) + .AssertVisibility(); + } + + [Test] + [SetCulture("cs-CZ")] + public void Equal_uses_format_string_matching_for_filter_values_that_look_like_non_patterns() + { + // Note the ',' separator that is used detect number. Excel doesn't use invariant culture. + new AutoFilterTester(f => f.EqualTo("1,00")) + .Add("1,00", true) + .Add(1, nf => nf.SetNumberFormatId((int)XLPredefinedFormat.Number.Precision2), true) + .Add(99, nf => nf.SetFormat("\"1,00\""), true) + .AddFalse(1, "A", "B", 2, XLError.DivisionByZero, true, false) + .AssertVisibility(); + } + + [Test] + [SetCulture("cs-CZ")] + public void NotEqual_matches_detected_type_and_value_of_filter_value_for_non_text_data_types() + { + // 1,00 is detected as a type number with value 1. + new AutoFilterTester(f => f.NotEqualTo("1,00")) + .Add(1, false) // Value is equal => hide + .Add(1, nf => nf.SetNumberFormatId((int)XLPredefinedFormat.Number.Precision2), false) // Value is equal => hide + .Add("1,00", true) // wrong type + .Add(99, nf => nf.SetFormat("\"1,00\""), true) // Value is wrong => non-equal + .AddTrue("A", "B", 2, XLError.DivisionByZero, true, false) // Wrong type + .AssertVisibility(); + } + + [Test] + [SetCulture("cs-CZ")] + public void NotEqual_for_detected_wildcard_matches_only_texts() + { + // NotEqual with text pattern must have text type. + new AutoFilterTester(f => f.NotEqualTo("1*0")) + .Add(1, true) + .Add(1, nf => nf.SetNumberFormatId((int)XLPredefinedFormat.Number.Precision2), true) + .Add("1,00", false) + .Add("100", false) + .Add(100, true) + .Add(99, nf => nf.SetFormat("\"1,00\""), true) + .AddTrue("A", "B", 2, XLError.DivisionByZero, true, false) + .AssertVisibility(); + } + + private static AutoFilterTester WithOneAndOtherTypes(Action filter) + { + // Add equivalent of 1 and other types + return new AutoFilterTester(filter) + .Add(1, true) + .Add(new DateTime(1900, 1, 1), true) // =1 in serial date time + .Add(new TimeSpan(1, 0, 0, 0), true) // =1 in serial date time + .Add("1", false) + .Add(Blank.Value, false) + .Add("Hello", false) + .Add(true, false) + .Add(XLError.NullValue, false); // #NULL! has type value 1 + } + } +} diff --git a/ClosedXML.Tests/Excel/AutoFilters/DynamicFilterTests.cs b/ClosedXML.Tests/Excel/AutoFilters/DynamicFilterTests.cs new file mode 100644 index 000000000..a4059d57c --- /dev/null +++ b/ClosedXML.Tests/Excel/AutoFilters/DynamicFilterTests.cs @@ -0,0 +1,70 @@ +using System; +using NUnit.Framework; +using System.Linq; +using ClosedXML.Excel; + +namespace ClosedXML.Tests.Excel.AutoFilters +{ + [TestFixture] + public class DynamicFilterTests + { + [Test] + public void Average_filter_is_initialized_after_load() + { + TestHelper.CreateSaveLoadAssert( + (_, ws) => + { + var autoFilter = ws.Cell("A1").InsertData(new object[] + { + "Data", + 1,2,3,4,5,10, // avg. 4.16 + }).SetAutoFilter(); + autoFilter.Column(1).AboveAverage(); + }, + (_, ws) => + { + ws.AutoFilter.Reapply(); + var filterResult = ws.Rows("2:7").Select(row => !row.IsHidden); + CollectionAssert.AreEqual(new[] { false, false, false, false, true, true }, filterResult); + }); + } + + [Test] + public void BelowAverage_takes_values_under_avg_value() + { + // The average 2 is not included. + new AutoFilterTester(f => f.BelowAverage()) + .AddTrue(1) + .AddFalse(2, 3) + .AssertVisibility(); + } + + [Test] + public void AboveAverage_takes_values_over_avg_value() + { + new AutoFilterTester(f => f.AboveAverage()) + .AddTrue(3) + .AddFalse(2, 1) + .AssertVisibility(); + } + + [Test] + public void Average_ignores_non_unified_numbers() + { + new AutoFilterTester(f => f.BelowAverage()) + .AddTrue(new DateTime(1900, 1, 1)) // Serial date time 1 + .AddFalse(1.1) + .AddFalse(1.2) + .AddFalse(XLError.NoValueAvailable, true, false, "-100", "Text", Blank.Value) + .AssertVisibility(); + } + + [Test] + public void All_rows_are_hidden_when_column_has_no_number() + { + new AutoFilterTester(f => f.AboveAverage()) + .AddFalse(Blank.Value, true, false, "-100", "Text", XLError.NoValueAvailable) + .AssertVisibility(); + } + } +} diff --git a/ClosedXML.Tests/Excel/AutoFilters/RegularFilterTests.cs b/ClosedXML.Tests/Excel/AutoFilters/RegularFilterTests.cs new file mode 100644 index 000000000..4b56ad60e --- /dev/null +++ b/ClosedXML.Tests/Excel/AutoFilters/RegularFilterTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Linq; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.AutoFilters +{ + [TestFixture] + public class RegularFilterTests + { + [Test] + public void DateTimeGrouping_and_regular_values_can_be_used_together() + { + // OpenXML SDK validator considers filter and dateTimeGroup filter elements together to + // be an error, but it isn't (XSD allows and Excel reads). Therefore, disable + // validation for the test. + TestHelper.CreateSaveLoadAssert( + (_, ws) => + { + var autoFilter = ws.Cell("A1").InsertData(new object[] + { + "Data", + 1, 2, + new DateTime(2015, 7, 25), + new DateTime(2015, 8, 25), + }).SetAutoFilter(); + autoFilter.Column(1) + .AddFilter(1) + .AddDateGroupFilter(new DateTime(2015, 8, 1), XLDateTimeGrouping.Month); + }, + (_, ws) => + { + ws.AutoFilter.Reapply(); + var dataVisibility = ws.Rows("2:5").Select(row => !row.IsHidden); + CollectionAssert.AreEqual(new[] { true, false, false, true }, dataVisibility); + }, false); + } + + [Test] + [SetCulture("cs-CZ")] + public void Regular_number_value_is_compared_as_text_against_formatted_text() + { + new AutoFilterTester(f => f.AddFilter(1.5)) + .Add(1.5, true) + .Add("1.5", false) + .Add("1,5", true) + .Add("1,50", false) + .Add(1.5, nf => nf.SetNumberFormatId((int)XLPredefinedFormat.Number.PercentPrecision2), false) + .Add(700, nf => nf.SetFormat("\"1,5\""), true) + .AssertVisibility(); + } + + [Test] + [SetCulture("cs-CZ")] + public void Regular_logical_value_is_compared_as_text_against_formatted_text() + { + new AutoFilterTester(f => f.AddFilter(false)) + .Add(false, true) + .Add(0, false) + .Add("FALSE", true) + .Add("TRUE", false) + .Add(true, false) + .Add(77, nf => nf.SetFormat("\"FALSE\""), true) + .AssertVisibility(); + } + + [Test] + [SetCulture("cs-CZ")] + public void Regular_error_value_is_compared_as_text_against_formatted_text() + { + new AutoFilterTester(f => f.AddFilter("#VALUE!")) + .Add(XLError.IncompatibleValue, true) + .Add(2, false) + .Add("#VALUE!", true) + .AssertVisibility(); + } + + [Test] + public void Pattern_is_not_interpreted_as_wildcard() + { + new AutoFilterTester(f => f.AddFilter("A*")) + .Add("A*", true) + .Add("A", false) + .Add("A something", false) + .AssertVisibility(); + } + } +} diff --git a/ClosedXML.Tests/Excel/AutoFilters/Top10FilterTests.cs b/ClosedXML.Tests/Excel/AutoFilters/Top10FilterTests.cs new file mode 100644 index 000000000..7a198d553 --- /dev/null +++ b/ClosedXML.Tests/Excel/AutoFilters/Top10FilterTests.cs @@ -0,0 +1,146 @@ +using System; +using NUnit.Framework; +using System.Linq; +using ClosedXML.Excel; + +namespace ClosedXML.Tests.Excel.AutoFilters +{ + [TestFixture] + public class Top10FilterTests + { + [Test] + public void Top10_filter_is_initialized_after_load() + { + TestHelper.CreateSaveLoadAssert( + (_, ws) => + { + var autoFilter = ws.Cell("A1").InsertData(new object[] + { + "Data", + 4,4,1,3,2,5, + }).SetAutoFilter(); + autoFilter.Column(1).Top(3); + }, + (_, ws) => + { + ws.AutoFilter.Reapply(); + var filterResult = ws.Rows("2:7").Select(row => !row.IsHidden); + CollectionAssert.AreEqual(new[] { true, true, false, false, false, true }, filterResult); + }); + } + + [Test] + public void Top_items_filter_excludes_non_unified_numbers() + { + // Sort and then use cutoff value, it's 4 here and then take all values >= cutoff. + new AutoFilterTester(f => f.Top(1)) + .AddTrue(new DateTime(1900, 2, 10)) + .AddFalse(11, 10) + .AddFalse("-1000", "Text", Blank.Value, true, false, XLError.IncompatibleValue) + .AssertVisibility(); + } + + [Test] + public void Bottom_items_filter_excludes_non_unified_numbers() + { + new AutoFilterTester(f => f.Bottom(1)) + .AddTrue(new DateTime(1900, 1, 1)) + .AddFalse(2, 3) + .AddFalse("-1000", "Text", Blank.Value, true, false, XLError.IncompatibleValue) + .AssertVisibility(); + } + + [Test] + public void Top_items_filter_determines_top_items_by_determining_cut_off_value() + { + // Sort and then use cutoff value, it's 4 here and then take all values <= cutoff. + new AutoFilterTester(f => f.Top(2)) + .AddTrue(5, 4, 4, 4) + .AddFalse(3, 2, 1) + .AssertVisibility(); + + // Cutoff is 5 here. + new AutoFilterTester(f => f.Top(2)) + .AddTrue(5, 5) + .AddFalse(4, 4, 4, 3, 2, 1) + .AssertVisibility(); + } + + [Test] + public void Bottom_items_filter_determines_top_items_by_determining_cut_off_value() + { + // Cutoff is 2 + new AutoFilterTester(f => f.Bottom(2)) + .AddTrue(1, 2, 2, 2) + .AddFalse(3, 4, 5) + .AssertVisibility(); + + // Cutoff is 5 + new AutoFilterTester(f => f.Bottom(2)) + .AddTrue(1, 1) + .AddFalse(2, 2, 2, 3, 4, 5) + .AssertVisibility(); + } + + [Test] + public void Top_percents_uses_inclusive_percent_value() + { + // Autofilter doesn't include value 750, which is at 75%, i.e. right at the border. + new AutoFilterTester(f => f.Top(25, XLTopBottomType.Percent)) + .AddFalse(Enumerable.Range(1, 750).Select(x => x).ToArray()) + .AddTrue(Enumerable.Range(751, 250).Select(x => x).ToArray()) + .AssertVisibility(); + } + + [Test] + public void Bottom_percents_uses_inclusive_percent_value() + { + new AutoFilterTester(f => f.Bottom(25, XLTopBottomType.Percent)) + .AddTrue(Enumerable.Range(1, 250).Select(x => x).ToArray()) + .AddFalse(Enumerable.Range(251, 750).Select(x => x).ToArray()) + .AssertVisibility(); + } + + [Test] + public void Top_percents_always_has_at_least_one_item() + { + // Top 1% takes one item that is 33% of all items. + new AutoFilterTester(f => f.Top(1, XLTopBottomType.Percent)) + .AddTrue(3) + .AddFalse(2, 1) + .AssertVisibility(); + } + + [Test] + public void Bottom_percents_always_has_at_least_one_item() + { + new AutoFilterTester(f => f.Bottom(1, XLTopBottomType.Percent)) + .AddTrue(1) + .AddFalse(2, 3) + .AssertVisibility(); + } + + [TestCase(0, true)] + [TestCase(501, true)] + [TestCase(0, false)] + [TestCase(501, false)] + public void Top_and_bottom_filter_value_must_be_between_1_and_500(int value, bool top) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = "Data"; + ws.Cell("A2").Value = value; + var autoFilter = ws.Range("A1:A2").SetAutoFilter(); + var filterColumn = autoFilter.Column(1); + + var ex = Assert.Throws(() => + { + if (top) + filterColumn.Top(value); + else + filterColumn.Bottom(value); + })!; + StringAssert.Contains("Value must be between 1 and 500.", ex.Message); + } + } +} diff --git a/ClosedXML.Tests/Excel/Caching/SampleRepositoryTests.cs b/ClosedXML.Tests/Excel/Caching/SampleRepositoryTests.cs index 32379a8d7..e3725a3ec 100644 --- a/ClosedXML.Tests/Excel/Caching/SampleRepositoryTests.cs +++ b/ClosedXML.Tests/Excel/Caching/SampleRepositoryTests.cs @@ -33,13 +33,16 @@ public void NonUsedReferencesAreGCed() { #if !DEBUG // Arrange - int key = 12345; + var key = 12345; var sampleRepository = this.CreateSampleRepository(); // Act - var storedEntityRef1 = new System.WeakReference(sampleRepository.Store(ref key, new SampleEntity(key))); + // In net8, JIT could make a hidden temporary variable for created object that would prevent + // GC collection. Therefore, make the reference in another method, so the hidden variable + // doesn't get inlined. https://github.com/dotnet/runtime/issues/63568#issuecomment-1008602069 + var storedEntityRef1 = AddEntityToRepository(sampleRepository, ref key); - int count = 0; + var count = 0; do { System.Threading.Thread.Sleep(50); @@ -52,6 +55,13 @@ public void NonUsedReferencesAreGCed() Assert.Fail("storedEntityRef1 was not GCed"); Assert.IsFalse(sampleRepository.Any()); + + return; + + static System.WeakReference AddEntityToRepository(SampleRepository repository, ref int key) + { + return new System.WeakReference(repository.Store(ref key, new SampleEntity(key))); + } #else Assert.Ignore("Can't run in DEBUG"); #endif diff --git a/ClosedXML.Tests/Excel/CalcEngine/ArithmeticOperatorsTests.cs b/ClosedXML.Tests/Excel/CalcEngine/ArithmeticOperatorsTests.cs index fc327891e..da0de52ea 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/ArithmeticOperatorsTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/ArithmeticOperatorsTests.cs @@ -1,6 +1,6 @@ using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine; using NUnit.Framework; +using System; namespace ClosedXML.Tests.Excel.CalcEngine { @@ -30,7 +30,7 @@ public void Concat_ConcatenateBlank(string formula, object expectedResult) [TestCase("FALSE & \" to text\"", "FALSE to text")] [TestCase("true & \" to text\"", "TRUE to text")] [TestCase("false & \" to text\"", "FALSE to text")] - [TestCase("TRUE & FALSE", "TRUEFALSE")] + [TestCase("TRUE & FALSE", @"TRUEFALSE")] public void Concat_ConvertsLogicalToString(string formula, object expectedResult) { Assert.AreEqual(expectedResult, XLWorkbook.EvaluateExpr(formula)); @@ -55,16 +55,6 @@ public void Concat_WithErrorAsOperandReturnsTheError(string formula, XLError exp Assert.AreEqual(expectedError, XLWorkbook.EvaluateExpr(formula)); } - [Ignore("Arrays are not implemented")] - [TestCase("{1,2} & \"A\"", "1A")] - [TestCase("{\"A\",2} & \"B\"", "AB")] - [TestCase("{TRUE,2} & \"B\"", "TRUEB")] - [TestCase("{#REF!,5} & 1", XLError.CellReference)] - public void Concat_UsesFirstElementOfArray(string formula, object expected) - { - Assert.AreEqual(expected, XLWorkbook.EvaluateExpr(formula)); - } - #endregion #region Unary plus @@ -74,7 +64,7 @@ public void Concat_UsesFirstElementOfArray(string formula, object expected) [TestCase("+TRUE", true)] [TestCase("+FALSE", false)] [TestCase("+#DIV/0!", XLError.DivisionByZero)] - [TestCase("+A1", 0)] + [TestCase("ISBLANK(+A1)", true)] public void UnaryPlus_IsNonOpThatKeepsValueAndType(string formula, object expectedValue) { Assert.AreEqual(expectedValue, Evaluate(formula)); @@ -224,7 +214,78 @@ public void Subtraction_CanWorkWithScalars(string formula, object expectedValue) #endregion - private static object Evaluate(string formula) + #region Array Operations + + [Test] + public void ArraysOperation_BinaryOperationBetweenAreaReferenceAndSingleCellReferenceShouldWork() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Test1"); + ws.Cell("A1").Value = new DateTime(2021, 1, 15); + ws.Cell("A2").Value = new DateTime(2021, 1, 10); + ws.Cell("B1").Value = new DateTime(2021, 1, 5); + Assert.AreEqual(5, ws.Evaluate("MIN(A1:A2-B1)")); + } + + [Test] + public void ArraysOperation_MultiAreaReferencesArgumentResultsInScalarError() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cells("A1:A2").Value = 1; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("(A1:A1,A1:A2)+1")); + Assert.AreEqual(16, ws.Evaluate("TYPE((A1:A1,A1:A2)+1)")); // The result is a scalar error, not an array of errors + } + + [Test] + public void ArrayOperation_SameSizeArrayPerformsOperationIndividually() + { + Assert.AreEqual(6 * 7, XLWorkbook.EvaluateExpr("SUM({1,2,3;4,5,6} + {6,5,4;3,2,1})")); + Assert.AreEqual(2, XLWorkbook.EvaluateExpr("COLUMNS({1,2} + \"A\")")); + } + + [Test] + public void ArrayOperation_ArrayPlusScalarUpscalesScalarToSizeOfArray() + { + Assert.AreEqual(18, XLWorkbook.EvaluateExpr("SUM({1,1,1;1,1,1} * 3)")); + Assert.AreEqual(15, XLWorkbook.EvaluateExpr("SUM(6 / {2,2,2;3,3,3})")); + } + + [Test] + public void ArrayOperation_RowOnlyArrayIsRepeatedToHaveSameNumberOfRowsAsOtherArray() + { + // {3,2} is scaled to {3,2;3,2} of second array + Assert.AreEqual(14, XLWorkbook.EvaluateExpr("SUM({3,2}+{1,1;1,1})")); + Assert.AreEqual(14, XLWorkbook.EvaluateExpr("SUM({1,1;1,1}+{3,2})")); + } + + [Test] + public void ArrayOperation_ColumnOnlyArrayIsRepeatedToHaveSameNumberOfColumnsAsOtherArray() + { + // {3;2} is scaled to {3,3;2,2} of second array + Assert.AreEqual(16, XLWorkbook.EvaluateExpr("SUM({3;2}*{1,1;2,3})")); + Assert.AreEqual(16, XLWorkbook.EvaluateExpr("SUM({1,1;2,3}*{3;2})")); + } + + [Test] + public void ArrayOperation_1x1ArrayIsScaledToOtherArray() + { + Assert.AreEqual(20, XLWorkbook.EvaluateExpr("SUM({2}*{1,2;3,4})")); + Assert.AreEqual(20, XLWorkbook.EvaluateExpr("SUM({1,2;3,4}*{2})")); + } + + [Test] + public void ArrayOperation_DifferentSizedArraysAreUpscaledToContainingSize() + { + // The extra value are #N/A + value, i.e. #N/A, thus the whole sum is #N/A + Assert.AreEqual(XLError.NoValueAvailable, XLWorkbook.EvaluateExpr("SUM({1,2;3,4;5,6}+{1,2,3;4,5,6})")); + Assert.AreEqual(3, XLWorkbook.EvaluateExpr("ROWS({1,2;3,4;5,6}+{1,2,3;4,5,6})")); + Assert.AreEqual(3, XLWorkbook.EvaluateExpr("COLUMNS({1,2;3,4;5,6}+{1,2,3;4,5,6})")); + } + + #endregion + + private static XLCellValue Evaluate(string formula) { using var wb = new XLWorkbook(); var ws = wb.AddWorksheet(); diff --git a/ClosedXML.Tests/Excel/CalcEngine/ArrayFormulaCalculationTests.cs b/ClosedXML.Tests/Excel/CalcEngine/ArrayFormulaCalculationTests.cs new file mode 100644 index 000000000..6d452b658 --- /dev/null +++ b/ClosedXML.Tests/Excel/CalcEngine/ArrayFormulaCalculationTests.cs @@ -0,0 +1,134 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.CalcEngine +{ + [TestFixture] + public class ArrayFormulaCalculationTests + { + [Test] + public void ScalarResultOfArrayFormulaIsCopiedAcrossCellGroup() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.Range("C2:D4"); + + range.FormulaArrayA1 = "ABS(-1)"; + + foreach (var arrayFormulaCell in range.Cells()) + { + Assert.AreEqual(1, arrayFormulaCell.Value); + } + } + + [Test] + public void SameShapeResultCausesEachCellOfCellGroupToUseCorrespondingValue() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.Range("A1:A2"); + + range.FormulaArrayA1 = "TRANSPOSE({1,2})"; + + Assert.AreEqual(1, ws.Cell("A1").Value); + Assert.AreEqual(2, ws.Cell("A2").Value); + } + + [Test] + public void OnlyLeftmostValuesAreUsedWhenCellGroupHasFewerColumnsThanValue() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.Range("A1:C1"); + + range.FormulaArrayA1 = "{1,2,3,4,5}"; + + Assert.AreEqual(1, ws.Cell("A1").Value); + Assert.AreEqual(2, ws.Cell("B1").Value); + Assert.AreEqual(3, ws.Cell("C1").Value); + Assert.AreEqual(Blank.Value, ws.Cell("D1").Value); + } + + [Test] + public void OnlyTopmostValuesAreUsedWhenCellGroupHasFewerRowsThanValue() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.Range("A1:A3"); + + range.FormulaArrayA1 = "{1;2;3;4;5}"; + + Assert.AreEqual(1, ws.Cell("A1").Value); + Assert.AreEqual(2, ws.Cell("A2").Value); + Assert.AreEqual(3, ws.Cell("A3").Value); + Assert.AreEqual(Blank.Value, ws.Cell("A4").Value); + } + + [Test] + public void SingleColumnValueIsClonedAcrossCellGroup() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.Range("A1:C3"); + + range.FormulaArrayA1 = "{1;2}"; + + for (var column = 1; column <= 3; column++) + { + Assert.AreEqual(1, ws.Cell(1, column).Value); + Assert.AreEqual(2, ws.Cell(2, column).Value); + Assert.AreEqual(XLError.NoValueAvailable, ws.Cell(3, column).Value); + } + } + + [Test] + public void SingleRowValueIsClonedAcrossCellGroup() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.Range("A1:C3"); + + range.FormulaArrayA1 = "{1,2}"; + + for (var row = 1; row <= 3; row++) + { + Assert.AreEqual(1, ws.Cell(row, 1).Value); + Assert.AreEqual(2, ws.Cell(row, 2).Value); + Assert.AreEqual(XLError.NoValueAvailable, ws.Cell(row, 3).Value); + } + } + + [Test] + public void ExcessColumnsAndRowsOfCellGroupTakeOnNoValueAvailable() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.Range("A1:C3"); + + range.FormulaArrayA1 = "{1,2;3,4}"; + + Assert.AreEqual(1, ws.Cell("A1").Value); + Assert.AreEqual(2, ws.Cell("B1").Value); + Assert.AreEqual(XLError.NoValueAvailable, ws.Cell("C1").Value); + Assert.AreEqual(3, ws.Cell("A2").Value); + Assert.AreEqual(4, ws.Cell("B2").Value); + Assert.AreEqual(XLError.NoValueAvailable, ws.Cell("C2").Value); + Assert.AreEqual(XLError.NoValueAvailable, ws.Cell("A3").Value); + Assert.AreEqual(XLError.NoValueAvailable, ws.Cell("B3").Value); + Assert.AreEqual(XLError.NoValueAvailable, ws.Cell("C3").Value); + } + + [Test] + public void Array_argument_for_scalar_function_in_array_formula_uses_only_first_value_of_array() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Range("B1:B3").FormulaArrayA1 = "SIGN({-1,2,0})"; + + // Uses only -1 for all values + Assert.AreEqual(-1, ws.Cell("B1").Value); + Assert.AreEqual(-1, ws.Cell("B2").Value); + Assert.AreEqual(-1, ws.Cell("B3").Value); + } + } +} diff --git a/ClosedXML.Tests/Excel/CalcEngine/ArrayFormulaTests.cs b/ClosedXML.Tests/Excel/CalcEngine/ArrayFormulaTests.cs new file mode 100644 index 000000000..741110f10 --- /dev/null +++ b/ClosedXML.Tests/Excel/CalcEngine/ArrayFormulaTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.CalcEngine +{ + [TestFixture] + public class ArrayFormulaTests + { + [Test] + public void ArrayFormulaIsSaved() + { + TestHelper.CreateAndCompare(wb => + { + var ws = wb.AddWorksheet(); + ws.Range("A1:B2").FormulaArrayA1 = "1+2"; + }, @"Other\Formulas\ArrayFormula.xlsx"); + } + + [Test] + public void ArrayFormulaCanBeLoaded() + { + TestHelper.LoadAndAssert(wb => + { + var ws = wb.Worksheets.First(); + + foreach (var arrayFormulaCell in ws.Range("A1:B2").Cells()) + { + Assert.AreEqual("1+2", arrayFormulaCell.FormulaA1); + Assert.AreEqual("A1:B2", arrayFormulaCell.FormulaReference.ToStringRelative()); + } + + var outsideCell = ws.Cell("A3"); + Assert.IsEmpty(outsideCell.FormulaA1); + Assert.Null(outsideCell.FormulaReference); + }, @"Other\Formulas\ArrayFormula.xlsx"); + } + + [Test] + public void CanBeOnlyForOneCell() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var oneCell = ws.Cell("B3"); + + oneCell.AsRange().FormulaArrayA1 = "2+5"; + + Assert.True(oneCell.HasArrayFormula); + Assert.AreEqual("2+5", oneCell.FormulaA1); + Assert.AreEqual("B3:B3", oneCell.FormulaReference.ToStringRelative()); + } + + [TestCase("B2:C3")] + [TestCase("B2:C4")] + [TestCase("A1:D7")] + public void SettingValueToContainingRangeClearsArrayFormula(string containingRange) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var arrayFormulaRange = ws.Range("B2:C3"); + arrayFormulaRange.FormulaArrayA1 = "5"; + + ws.Range(containingRange).Value = Blank.Value; + + foreach (var cell in arrayFormulaRange.Cells()) + { + Assert.AreEqual(Blank.Value, cell.Value); + Assert.False(cell.HasArrayFormula); + Assert.IsEmpty(cell.FormulaA1); + Assert.Null(cell.FormulaReference); + } + } + + [TestCase("B2:D3")] + [TestCase("A1:E4")] + public void SettingFormulaToContainingRangeClearsOriginalArrayFormula(string overlapRange) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Range("B2:D3").FormulaArrayA1 = "1"; + + Assert.DoesNotThrow(() => ws.Range(overlapRange).FormulaArrayA1 = "2"); + } + + [TestCase("B2:B2")] + [TestCase("B2:B3")] + [TestCase("A1:C3")] + [TestCase("D2:F3")] + [TestCase("C:C")] + [TestCase("2:2")] + public void ArrayFormulaCantPartiallyOverlapWithAnotherArrayFormula(string partialOverlapRange) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Range("B2:D3").FormulaArrayA1 = "1"; + + Assert.That(() => ws.Range(partialOverlapRange).FormulaArrayA1 = "2", + Throws.TypeOf() + .With.Message.EqualTo("Can't create array function that partially covers another array function.")); + } + + [TestCase("A1:B2")] + [TestCase("A2")] + public void ArrayFormulaCantOverlapWithMergedRange(string partialOverlapRange) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Range("A1:A2").Merge(); + + Assert.That(() => ws.Range(partialOverlapRange).FormulaArrayA1 = "1", + Throws.TypeOf() + .With.Message.EqualTo("Can't create array function over a merged range.")); + } + + [TestCase("A1:B2")] + [TestCase("A1:C1")] + public void ArrayFormulaCantOverlapWithTable(string formulaRange) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = "Name"; + ws.Cell("A2").Value = 5; + ws.Range("A1:A2").CreateTable(); + + Assert.That(() => ws.Range(formulaRange).FormulaArrayA1 = "1", + Throws.TypeOf() + .With.Message.EqualTo("Can't create array function over a table.")); + } + + [Test] + public void SettingArrayFormulaInvalidatesCells() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.False(ws.Cell("A1").NeedsRecalculation); + Assert.False(ws.Cell("A2").NeedsRecalculation); + + ws.Range("A1:A2").FormulaArrayA1 = "ABS(-3)"; + + Assert.True(ws.Cell("A1").NeedsRecalculation); + Assert.True(ws.Cell("A2").NeedsRecalculation); + } + + [Test] + public void ReferencingItselfIsCircularError() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").FormulaA1 = "A2"; + ws.Range("A2").FormulaArrayA1 = "A1"; + + Assert.That(() => _ = ws.Cell("A2").Value, + Throws.TypeOf() + .With.Message.EqualTo("Formula in a cell '$Sheet1'!$A1 is part of a cycle.")); + } + } +} diff --git a/ClosedXML.Tests/Excel/CalcEngine/CalcEngineExceptionTests.cs b/ClosedXML.Tests/Excel/CalcEngine/CalcEngineExceptionTests.cs index 49d5484db..97d0af1c9 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/CalcEngineExceptionTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/CalcEngineExceptionTests.cs @@ -1,8 +1,5 @@ -using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine; -using ClosedXML.Excel.CalcEngine.Exceptions; +using ClosedXML.Excel; using NUnit.Framework; -using System; using System.Globalization; using System.Threading; @@ -20,8 +17,8 @@ public void SetCultureInfo() [Test] public void InvalidCharNumber() { - Assert.Throws(() => XLWorkbook.EvaluateExpr("CHAR(-2)")); - Assert.Throws(() => XLWorkbook.EvaluateExpr("CHAR(270)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("CHAR(-2)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("CHAR(270)")); } [Test] @@ -34,7 +31,6 @@ public void DivisionByZero() [Test] public void InvalidFunction() { - Exception ex; Assert.AreEqual(XLError.NameNotRecognized, XLWorkbook.EvaluateExpr("XXX(A1:A2)")); var ws = new XLWorkbook().AddWorksheet(); diff --git a/ClosedXML.Tests/Excel/CalcEngine/CalcEngineListenerTests.cs b/ClosedXML.Tests/Excel/CalcEngine/CalcEngineListenerTests.cs new file mode 100644 index 000000000..c6a2f8d85 --- /dev/null +++ b/ClosedXML.Tests/Excel/CalcEngine/CalcEngineListenerTests.cs @@ -0,0 +1,147 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.CalcEngine +{ + /// + /// Tests that calc engine adjusts its internal state in response to changes of workbook structure. + /// + [TestFixture] + internal class CalcEngineListenerTests + { + [Test] + public void Formulas_dependent_on_specific_sheet_are_dirty_after_sheet_addition() + { + using var wb = new XLWorkbook(); + var sutWs = wb.AddWorksheet(); + sutWs.Cell("A1").FormulaA1 = "new!A1"; + Assert.AreEqual(XLError.CellReference, sutWs.Cell("A1").Value); + + var newWs = wb.AddWorksheet("new"); + newWs.Cell("A1").Value = 5; + + // Cell contains last calculated value + Assert.AreEqual(XLError.CellReference, sutWs.Cell("A1").CachedValue); + + // But once asked for real value, it calculates it. + Assert.True(sutWs.Cell("A1").NeedsRecalculation); + Assert.AreEqual(5.0, sutWs.Cell("A1").Value); + } + + [Test] + public void Formulas_dependent_on_specific_sheet_are_dirty_after_sheet_deletion() + { + using var wb = new XLWorkbook(); + var keptWs = wb.AddWorksheet(); + var deletedWs = wb.AddWorksheet("deleted"); + + deletedWs.Cell("A1").Value = 5; + keptWs.Cell("A1").FormulaA1 = "deleted!A1"; + Assert.AreEqual(5.0, keptWs.Cell("A1").Value); + + deletedWs.Delete(); + + // Cell contains last calculated value + Assert.AreEqual(5.0, keptWs.Cell("A1").CachedValue); + + // But once asked for real value, it calculates it. + Assert.True(keptWs.Cell("A1").NeedsRecalculation); + Assert.AreEqual(XLError.CellReference, keptWs.Cell("A1").Value); + } + + [Test] + public void Formulas_are_shifted_when_area_is_added_and_cells_shifted_down() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").FormulaA1 = "B1*2"; + ws.Cell("B1").FormulaA1 = "C1*2"; + ws.Cell("C1").FormulaA1 = "1+2"; + + ws.RecalculateAllFormulas(); + + ws.Range("A1:B1").InsertRowsAbove(2); + + Assert.AreEqual(12.0, ws.Cell("A3").Value); + Assert.False(ws.Cell("A3").NeedsRecalculation); + Assert.False(ws.Cell("B3").NeedsRecalculation); + + // Dependency tree should pick up the change + ws.Cell("C1").FormulaA1 = "2+2"; + Assert.True(ws.Cell("A3").NeedsRecalculation); + Assert.True(ws.Cell("B3").NeedsRecalculation); + Assert.AreEqual(16.0, ws.Cell("A3").Value); + } + + [Test] + public void Formulas_are_shifted_when_area_is_added_and_cells_shifted_right() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").FormulaA1 = "A2*2"; + ws.Cell("A2").FormulaA1 = "A3*2"; + ws.Cell("A3").FormulaA1 = "1+2"; + + ws.RecalculateAllFormulas(); + + ws.Cell("A2").InsertCellsBefore(4); + + Assert.AreEqual(12.0, ws.Cell("A1").Value); + Assert.False(ws.Cell("E2").NeedsRecalculation); + + // Dependency tree should pick up the change + ws.Cell("A3").FormulaA1 = "2+2"; + Assert.True(ws.Cell("E2").NeedsRecalculation); + Assert.True(ws.Cell("A1").NeedsRecalculation); + Assert.AreEqual(16.0, ws.Cell("A1").Value); + } + + [Test] + public void Formulas_are_shifted_when_area_is_deleted_and_cells_shifted_up() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A5").FormulaA1 = "1+2"; + ws.Cell("B5").FormulaA1 = "A5*2"; + ws.Cell("C5").FormulaA1 = "B5*2"; + + ws.RecalculateAllFormulas(); + + ws.Range("B2:C4").Delete(XLShiftDeletedCells.ShiftCellsUp); + + Assert.AreEqual(12.0, ws.Cell("C2").Value); + Assert.False(ws.Cell("B2").NeedsRecalculation); + Assert.False(ws.Cell("A2").NeedsRecalculation); + + // Dependency tree should pick up the change + ws.Cell("A5").FormulaA1 = "2+2"; + Assert.True(ws.Cell("B2").NeedsRecalculation); + Assert.True(ws.Cell("C2").NeedsRecalculation); + Assert.AreEqual(16.0, ws.Cell("C2").Value); + } + + [Test] + public void Formulas_are_shifted_when_area_is_deleted_and_cells_shifted_left() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("D1").FormulaA1 = "1+2"; + ws.Cell("E2").FormulaA1 = "D1*2"; + ws.Cell("D3").FormulaA1 = "E2*2"; + + ws.RecalculateAllFormulas(); + + ws.Range("A1:C5").Delete(XLShiftDeletedCells.ShiftCellsLeft); + + Assert.AreEqual(12.0, ws.Cell("A3").Value); + Assert.False(ws.Cell("B2").NeedsRecalculation); + Assert.False(ws.Cell("A1").NeedsRecalculation); + + // Dependency tree should pick up the change + ws.Cell("A1").FormulaA1 = "2+2"; + Assert.True(ws.Cell("B2").NeedsRecalculation); + Assert.True(ws.Cell("A3").NeedsRecalculation); + Assert.AreEqual(16.0, ws.Cell("A3").Value); + } + } +} diff --git a/ClosedXML.Tests/Excel/CalcEngine/CompareOperatorsTests.cs b/ClosedXML.Tests/Excel/CalcEngine/CompareOperatorsTests.cs index 3ec503c0e..e6066fda6 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/CompareOperatorsTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/CompareOperatorsTests.cs @@ -1,5 +1,4 @@ -using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine; +using ClosedXML.Excel; using NUnit.Framework; namespace ClosedXML.Tests.Excel.CalcEngine @@ -151,10 +150,10 @@ public void Comparison_TextIsAlwaysGreaterThanAnyNumber(string formula, bool exp [TestCase("A1=\"\"")] public void Comparison_BlankIsEqualToFalseOrZeroOrEmptyString(string formula) { - Assert.That(Evaluate(formula), Is.True); + Assert.AreEqual(true, Evaluate(formula)); } - private static object Evaluate(string formula) + private static XLCellValue Evaluate(string formula) { using var wb = new XLWorkbook(); var ws = wb.AddWorksheet(); diff --git a/ClosedXML.Tests/Excel/CalcEngine/CriteriaTests.cs b/ClosedXML.Tests/Excel/CalcEngine/CriteriaTests.cs new file mode 100644 index 000000000..0fd3f0599 --- /dev/null +++ b/ClosedXML.Tests/Excel/CalcEngine/CriteriaTests.cs @@ -0,0 +1,231 @@ +using System.Collections.Generic; +using System.Globalization; +using ClosedXML.Excel; +using ClosedXML.Excel.CalcEngine; +using ClosedXML.Excel.CalcEngine.Functions; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.CalcEngine; + +[TestFixture] +internal class CriteriaTests +{ + [Test] + [SetCulture("cs-CZ")] // cs-CZ has ',' as a decimal separator (e.g. '1,2' is one point two). + [TestCaseSource(nameof(CriteriaTestCases))] + public void Selection_criteria_uses_type_and_comparator_to_match_values(ScalarValue selectionCriteria, XLCellValue value, bool expectedResult) + { + var criteria = Criteria.Create(selectionCriteria, CultureInfo.CurrentCulture); + var matchResult = criteria.Match(value); + Assert.AreEqual(expectedResult, matchResult); + + // TallyCriteria skips unused (=blank) cells as an optimization (e.g. SUMIF over whole column/sheet), + // unless it's possible that blanks will match the criteria. Assert that when tested value matches and + // is blank, teh TallyCriteria will include blank cells. + if (matchResult && value.IsBlank) + Assert.True(criteria.CanBlankValueMatch); + } + + public static IEnumerable CriteriaTestCases + { + get + { + // These test cases were checked with SUMIF function, Excel 2022, english language. + + // Real blank is interpreted as number 0. Criteria with a real blank value has to be + // passed as an argument through cell reference or SUMIF(A1:A5, IF(TRUE,), B1:B5). + yield return S(ScalarValue.Blank, 0); + yield return S(ScalarValue.Blank, "0 0/2"); + yield return F(ScalarValue.Blank, Blank.Value); + yield return F(ScalarValue.Blank, ""); + + // Criteria of a empty string behaves matches only empty string or blank. + yield return S("", Blank.Value); + yield return S("", ""); + yield return F("", 0); + yield return F(" ", ""); + yield return F(" ", 0); + + // Blank with equal op is interpreted as blank and type checked. + yield return S("=", Blank.Value); + yield return F("=", 0); + yield return F("=", "0 0/2"); + yield return F("=", ""); + + // Blank with not equal is interpreted as anything but blank value + yield return F("<>", Blank.Value); + yield return S("<>", 0); + yield return S("<>", "0 0/2"); + yield return S("<>", ""); + + // Any comparison with blank always return false, like NaN. + foreach (var cmp in new[] { "<", "<=", ">=", ">" }) + { + yield return F(cmp, Blank.Value); + yield return F(cmp, 0); + yield return F(cmp, "0 0/2"); + yield return F(cmp, ""); + } + + // Logical are compared by type and value + foreach (var eq in new[] { "", "=" }) + { + yield return S(eq + "TRUE", true); + yield return S(eq + "true", true); + yield return F(eq + "TRUE", "TRUE"); + yield return F(eq + "TRUE", 1); + yield return S(eq + "FALSE", false); + yield return S(eq + "false", false); + yield return F(eq + "FALSE", "FALSE"); + yield return F(eq + "FALSE", 0); + yield return F(eq + "FALSE", Blank.Value); + } + + yield return S("<>TRUE", false); + yield return S("<>TRUE", 1); + yield return S("<>TRUE", "Text"); + yield return S("<>TRUE", XLError.DivisionByZero); + yield return F("<>TRUE", true); + + yield return S(">FALSE", true); + yield return F(">FALSE", false); + yield return F(">TRUE", true); + + yield return S(">=FALSE", true); + yield return S(">=FALSE", false); + + yield return S("<=FALSE", false); + yield return F("<=FALSE", true); + + yield return S("1", 0.9); + yield return F("<>1", 1); + yield return F("<>,5", "0 1/2"); + yield return S("<>1", Blank.Value); + yield return S("<>1", true); + yield return S("<>1", false); + yield return S("<>1", "text"); + yield return S("<>1", XLError.NullValue); + + yield return F("<1", 1); + yield return S("<=1", 1); + foreach (var lt in new[] { "<", "<=" }) + { + yield return S(lt + "1", 0); + yield return S(lt + "1", 0.9); + yield return S(lt + "0,5", "0,4"); + yield return S(lt + "24:00", "0 1/2"); + yield return F(lt + "24:00", "0 3/2"); + yield return F(lt + "1", "text"); + yield return F(lt + "1", ""); + yield return F(lt + "1", false); + } + + yield return F(">1", 1); + yield return S(">=1", 1); + foreach (var gt in new[] { ">", ">=" }) + { + yield return S(gt + "1", 2); + yield return S(gt + "1", 1.1); + yield return S(gt + "0,5", "0,6"); + yield return S(gt + "24:00", "1 1/2"); + yield return F(gt + "24:00", "0 1/2"); + yield return F(gt + "1", "text"); + yield return F(gt + "1", ""); + yield return F(gt + "0", true); + } + + // Text for equals is a wildcard + foreach (var eq in new[] { "", "=" }) + { + yield return S(eq + "abc", "abc"); + yield return F(eq + "ab", "abc"); + yield return S(eq + "AbC", "aBc"); + yield return S(eq + "?", "a"); + yield return S(eq + "?", "1"); + yield return F(eq + "?", "ab"); + yield return S(eq + "a?", "ab"); + + // Fail for other types + yield return F(eq + "?", 1); + } + + // Not equal matches with the text and then inverts the result. + yield return F("<>?", "a"); + yield return F("<>?", "b"); + yield return S("<>?", "ab"); + yield return S("<>?", 1); + yield return S("<>?", true); + yield return S("<>B", Blank.Value); + + // Text comparison are culture dependent and don't use wildcards + // In Czech, order of letters is 'h', 'ch', 'i' (yes, there is a two grapheme letter). + yield return F("a", "a"); + yield return S(">=a", "a"); + foreach (var gt in new[] { ">", ">=" }) + { + yield return S(gt + "a", "b"); + yield return F(gt + "?", "!"); + yield return S(gt + "?", "a"); + yield return S(gt + "ch", "i"); // 'i' > 'ch' = true. + yield return F(gt + "ch", "h"); // 'h' > 'ch' = false + } + + // Errors + foreach (var eq in new[] { "", "=" }) + { + yield return S(eq + "#DIV/0!", XLError.DivisionByZero); + yield return F(eq + "#DIV/0!", "#DIV/0!"); + yield return F(eq + "#NULL!", 1); + } + + yield return S(">#NULL!", XLError.DivisionByZero); + yield return F(">#DIV/0!", XLError.NullValue); + + yield return S(">=#NULL!", XLError.DivisionByZero); + yield return S(">=#NULL!", XLError.NullValue); + yield return F(">=#DIV/0!", XLError.NullValue); + + yield return S("<=#DIV/0!", XLError.NullValue); + yield return S("<=#NULL!", XLError.NullValue); + yield return F("<=#NULL!", XLError.DivisionByZero); + + yield return S("<#DIV/0!", XLError.NullValue); + yield return F("<#NULL!", XLError.DivisionByZero); + + yield break; + + static object[] S(ScalarValue s, XLCellValue v) + => new object[] { s, v, true }; + + static object[] F(ScalarValue s, XLCellValue v) + => new object[] { s, v, false }; + } + } +} diff --git a/ClosedXML.Tests/Excel/CalcEngine/DateAndTimeTests.cs b/ClosedXML.Tests/Excel/CalcEngine/DateAndTimeTests.cs index 65739d9fe..55dcddf6a 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/DateAndTimeTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/DateAndTimeTests.cs @@ -1,43 +1,72 @@ using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine.Exceptions; using NUnit.Framework; using System; -using System.Globalization; -using System.Threading; -namespace ClosedXML.Tests.Excel.DataValidations +namespace ClosedXML.Tests.Excel.CalcEngine { [TestFixture] + [SetCulture("en-US")] public class DateAndTimeTests { - [SetUp] - public void SetCultureInfo() - { - Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US"); - } - - [Test] - public void Date() - { - Object actual; - - actual = XLWorkbook.EvaluateExpr("Date(2008, 1, 1)"); - Assert.AreEqual(39448, actual); - - actual = XLWorkbook.EvaluateExpr("Date(2008, 15, 1)"); - Assert.AreEqual(39873, actual); - - actual = XLWorkbook.EvaluateExpr("Date(2008, -50, 1)"); - Assert.AreEqual(37895, actual); - - actual = XLWorkbook.EvaluateExpr("Date(2008, 5, 63)"); - Assert.AreEqual(39631, actual); - - actual = XLWorkbook.EvaluateExpr("Date(2008, 13, 63)"); - Assert.AreEqual(39876, actual); - - actual = XLWorkbook.EvaluateExpr("Date(2008, 15, -120)"); - Assert.AreEqual(39752, actual); + [TestCase(2008, 1, 1, ExpectedResult = 39448)] + [TestCase(2008, 15, 1, ExpectedResult = 39873)] + [TestCase(2008, -50, 1, ExpectedResult = 37895)] + [TestCase(2008, 5, 63, ExpectedResult = 39631)] + [TestCase(2008, 13, 63, ExpectedResult = 39876)] + [TestCase(2008, 15, -120, ExpectedResult = 39752)] + [TestCase(1900, 2, 29, ExpectedResult = 60)] // Loveable 29th feb 1900 + [TestCase(1900, 2, 28, ExpectedResult = 59)] + [TestCase(1900, 1, 1, ExpectedResult = 1)] + [TestCase(1900, 1, 0, ExpectedResult = 0)] // Excel formats it as 1900-01-00, but more like 1899-12-31 + [TestCase(1899, 1, 1, ExpectedResult = 693598)] // If year < 1900, add 1900 to it + public double Date_returns_serial_date(int year, int month, int day) + { + return XLWorkbook.EvaluateExpr($"DATE({year},{month},{day})").GetNumber(); + } + + [TestCase(1900, 1, -1)] // Serial date -1, below 0 + [TestCase(9999, 12, 32)] + public void Date_returns_error_when_result_outside_base_date_to_max_date_of_calendar_system(int year, int month, int day) + { + var actual = XLWorkbook.EvaluateExpr($"DATE({year},{month},{day})"); + Assert.AreEqual(XLError.NumberInvalid, actual); + } + + [TestCase(-1, 32000, 1, ExpectedResult = 973586)] // Year -1.1 behaves as -2 + [TestCase(-1.1, 32000, 1, ExpectedResult = 973221)] + [TestCase(-2, 32000, 1, ExpectedResult = 973221)] + [TestCase(2000, -5, 1, ExpectedResult = 36342)] // Month -5.1 behaves as -6 + [TestCase(2000, -5.1, 1, ExpectedResult = 36312)] + [TestCase(2000, -6, 1, ExpectedResult = 36312)] + [TestCase(2000, 2, -10, ExpectedResult = 36546)] // Day -10.1 behaves as -11 + [TestCase(2000, 2, -10.1, ExpectedResult = 36545)] + [TestCase(2000, 2, -11, ExpectedResult = 36545)] + public double Date_floors_arguments(double year, double month, double day) + { + return XLWorkbook.EvaluateExpr($"DATE({year},{month},{day})").GetNumber(); + } + + [TestCase(10000, -32767, 3, "7269-05-03")] // Month can be [-32767..32767) + [TestCase(10000, -32767.1, 3, XLError.NumberInvalid)] + [TestCase(2000, 32766.9, 1, "4730-06-01")] + [TestCase(2000, 32767, 1, XLError.NumberInvalid)] + [TestCase(2000, 1, 32767.9, "2089-09-16")] // Day is clamped to at most 32767 + [TestCase(2000, 1, 32768, "2089-09-16")] + [TestCase(2000, 1, 1E+100, "2089-09-16")] + [TestCase(2000, 1, -32768, "1910-04-14")] // When day is < -32768, day uses 32767 value instead + [TestCase(2000, 1, -32768.1, "2089-09-16")] + [TestCase(2000, 1, -1E+100, "2089-09-16")] + [TestCase(10000, -32000, 1, "7333-04-01")] // Year is clamped to 10000 + [TestCase(10001, -32000, 1, "7333-04-01")] + [TestCase(1E+100, -32000, 1, "7333-04-01")] + [TestCase(-1E+100, 1, 1, XLError.NumberInvalid)] // Even if year is less than int.MinValue, there is no error + public void Date_matches_excel_behavior_for_out_of_range_arguments(double year, double month, double day, object expectedResult) + { + if (expectedResult is string iso8601) + expectedResult = DateTime.Parse(iso8601).ToSerialDateTime(); + + var actual = XLWorkbook.EvaluateExpr($"DATE({year},{month},{day})"); + Assert.AreEqual(expectedResult, actual); } [TestCase("1/1/2006", "12/12/2010", "Y", ExpectedResult = 4)] @@ -50,403 +79,817 @@ public void Date() [TestCase(38718, 40524, "M", ExpectedResult = 59)] [TestCase(38718, 40524, "D", ExpectedResult = 1806)] [TestCase(38718, 40524, "MD", ExpectedResult = 11)] + [TestCase("2020-01-31", "2024-03-01", "MD", ExpectedResult = -1)] // Pathological case. Start is shifted to 2024-02-31, thus 2024-03-02 is one day before the end + [TestCase("1990-01-20", "2002-12-15", "YM", ExpectedResult = 10)] // YM across many years [TestCase(38718, 40524, "YM", ExpectedResult = 11)] [TestCase(38718, 40524, "YD", ExpectedResult = 345)] - [TestCase("2001-12-31", "2002-4-15", "YM", ExpectedResult = 3)] - [TestCase("2001-12-10", "2002-4-15", "YM", ExpectedResult = 4)] - [TestCase("2001-12-15", "2002-4-15", "YM", ExpectedResult = 4)] - [TestCase("2001-12-31", "2002-4-15", "YD", ExpectedResult = 105)] - [TestCase("2001-12-31", "2003-4-15", "YD", ExpectedResult = 105)] + [TestCase("2001-12-31", "2002-4-15", "YM", ExpectedResult = 3)] // YM counts only full months - the last month is not full + [TestCase("2001-12-10", "2002-4-15", "YM", ExpectedResult = 4)] // YM counts only full months - the last month is full + [TestCase("2001-12-15", "2002-4-15", "YM", ExpectedResult = 4)] // YM counts only full months - the last month exactly full + [TestCase("1900-01-12", "1901-03-04", "YD", ExpectedResult = 51)] // YD has plus +1 error with start dates in jan/feb 1900 and end in march of subsequent years + [TestCase("2001-12-31", "2002-4-15", "YD", ExpectedResult = 105)] // YD ignores year, baseline + [TestCase("2001-12-31", "2003-4-15", "YD", ExpectedResult = 105)] // YD ignores year, different year + [TestCase("2000-02-20", "2100-02-10", "YD", ExpectedResult = 356)] // YD uses start year, not end year. Start has feb29, baseline + [TestCase("2001-02-20", "2100-02-10", "YD", ExpectedResult = 355)] // YD uses start year, not end year. Start doesn't have feb29 => it's one less than the baseline [TestCase("2002-01-31", "2002-4-15", "YD", ExpectedResult = 74)] [TestCase("2001-12-02", "2001-12-15", "Y", ExpectedResult = 0)] [TestCase("2001-12-02", "2002-12-02", "Y", ExpectedResult = 1)] [TestCase("2006-01-15", "2006-03-14", "M", ExpectedResult = 1)] [TestCase("2020-11-22", "2020-11-23 9:00", "D", ExpectedResult = 1)] - public double Datedif(object startDate, object endDate, string unit) + public double DateDif(object startDate, object endDate, string unit) { if (startDate is string s1) startDate = $"\"{s1}\""; if (endDate is string s2) endDate = $"\"{s2}\""; - return (double)XLWorkbook.EvaluateExpr($"DATEDIF({startDate}, {endDate}, \"{unit}\")"); + return (double)XLWorkbook.EvaluateExpr($"DATEDIF({startDate},{endDate},\"{unit}\")"); } - [TestCase("\"1/1/2010\"", "\"12/12/2006\"", "Y")] - [TestCase(40524, 38718, "Y")] - [TestCase("\"1/1/2006\"", "\"12/12/2010\"", "N")] - [TestCase(38718, 40524, "N")] - public void DatedifExceptions(object startDate, object endDate, string unit) + [TestCase("N")] + public void DateDif_returns_number_error_on_unexpected_unit(string unit) { - Assert.Throws(() => XLWorkbook.EvaluateExpr($"DATEDIF({startDate}, {endDate}, \"{unit}\")")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"DATEDIF(10,100,\"{unit}\")")); } [Test] - public void Datevalue() + public void DateDif_end_date_cant_be_after_start_date() { - Object actual = XLWorkbook.EvaluateExpr("DateValue(\"8/22/2008\")"); - Assert.AreEqual(39682, actual); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("DATEDIF(40524,38718,\"D\")")); } - [Test] - public void Day() + [TestCase(-0.1, 100)] + [TestCase(1, 2958466)] + public void DateDif_returns_number_error_on_date_out_of_date_system(decimal startDate, decimal endDate) { - Object actual = XLWorkbook.EvaluateExpr("Day(\"8/22/2008\")"); - Assert.AreEqual(22, actual); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"DATEDIF({startDate},{endDate},\"D\")")); } - [Test] - public void Days() + [TestCase("8/22/2008", ExpectedResult = 39682)] + [TestCase("2/1/2006", ExpectedResult = 38749)] + [TestCase("2006-2-1", ExpectedResult = 38749)] + [TestCase("22-MAY-2011", ExpectedResult = 40685)] + [TestCase("February 1, 2006 17:45", ExpectedResult = 38749)] + public double DateValue_returns_truncated_serial_date_extracted_from_text(string date) { - Object actual = XLWorkbook.EvaluateExpr("DAYS(DATE(2016,10,1),DATE(1992,2,29))"); - Assert.AreEqual(8981, actual); - - actual = XLWorkbook.EvaluateExpr("DAYS(\"2016-10-1\",\"1992-2-29\")"); - Assert.AreEqual(8981, actual); + return (double)XLWorkbook.EvaluateExprCurrent($"DATEVALUE(\"{date}\")"); } [Test] - public void DayWithDifferentCulture() + public void DateValue_returns_truncated_serial_date_using_current_year() { - CultureInfo ci = new CultureInfo(CultureInfo.InvariantCulture.LCID); - ci.DateTimeFormat.ShortDatePattern = "dd/MM/yyyy"; - Thread.CurrentThread.CurrentCulture = ci; - Object actual = XLWorkbook.EvaluateExpr("Day(\"1/6/2008\")"); - Assert.AreEqual(1, actual); + // If year isn't provided in string, it should parse as "current year" + double actual = (double)XLWorkbook.EvaluateExpr("DATEVALUE(\"5-JUL\")"); + double expected = new DateTime(DateTime.Now.Year, 7, 5).ToOADate(); + Assert.AreEqual(expected, actual); } - [Test] - public void Days360_Default() + [TestCase("\"100\"")] + [TestCase("\"0\"")] + public void DateValue_doesnt_coerce_number_in_a_text_to_a_date(string arg) { - Object actual = XLWorkbook.EvaluateExpr("Days360(\"1/30/2008\", \"2/1/2008\")"); - Assert.AreEqual(1, actual); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExprCurrent($"DATEVALUE({arg})")); } - [Test] - public void Days360_Europe1() + [TestCase("TRUE")] + [TestCase("FALSE")] + [TestCase("1000")] + [TestCase("DATE(2006,1,5)")] + public void DateValue_returns_coercion_error_on_non_text(string arg) { - Object actual = XLWorkbook.EvaluateExpr("DAYS360(\"1/1/2008\", \"3/31/2008\",TRUE)"); - Assert.AreEqual(89, actual); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExprCurrent($"DATEVALUE({arg})")); } [Test] - public void Days360_Europe2() + public void DateValue_propagates_error() { - Object actual = XLWorkbook.EvaluateExpr("DAYS360(\"3/31/2008\", \"1/1/2008\",TRUE)"); - Assert.AreEqual(-89, actual); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExprCurrent("DATEVALUE(#DIV/0!)")); } - [Test] - public void Days360_US1() + [TestCase(0, ExpectedResult = 0)] + [TestCase(0.5, ExpectedResult = 0)] + [TestCase(1, ExpectedResult = 1)] + [TestCase(31, ExpectedResult = 31)] + [TestCase(32, ExpectedResult = 1)] + [TestCase(59, ExpectedResult = 28)] + [TestCase(60, ExpectedResult = 29)] + [TestCase(61, ExpectedResult = 1)] + [TestCase(30000, ExpectedResult = 18)] + [TestCase(45718, ExpectedResult = 2)] + public double Day_returns_day_of_a_month_for_serial_culture(double serialDate) { - Object actual = XLWorkbook.EvaluateExpr("DAYS360(\"1/1/2008\", \"3/31/2008\",FALSE)"); - Assert.AreEqual(90, actual); + return XLWorkbook.EvaluateExpr($"DAY({serialDate})").GetNumber(); } - [Test] - public void Days360_US2() + [TestCase("\"8/22/2008\"", ExpectedResult = 22)] + [TestCase("\"1/2/2006 10:45 AM\"", ExpectedResult = 2)] + [TestCase("\"367\"", ExpectedResult = 1)] + [TestCase("IF(TRUE,)", ExpectedResult = 0)] // Blank + [TestCase("TRUE", ExpectedResult = 1)] + [TestCase("FALSE", ExpectedResult = 0)] + public double Day_accepts_non_number_values(string value) { - Object actual = XLWorkbook.EvaluateExpr("DAYS360(\"3/31/2008\", \"1/1/2008\",FALSE)"); - Assert.AreEqual(-89, actual); + return XLWorkbook.EvaluateExpr($"DAY({value})").GetNumber(); } [Test] - public void EDate_Negative1() + [Ignore("Excel accepts this but ClosedXML does not yet")] + public void Day_accepts_missing_year_and_substitutes_current_year() { - Object actual = XLWorkbook.EvaluateExpr("EDate(\"3/1/2008\", -1)"); - Assert.AreEqual(new DateTime(2008, 2, 1).ToOADate(), actual); + // Test providing just month and day, which should fill the year as "current year" + double actual = XLWorkbook.EvaluateExpr("DAY(\"8/22\")").GetNumber(); + Assert.AreEqual(22, actual); } [Test] - public void EDate_Negative2() + public void Day_only_accepts_serial_date_from_0_to_upper_limit_of_calendar_system() { - Object actual = XLWorkbook.EvaluateExpr("EDate(\"3/31/2008\", -1)"); - Assert.AreEqual(new DateTime(2008, 2, 29).ToOADate(), actual); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("DAY(-0.1)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("DAY(DATE(9999,12,31)+1)")); } - [Test] - public void EDate_Positive1() + [SetCulture("eu-ES")] + [TestCase("\"2006/1/2 10:45 AM\"", ExpectedResult = 2)] + [TestCase("DATE(2006,1,2)", ExpectedResult = 2)] + [TestCase("DATE(2006,0,2)", ExpectedResult = 2)] + [TestCase("DATE(2013,9,0)", ExpectedResult = 31)] + public double Day_examples(string date) { - Object actual = XLWorkbook.EvaluateExpr("EDate(\"3/1/2008\", 1)"); - Assert.AreEqual(new DateTime(2008, 4, 1).ToOADate(), actual); + return XLWorkbook.EvaluateExprCurrent($"DAY({date})").GetNumber(); } - [Test] - public void EDate_Positive2() + [TestCase(2016, 10, 1, 1992, 2, 29, ExpectedResult = 8981)] + [TestCase(1901, 3, 10, 1900, 1, 26, ExpectedResult = 409)] + public double Days_calculate_difference_between_two_dates(double endYear, double endMonth, double endDay, double startYear, double startMonth, double startDay) { - Object actual = XLWorkbook.EvaluateExpr("EDate(\"3/31/2008\", 1)"); - Assert.AreEqual(new DateTime(2008, 4, 30).ToOADate(), actual); + return (double)XLWorkbook.EvaluateExpr($"DAYS(DATE({endYear},{endMonth},{endDay}),DATE({startYear},{startMonth},{startDay}))"); } - [Test] - public void EOMonth_Negative() + [TestCase("2016-10-01", "1992-02-29", ExpectedResult = 8981)] + [TestCase("1901-03-10", "1900-01-26", ExpectedResult = 409)] + [TestCase("1900-01-26", "1901-03-10", ExpectedResult = -409)] + public double Days_coerces_dates_to_number(string endDate, string startDate) { - Object actual = XLWorkbook.EvaluateExpr("EOMonth(\"3/1/2008\", -1)"); - Assert.AreEqual(new DateTime(2008, 2, 29).ToOADate(), actual); + return (double)XLWorkbook.EvaluateExpr($"DAYS(\"{endDate}\",\"{startDate}\")"); } [Test] - public void EOMonth_Positive() + public void Days_truncates_passed_arguments() { - Object actual = XLWorkbook.EvaluateExpr("EOMonth(\"3/31/2008\", 1)"); - Assert.AreEqual(new DateTime(2008, 4, 30).ToOADate(), actual); + Assert.AreEqual(9, XLWorkbook.EvaluateExpr("DAYS(10.6,1.9)")); } [Test] - public void Hour() + public void Days_arguments_must_be_in_date_range() { - Object actual = XLWorkbook.EvaluateExpr("Hour(\"8/22/2008 3:30:45 PM\")"); - Assert.AreEqual(15, actual); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("DAYS(-0.1,1)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("DAYS(2958466,1)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("DAYS(1,-0.1)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("DAYS(1,2958466)")); } [Test] - public void Minute() + public void Days360_uses_US_method_by_default() { - Object actual = XLWorkbook.EvaluateExpr("Minute(\"8/22/2008 3:30:45 AM\")"); - Assert.AreEqual(30, actual); + const string formulaFormat = "DAYS360(DATE(2002,2,3),DATE(2005,5,31){0})"; + var defaultResult = XLWorkbook.EvaluateExpr(string.Format(formulaFormat, string.Empty)); + var usResult = XLWorkbook.EvaluateExpr(string.Format(formulaFormat, ",FALSE")); + var euResult = XLWorkbook.EvaluateExpr(string.Format(formulaFormat, ",TRUE")); + Assert.AreEqual(1198, defaultResult); + Assert.AreEqual(usResult, defaultResult); + Assert.AreNotEqual(euResult, defaultResult); } [Test] - public void Month() + public void Days360_Europe1() { - Object actual = XLWorkbook.EvaluateExpr("Month(\"8/22/2008\")"); - Assert.AreEqual(8, actual); + var actual = XLWorkbook.EvaluateExpr("DAYS360(\"1/1/2008\", \"3/31/2008\",TRUE)"); + Assert.AreEqual(89, actual); } [Test] - public void IsoWeekNum() + public void Days360_Europe2() { - Object actual = XLWorkbook.EvaluateExpr("ISOWEEKNUM(DATEVALUE(\"2012-3-9\"))"); - Assert.AreEqual(10, actual); - - actual = XLWorkbook.EvaluateExpr("ISOWEEKNUM(DATE(2012,12,31))"); - Assert.AreEqual(1, actual); + var actual = XLWorkbook.EvaluateExpr("DAYS360(\"3/31/2008\", \"1/1/2008\",TRUE)"); + Assert.AreEqual(-89, actual); } - [Test] - public void Networkdays_MultipleHolidaysGiven() + [TestCase(2002, 2, 3, 2005, 5, 31, ExpectedResult = 1198)] + [TestCase(2005, 5, 31, 2002, 2, 3, ExpectedResult = -1197)] + [TestCase(2008, 1, 1, 2008, 3, 31, ExpectedResult = 90)] + [TestCase(2008, 3, 31, 2008, 1, 1, ExpectedResult = -89)] + [TestCase(2020, 2, 29, 2021, 2, 28, ExpectedResult = 358)] + [TestCase(2020, 5, 29, 2020, 4, 1, ExpectedResult = -58)] + [TestCase(2020, 5, 29, 2020, 3, 31, ExpectedResult = -58)] + [TestCase(2020, 5, 30, 2020, 4, 1, ExpectedResult = -59)] + [TestCase(2020, 5, 30, 2020, 3, 31, ExpectedResult = -60)] + [TestCase(2020, 5, 30, 2020, 3, 30, ExpectedResult = -60)] + public double Days360_US_method(int startYear, int startMonth, int startDay, int endYear, int endMonth, int endDay) { - var wb = new XLWorkbook(); - IXLWorksheet ws = wb.AddWorksheet("Sheet1"); - ws.FirstCell().SetValue("Date") - .CellBelow().SetValue(new DateTime(2008, 10, 1)) - .CellBelow().SetValue(new DateTime(2009, 3, 1)) - .CellBelow().SetValue(new DateTime(2008, 11, 26)) - .CellBelow().SetValue(new DateTime(2008, 12, 4)) - .CellBelow().SetValue(new DateTime(2009, 1, 21)); - Object actual = ws.Evaluate("Networkdays(A2,A3,A4:A6)"); - Assert.AreEqual(105, actual); + return (double)XLWorkbook.EvaluateExpr($"DAYS360(DATE({startYear},{startMonth},{startDay}),DATE({endYear},{endMonth},{endDay}),FALSE)"); } - [Test] - public void Networkdays_NoHolidaysGiven() + [TestCase(1900, 2, 27, 1900, 2, 27, ExpectedResult = 0)] + [TestCase(1900, 2, 27, 1900, 2, 28, ExpectedResult = 1)] + [TestCase(1900, 2, 27, 1900, 2, 29, ExpectedResult = 2)] + [TestCase(1900, 2, 27, 1900, 3, 1, ExpectedResult = 4)] + [TestCase(1900, 2, 28, 1900, 2, 27, ExpectedResult = -1)] + [TestCase(1900, 2, 28, 1900, 2, 28, ExpectedResult = 0)] + [TestCase(1900, 2, 28, 1900, 2, 29, ExpectedResult = 1)] + [TestCase(1900, 2, 28, 1900, 3, 1, ExpectedResult = 3)] + [TestCase(1900, 2, 29, 1900, 2, 27, ExpectedResult = -3)] + [TestCase(1900, 2, 29, 1900, 2, 28, ExpectedResult = -2)] + [TestCase(1900, 2, 29, 1900, 2, 29, ExpectedResult = -1)] + [TestCase(1900, 2, 29, 1900, 3, 1, ExpectedResult = 1)] + [TestCase(1900, 3, 1, 1900, 2, 27, ExpectedResult = -4)] + [TestCase(1900, 3, 1, 1900, 2, 28, ExpectedResult = -3)] + [TestCase(1900, 3, 1, 1900, 2, 29, ExpectedResult = -2)] + [TestCase(1900, 3, 1, 1900, 3, 1, ExpectedResult = 0)] + public double Days360_US_method_for_feb_29_1900(int startYear, int startMonth, int startDay, int endYear, int endMonth, int endDay) { - Object actual = XLWorkbook.EvaluateExpr("Networkdays(\"10/01/2008\", \"3/01/2009\")"); - Assert.AreEqual(108, actual); + return (double)XLWorkbook.EvaluateExpr($"DAYS360(DATE({startYear},{startMonth},{startDay}),DATE({endYear},{endMonth},{endDay}),FALSE)"); } - [Test] - public void Networkdays_NegativeResult() + [TestCase("2008-03-01", -1, "2008-02-01")] + [TestCase("2008-03-31", -1, "2008-02-29")] + [TestCase("2008-03-01", 1, "2008-04-01")] + [TestCase("2008-03-31", 1, "2008-04-30")] + [TestCase("2008-03-01", -1, "2008-02-01")] + [TestCase("2008-03-31", 1, "2008-04-30")] + [TestCase("1900-01-31", 1, "1900-02-28")] // Uses correct FEB28 + [TestCase("1900-01-31", 2, "1900-03-31")] + [TestCase("1983-07-31", -77, "1977-02-28")] + [TestCase("2021-05-14", 35, "2024-04-14")] + public void EDate_returns_end_date_from_start_date_and_month_offset(string startDate, double monthOffset, string expectedEndDate) { - Object actual = XLWorkbook.EvaluateExpr("Networkdays(\"3/01/2009\", \"10/01/2008\")"); - Assert.AreEqual(-108, actual); - - actual = XLWorkbook.EvaluateExpr("Networkdays(\"2016-01-01\", \"2015-12-23\")"); - Assert.AreEqual(-8, actual); + var actual = XLWorkbook.EvaluateExpr($"EDATE(\"{startDate}\",{monthOffset})"); + Assert.AreEqual(DateTime.Parse(expectedEndDate).ToSerialDateTime(), actual); } [Test] - public void Networkdays_OneHolidaysGiven() + public void EDate_returns_number_error_for_non_date_values() { - Object actual = XLWorkbook.EvaluateExpr("Networkdays(\"10/01/2008\", \"3/01/2009\", \"11/26/2008\")"); - Assert.AreEqual(107, actual); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("EDATE(-0.1,0)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("EDATE(2958466,0)")); } - [Test] - public void Second() + [TestCase("1900-01-01", -1)] + [TestCase("9999-07-10", 6)] + [TestCase("9999-07-10", 1E+100)] + public void EDate_returns_number_error_when_end_date_is_out_of_date_system(string startDate, double monthOffset) { - Object actual = XLWorkbook.EvaluateExpr("Second(\"8/22/2008 3:30:45 AM\")"); - Assert.AreEqual(45, actual); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"EDATE(\"{startDate}\",{monthOffset})")); } - [Test] - public void Time() + [TestCase(1900, 1, 0, 0, ExpectedResult = 31)] + [TestCase(1900, 1, 1, 0, ExpectedResult = 31)] + [TestCase(1900, 1, 31, 0, ExpectedResult = 31)] + [TestCase(1900, 2, 20, 0, ExpectedResult = 59)] + [TestCase(1900, 2, 29, 0, ExpectedResult = 59)] + [TestCase(1900, 2, 29, 1, ExpectedResult = 91)] + [TestCase(1900, 2, 29, 1, ExpectedResult = 91)] + [TestCase(1900, 3, 1, -1, ExpectedResult = 59)] + [TestCase(1985, 4, 15, 9, ExpectedResult = 31443)] + [TestCase(2006, 1, 31, 5, ExpectedResult = 38898)] // Spec examples + [TestCase(2004, 2, 29, 12, ExpectedResult = 38411)] + [TestCase(2004, 2, 28, 12, ExpectedResult = 38411)] + [TestCase(2004, 1, 15, -23, ExpectedResult = 37315)] + public double Eomonth_returns_end_of_month_from_start_date_plus_month_offset(int year, int month, int day, int months) { - Object actual = XLWorkbook.EvaluateExpr("Time(1,2,3)"); - Assert.AreEqual(0.043090277777778, (double)actual, XLHelper.Epsilon); + return (double)XLWorkbook.EvaluateExpr($"EOMONTH(DATE({year},{month},{day}),{months})"); } [Test] - public void TimeValue1() + public void Eomonth_truncates_arguments() { - Object actual = XLWorkbook.EvaluateExpr("TimeValue(\"2:24 AM\")"); - Assert.IsTrue(XLHelper.AreEqual(0.1, (double)actual)); + Assert.AreEqual(59, XLWorkbook.EvaluateExpr("EOMONTH(60.1,0.9)")); } [Test] - public void TimeValue2() + public void Eomonth_start_date_must_be_in_date_values() { - Object actual = XLWorkbook.EvaluateExpr("TimeValue(\"22-Aug-2008 6:35 AM\")"); - Assert.IsTrue(XLHelper.AreEqual(0.27430555555555558, (double)actual)); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("EOMONTH(-0.1,0)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("EOMONTH(DATE(9999,12,31)+1,0)")); } - [Test] - public void Today() + [TestCase("1900-01-01", -1)] + [TestCase("9999-12-10", 1)] + public void Eomonth_returns_number_error_when_end_date_is_out_of_date_system(string startDate, double monthOffset) { - Object actual = XLWorkbook.EvaluateExpr("Today()"); - Assert.AreEqual(DateTime.Now.Date.ToOADate(), actual); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"EOMONTH(\"{startDate}\",{monthOffset})")); } - [Test] - public void Weekday_1() + [TestCase("0", ExpectedResult = 0)] + [TestCase("0.25", ExpectedResult = 6)] + [TestCase("0.5", ExpectedResult = 12)] + [TestCase("0.75", ExpectedResult = 18)] + [TestCase("1", ExpectedResult = 0)] + [TestCase("1.75", ExpectedResult = 18)] + [TestCase("\"1.75\"", ExpectedResult = 18)] // Test string in addition to number in TestCase before + [TestCase("\"7/18/2011 7:45\"", ExpectedResult = 7)] + [TestCase("\"4/21/2012\"", ExpectedResult = 0)] + [TestCase("\"12:00:00\"", ExpectedResult = 12)] + [TestCase("\"8/22/2008 3:30:45 PM\"", ExpectedResult = 15, Ignore = "We don't parse seconds")] + [TestCase("\"8/22/2008 3:30 PM\"", ExpectedResult = 15)] + [TestCase("DATE(2006,2,26)+TIME(2,10,20)", ExpectedResult = 2)] + [TestCase("TIME(22,56,34)", ExpectedResult = 22)] + [TestCase("\"22-Oct-2001 10:53:12\"", ExpectedResult = 10, Ignore = "We don't parse seconds plus culture is wrong")] + [TestCase("\"October 22, 2001 10:53\"", ExpectedResult = 10)] + [TestCase("\"10:53:12 pm\"", ExpectedResult = 22)] + [TestCase("\"22:53:12\"", ExpectedResult = 22)] + [TestCase("IF(TRUE,)", ExpectedResult = 0)] // Blank + [TestCase("TRUE", ExpectedResult = 0)] + [TestCase("FALSE", ExpectedResult = 0)] + public double Hour_returns_hour_of_serial_date(string dateArg) { - Object actual = XLWorkbook.EvaluateExpr("Weekday(\"2/14/2008\", 1)"); - Assert.AreEqual(5, actual); + return XLWorkbook.EvaluateExprCurrent($"HOUR({dateArg})").GetNumber(); } [Test] - public void Weekday_2() + public void Hour_accepts_only_serial_time_between_zero_and_upper_limit_of_date_system() { - Object actual = XLWorkbook.EvaluateExpr("Weekday(\"2/14/2008\", 2)"); - Assert.AreEqual(4, actual); - } + Assert.AreEqual(0, XLWorkbook.EvaluateExprCurrent("HOUR(0)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExprCurrent("HOUR(-0.1)")); - [Test] - public void Weekday_3() - { - Object actual = XLWorkbook.EvaluateExpr("Weekday(\"2/14/2008\", 3)"); - Assert.AreEqual(3, actual); + Assert.AreEqual(21, XLWorkbook.EvaluateExprCurrent("HOUR(DATE(9999,12,31)+0.9)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExprCurrent("HOUR(DATE(9999,12,31)+1)")); } - [Test] - public void Weekday_Omitted() + [TestCase("0", ExpectedResult = 0)] + [TestCase("0.5", ExpectedResult = 0)] + [TestCase("0.68", ExpectedResult = 19)] + [TestCase("0.69", ExpectedResult = 33)] + [TestCase("0.85", ExpectedResult = 24)] + [TestCase("10.85", ExpectedResult = 24)] + [TestCase("\"10.85\"", ExpectedResult = 24)] // Test string in addition to number in TestCase before + [TestCase("\"14:47:20\"", ExpectedResult = 47)] + [TestCase("\"8/22/2008 3:30 AM\"", ExpectedResult = 30)] + [TestCase("IF(TRUE,)", ExpectedResult = 0)] // Blank + [TestCase("TRUE", ExpectedResult = 0)] + [TestCase("FALSE", ExpectedResult = 0)] + public double Minute_returns_minute_of_serial_date(string dateArg) { - Object actual = XLWorkbook.EvaluateExpr("Weekday(\"2/14/2008\")"); - Assert.AreEqual(5, actual); + return XLWorkbook.EvaluateExprCurrent($"MINUTE({dateArg})").GetNumber(); } [Test] - public void Weeknum_1() + public void Minute_accepts_only_serial_time_between_zero_and_upper_limit_of_date_system() { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2000\", 1)")); - } + Assert.AreEqual(0, XLWorkbook.EvaluateExprCurrent("MINUTE(0)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExprCurrent("MINUTE(-0.1)")); - [Test] - public void Weeknum_10() - { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2004\", 2)")); + Assert.AreEqual(36, XLWorkbook.EvaluateExprCurrent("MINUTE(DATE(9999,12,31)+0.9)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExprCurrent("MINUTE(DATE(9999,12,31)+1)")); } - [Test] - public void Weeknum_11() + [SetCulture("eu-ES")] + [TestCase(0, ExpectedResult = 1)] // 1900-01-00 + [TestCase(31, ExpectedResult = 1)] // 1900-01-31 + [TestCase(32, ExpectedResult = 2)] // 1900-02-01 + [TestCase(59, ExpectedResult = 2)] // 1900-02-28 + [TestCase(60, ExpectedResult = 2)] // 1900-02-29 + [TestCase(61, ExpectedResult = 3)] // 1900-03-01 + [TestCase("DATE(2006,1,2)", ExpectedResult = 1)] + [TestCase("DATE(2006,0,2)", ExpectedResult = 12)] + [TestCase("\"2006/1/2 10:45 AM\"", ExpectedResult = 1)] + [TestCase("30000", ExpectedResult = 2)] + [TestCase("45596", ExpectedResult = 10)] + [TestCase("45596.9", ExpectedResult = 10)] + [TestCase("45597", ExpectedResult = 11)] + [TestCase("\"45597\"", ExpectedResult = 11)] // Test string in addition to number in TestCase before + [TestCase("IF(TRUE,)", ExpectedResult = 1)] // Blank + [TestCase("TRUE", ExpectedResult = 1)] + [TestCase("FALSE", ExpectedResult = 1)] + public double Month_returns_month_of_serial_date(object argument) { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2005\", 1)")); + return XLWorkbook.EvaluateExprCurrent($"MONTH({argument})").GetNumber(); } [Test] - public void Weeknum_12() + [Ignore("Excel accepts this but ClosedXML does not yet")] + public void Month_accepts_missing_year_and_substitutes_current_year() { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2005\", 2)")); + // Test providing just month and day, which should fill the year as "current year" + double actual = XLWorkbook.EvaluateExpr("MONTH(\"8/22\")").GetNumber(); + Assert.AreEqual(8, actual); } [Test] - public void Weeknum_13() + public void Month_serial_date_must_be_between_zero_and_upper_limit_of_date_system() { - Assert.AreEqual(10, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2006\", 1)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("MONTH(-0.1)")); + Assert.AreEqual(12, XLWorkbook.EvaluateExpr("MONTH(DATE(9999,12,31) + 0.9)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("MONTH(DATE(9999,12,31) + 1)")); } - [Test] - public void Weeknum_14() + [TestCase(1900, 1, 0, ExpectedResult = 52)] + [TestCase(1900, 1, 1, ExpectedResult = 52)] + [TestCase(1900, 1, 2, ExpectedResult = 1)] + [TestCase(1900, 2, 28, ExpectedResult = 9)] + [TestCase(1900, 2, 29, ExpectedResult = 9)] + [TestCase(1900, 3, 1, ExpectedResult = 9)] + [TestCase(2012, 1, 2, ExpectedResult = 1)] + [TestCase(2012, 12, 31, ExpectedResult = 1)] + [TestCase(2012, 3, 9, ExpectedResult = 10)] + [TestCase(2014, 12, 12, ExpectedResult = 50)] + [TestCase(9999, 12, 31, ExpectedResult = 52)] + public double IsoWeekNum(int year, int month, int day) { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2006\", 2)")); + return (double)XLWorkbook.EvaluateExpr($"ISOWEEKNUM(DATE({year},{month},{day}))"); } [Test] - public void Weeknum_15() + public void NetWorkDays_with_holidays() { - Assert.AreEqual(10, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2007\", 1)")); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.FirstCell().SetValue("Date") + .CellBelow().SetValue(new DateTime(2008, 10, 1)) + .CellBelow().SetValue(new DateTime(2009, 3, 1)) + .CellBelow().SetValue(new DateTime(2008, 11, 26)) + .CellBelow().SetValue(new DateTime(2008, 12, 4)) + .CellBelow().SetValue(new DateTime(2009, 1, 21)) + .CellBelow().SetValue(new DateTime(2009, 1, 4)) // Holiday is on Sunday - do not count twice + .CellBelow().SetValue(new DateTime(2009, 1, 6)) // Workweek holiday is specified twice, shouldn't be counted twice + .CellBelow().SetValue(new DateTime(2009, 1, 6)) + .CellBelow().SetValue(new DateTime(2008, 9, 30)) // Tuesday holiday just before the first date, shouldn't be counted + .CellBelow().SetValue(new DateTime(2009, 3, 2)) // Monday holiday just after the last date, shouldn't be counted + ; + var actual = ws.Evaluate("NETWORKDAYS(A2, A3, A4:A11)"); + Assert.AreEqual(104, actual); + } + + [TestCase("2024-10-01", "2024-10-01", 1)] // Tue-Tue + [TestCase("2024-10-01", "2024-10-02", 2)] // Tue-Wed + [TestCase("2024-10-01", "2024-10-03", 3)] // Tue-Thu + [TestCase("2024-10-01", "2024-10-04", 4)] // Tue-Fri + [TestCase("2024-10-01", "2024-10-05", 4)] // Tue-Sat + [TestCase("2024-10-01", "2024-10-06", 4)] // Tue-Sun + [TestCase("2024-10-01", "2024-10-07", 5)] // Tue-Mon + [TestCase("2024-09-29", "2024-10-12", 10)] // Sun-Sat + [TestCase("2024-09-29", "2024-10-13", 10)] // Sun-Sun + [TestCase("2024-09-29", "2024-10-14", 11)] // Sun-Mon + [TestCase("2024-09-29", "2024-10-15", 12)] // Sun-Tue + [TestCase("2024-09-29", "2024-10-16", 13)] // Sun-Wed + [TestCase("2024-09-29", "2024-10-17", 14)] // Sun-Thu + [TestCase("2024-09-29", "2024-10-18", 15)] // Sun-Fri + [TestCase("2024-09-29", "2024-10-19", 15)] // Sun-Sat + public void NetWorkDays_non_full_weeks_are_counted_correctly(string startDate, string endDate, int expected) + { + var actual = XLWorkbook.EvaluateExpr($"NETWORKDAYS(\"{startDate}\", \"{endDate}\")"); + Assert.AreEqual(expected, actual); + } + + [Test] + [Culture("en-US")] + public void NetWorkDays_with_end_date_earlier_than_start_date() + { + var actual = XLWorkbook.EvaluateExpr("NETWORKDAYS(\"3/01/2009\", \"10/01/2008\")"); + Assert.AreEqual(-108, actual); - [Test] - public void Weeknum_16() - { - Assert.AreEqual(10, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2007\", 2)")); + actual = XLWorkbook.EvaluateExpr("NETWORKDAYS(\"2016-01-01\", \"2015-12-23\")"); + Assert.AreEqual(-8, actual); } [Test] - public void Weeknum_17() + [Culture("en-US")] + public void NetWorkDays_behavior() { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2008\", 1)")); + using var wb = new XLWorkbook(); + var actual = wb.Evaluate("NETWORKDAYS(\"10/01/2008\", \"3/01/2009\", \"11/26/2008\")"); + Assert.AreEqual(107, actual); + + // Example from specification. Except spec wrong. The value is 1 off from Excel value. + Assert.AreEqual(22, wb.Evaluate("NETWORKDAYS(DATE(2006, 1, 1), DATE(2006, 1, 31))")); + Assert.AreEqual(-22, wb.Evaluate("NETWORKDAYS(DATE(2006, 1, 31), DATE(2006, 1, 1))")); + Assert.AreEqual(21, wb.Evaluate("NETWORKDAYS(DATE(2006, 1, 1), DATE(2006, 2, 1), { \"2006-01-02\", \"2006-01-16\" })")); + + // Scalar number is accepted for holidays + Assert.AreEqual(6, wb.Evaluate("NETWORKDAYS(1, 10, 2)")); + + // Scalar logical causes conversion error + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("NETWORKDAYS(TRUE, 10)")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("NETWORKDAYS(0, TRUE)")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("NETWORKDAYS(1, 10, TRUE)")); + + // Scalar text is converted + Assert.AreEqual(6, wb.Evaluate("NETWORKDAYS(\"1\", \"10\", \"2\")")); + Assert.AreEqual(6, wb.Evaluate("NETWORKDAYS(1, 10, \"0 4/2\")")); + Assert.AreEqual(6, wb.Evaluate("NETWORKDAYS(1, 10, \"1900-01-02\")")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("NETWORKDAYS(\"Text\", 10)")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("NETWORKDAYS(1, \"Text\")")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("NETWORKDAYS(1, 10, \"Text\")")); + + // Array accepts numbers and converts text + Assert.AreEqual(5, wb.Evaluate("NETWORKDAYS(1, 10, {\"2\", 3})")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("NETWORKDAYS(1, 10, {\"Text\"})")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("NETWORKDAYS(1, 10, {TRUE})")); + + // Same conversion logic applies to reference values + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = Blank.Value; // Ignored + ws.Cell("A2").Value = false; // Causes conversion error + ws.Cell("A3").Value = true; // Causes conversion error + ws.Cell("A4").Value = 37147; // 2001-09-13 + ws.Cell("A5").Value = "2001-09-12"; // Monday + ws.Cell("A6").Value = XLError.NoValueAvailable; + + Assert.AreEqual(175, ws.Evaluate("NETWORKDAYS(\"2001-05-01\", \"2001-12-31\", A1)")); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("NETWORKDAYS(\"2001-05-01\", \"2001-12-31\", A1:A3)")); + Assert.AreEqual(173, ws.Evaluate("NETWORKDAYS(\"2001-05-01\",\"2001-12-31\", A4:A5)")); + + // Errors are propagated + Assert.AreEqual(XLError.NoValueAvailable, wb.Evaluate("NETWORKDAYS(#N/A, 10)")); + Assert.AreEqual(XLError.NoValueAvailable, wb.Evaluate("NETWORKDAYS(1, #N/A)")); + Assert.AreEqual(XLError.NoValueAvailable, wb.Evaluate("NETWORKDAYS(1, 10, {#N/A})")); + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("NETWORKDAYS(1, 10, A6)")); } - [Test] - public void Weeknum_18() + [TestCase("0", ExpectedResult = 0)] + [TestCase("0.5", ExpectedResult = 0)] + [TestCase("1", ExpectedResult = 0)] + [TestCase("366", ExpectedResult = 0)] + [TestCase("367", ExpectedResult = 0)] + [TestCase("\"367\"", ExpectedResult = 0)] // Test string in addition to number in TestCase before + [TestCase("\"8/22/2008\"", ExpectedResult = 0)] + [TestCase("\"1/2/2006 10:45 AM\"", ExpectedResult = 0)] + [TestCase("\"8/22/2008 3:30:4 PM\"", ExpectedResult = 4, Ignore = "We don't parse seconds")] + [TestCase("\"8/22/2008 3:30:23 PM\"", ExpectedResult = 23, Ignore = "We don't parse seconds")] + [TestCase("\"3:30:45\"", ExpectedResult = 45)] + [TestCase("IF(TRUE,)", ExpectedResult = 0)] // Blank + [TestCase("TRUE", ExpectedResult = 0)] + [TestCase("FALSE", ExpectedResult = 0)] + public double Second_returns_second_of_serial_date(string dateArg) { - Assert.AreEqual(10, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2008\", 2)")); + return XLWorkbook.EvaluateExprCurrent($"SECOND({dateArg})").GetNumber(); } [Test] - public void Weeknum_19() + public void Second_accepts_only_serial_time_between_zero_and_upper_limit_of_date_system() { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2009\", 1)")); + Assert.AreEqual(0, XLWorkbook.EvaluateExprCurrent("SECOND(0)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExprCurrent("SECOND(-0.1)")); + + Assert.AreEqual(51, XLWorkbook.EvaluateExprCurrent("SECOND(DATE(9999,12,31)+0.9999)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExprCurrent("SECOND(DATE(9999,12,31)+1)")); } - [Test] - public void Weeknum_2() + [TestCase(0, 0, 0, ExpectedResult = 0)] + [TestCase(0, 0, 1, ExpectedResult = 0.0000115740740741)] + [TestCase(0, 0, 2, ExpectedResult = 0.0000231481481481)] + [TestCase(0, 0, 20, ExpectedResult = 0.0002314814814815)] + [TestCase(2, 3, 20, ExpectedResult = 0.0856481481481481)] + [TestCase(12, 0, 0, ExpectedResult = 0.5000000000000000)] + [TestCase(23, 59, 59, ExpectedResult = 0.9999884259259260)] + [TestCase(26, 120, 240, ExpectedResult = 0.1694444444444450)] + [TestCase(1, 2, 3, ExpectedResult = 0.043090277777778)] + [TestCase(1.9, 2.9, 3.9, ExpectedResult = 0.043090277777778)] + [TestCase(24, 0, 0, ExpectedResult = 0)] + [TestCase(0, 42 * 60, 0, ExpectedResult = 0.75)] + [TestCase(0, 0, 60 * 60 * 3, ExpectedResult = 0.125)] + [TestCase(120, 240, 347, ExpectedResult = 0.170682870370)] + [DefaultFloatingPointTolerance(XLHelper.Epsilon)] + public double Time_returns_serial_date_time(double hour, double minute, double second) { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2000\", 2)")); + return (double)XLWorkbook.EvaluateExpr($"TIME({hour},{minute},{second})"); } - [Test] - public void Weeknum_20() + [TestCase(-0.1, 0, 0)] + [TestCase(32768, 0, 0)] + [TestCase(0, -0.1, 0)] + [TestCase(0, 32768, 0)] + [TestCase(0, 0, -0.1)] + [TestCase(0, 0, 32768)] + public void Time_components_must_be_in_zero_to_32767_interval(double hour, double minute, double second) { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2009\", 2)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"TIME({hour},{minute},{second})")); } - [Test] - public void Weeknum_3() + [TestCase("2:24 AM", ExpectedResult = 0.1)] + [TestCase("August 22, 2008 6:35 AM", ExpectedResult = 0.27430555555555558)] + [DefaultFloatingPointTolerance(XLHelper.Epsilon)] + public double TimeValue_returns_time_component_of_serial_date_extracted_from_text(string time) { - Assert.AreEqual(10, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2001\", 1)")); + return (double)XLWorkbook.EvaluateExprCurrent($"TIMEVALUE(\"{time}\")"); } - [Test] - public void Weeknum_4() + [TestCase("\"10.5\"")] + [TestCase("\"0\"")] + public void TimeValue_doesnt_coerce_number_in_a_text_to_a_time(string numberText) { - Assert.AreEqual(10, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2001\", 2)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExprCurrent($"TIMEVALUE({numberText})")); } - [Test] - public void Weeknum_5() + [TestCase("TRUE")] + [TestCase("FALSE")] + [TestCase("0.25")] + [TestCase("TIME(18,25,48)")] + public void TimeValue_returns_coercion_error_on_non_text(string nonText) { - Assert.AreEqual(10, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2002\", 1)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExprCurrent($"TIMEVALUE({nonText})")); } [Test] - public void Weeknum_6() + public void TimeValue_propagates_error() { - Assert.AreEqual(10, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2002\", 2)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExprCurrent("TIMEVALUE(#DIV/0!)")); } [Test] - public void Weeknum_7() + public void Today() { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2003\", 1)")); + var actual = (double)XLWorkbook.EvaluateExpr("TODAY()"); + Assert.AreEqual(DateTime.Today.ToSerialDateTime(), actual); } - [Test] - public void Weeknum_8() + [TestCase("\"2/14/2008\"", 1, 5)] + [TestCase("\"2/14/2008\"", 2, 4)] + [TestCase("\"2/14/2008\"", 3, 3)] + [TestCase("\"2/14/2008\"", 11, 4)] + [TestCase("\"2/14/2008\"", 12, 3)] + [TestCase("\"2/14/2008\"", 13, 2)] + [TestCase("\"2/14/2008\"", 14, 1)] + [TestCase("\"2/14/2008\"", 15, 7)] + [TestCase("\"2/14/2008\"", 16, 6)] + [TestCase("\"2/14/2008\"", 17, 5)] + public void Weekday_calculates_week_day(string value, int flag, int expected) { - Assert.AreEqual(10, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2003\", 2)")); + var actual = XLWorkbook.EvaluateExpr($"WEEKDAY({value}, {flag})"); + Assert.AreEqual(expected, actual); } [Test] - public void Weeknum_9() + public void Weekday_without_flag() { - Assert.AreEqual(11, XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2004\", 1)")); + var actual = XLWorkbook.EvaluateExpr("WEEKDAY(\"2/14/2008\")"); + Assert.AreEqual(5, actual); } [Test] - public void Weeknum_Default() - { - Object actual = XLWorkbook.EvaluateExpr("Weeknum(\"3/9/2008\")"); - Assert.AreEqual(11, actual); + public void Weekday_behavior() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + ws.Cell("A1").Value = 45577; + Assert.AreEqual(7, ws.Evaluate("WEEKDAY(A1)")); + + // Time of the day doesn't matter, serial date is truncated + Assert.AreEqual(7, XLWorkbook.EvaluateExpr("WEEKDAY(45577.9, 1.9)")); + + Assert.AreEqual(7, XLWorkbook.EvaluateExpr("WEEKDAY(0)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("WEEKDAY(-1)")); + + // Year 10k + Assert.AreEqual(6, XLWorkbook.EvaluateExpr("WEEKDAY(2958465)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("WEEKDAY(2958466)")); + + // Convert from logical/text to number + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("WEEKDAY(TRUE)")); + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("WEEKDAY(\"0 2/2\")")); + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("WEEKDAY(1, TRUE)")); + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("WEEKDAY(1, \"1 0/2\")")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("WEEKDAY(\"text\")")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("WEEKDAY(1, \"text\")")); + + // Flag can only have some values + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("WEEKDAY(1, 0)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("WEEKDAY(1, 4)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("WEEKDAY(1, 10)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("WEEKDAY(1, 18)")); + + // Error is propagated + Assert.AreEqual(XLError.NoValueAvailable, XLWorkbook.EvaluateExpr("WEEKDAY(#N/A)")); + Assert.AreEqual(XLError.NoValueAvailable, XLWorkbook.EvaluateExpr("WEEKDAY(5, #N/A)")); + } + + [TestCase(1, 1986, 12, 27, ExpectedResult = 52)] + [TestCase(1, 1986, 12, 28, ExpectedResult = 53)] + [TestCase(1, 1986, 12, 31, ExpectedResult = 53)] + [TestCase(1, 1987, 1, 1, ExpectedResult = 1)] + [TestCase(1, 1987, 1, 3, ExpectedResult = 1)] + [TestCase(1, 1987, 1, 4, ExpectedResult = 2)] + [TestCase(1, 2000, 3, 9, ExpectedResult = 11)] + [TestCase(1, 2002, 3, 9, ExpectedResult = 10)] + [TestCase(1, 2003, 3, 9, ExpectedResult = 11)] + [TestCase(1, 2004, 3, 9, ExpectedResult = 11)] + [TestCase(1, 2005, 3, 9, ExpectedResult = 11)] + [TestCase(1, 2006, 3, 9, ExpectedResult = 10)] + [TestCase(1, 2007, 3, 9, ExpectedResult = 10)] + [TestCase(1, 2008, 3, 9, ExpectedResult = 11)] + [TestCase(1, 2009, 3, 9, ExpectedResult = 11)] + [TestCase(2, 1988, 12, 25, ExpectedResult = 52)] + [TestCase(2, 1988, 12, 26, ExpectedResult = 53)] + [TestCase(2, 1988, 12, 31, ExpectedResult = 53)] + [TestCase(2, 1989, 1, 1, ExpectedResult = 1)] + [TestCase(2, 1989, 1, 2, ExpectedResult = 2)] + [TestCase(2, 2000, 3, 9, ExpectedResult = 11)] + [TestCase(2, 2001, 3, 9, ExpectedResult = 10)] + [TestCase(2, 2002, 3, 9, ExpectedResult = 10)] + [TestCase(2, 2003, 3, 9, ExpectedResult = 10)] + [TestCase(2, 2004, 3, 9, ExpectedResult = 11)] + [TestCase(2, 2005, 3, 9, ExpectedResult = 11)] + [TestCase(2, 2006, 3, 9, ExpectedResult = 11)] + [TestCase(2, 2007, 3, 9, ExpectedResult = 10)] + [TestCase(2, 2008, 3, 9, ExpectedResult = 10)] + [TestCase(2, 2009, 3, 9, ExpectedResult = 11)] + [TestCase(11, 1990, 12, 23, ExpectedResult = 51)] + [TestCase(11, 1990, 12, 24, ExpectedResult = 52)] + [TestCase(11, 1990, 12, 30, ExpectedResult = 52)] + [TestCase(11, 1990, 12, 31, ExpectedResult = 53)] + [TestCase(11, 1991, 1, 1, ExpectedResult = 1)] + [TestCase(11, 1991, 1, 6, ExpectedResult = 1)] + [TestCase(11, 1991, 1, 7, ExpectedResult = 2)] + [TestCase(12, 1992, 12, 28, ExpectedResult = 52)] + [TestCase(12, 1992, 12, 29, ExpectedResult = 53)] + [TestCase(12, 1992, 12, 31, ExpectedResult = 53)] + [TestCase(12, 1993, 1, 1, ExpectedResult = 1)] + [TestCase(12, 1993, 1, 4, ExpectedResult = 1)] + [TestCase(12, 1993, 1, 5, ExpectedResult = 2)] + [TestCase(13, 1994, 12, 27, ExpectedResult = 52)] + [TestCase(13, 1994, 12, 28, ExpectedResult = 53)] + [TestCase(13, 1994, 12, 31, ExpectedResult = 53)] + [TestCase(13, 1995, 1, 1, ExpectedResult = 1)] + [TestCase(13, 1995, 1, 3, ExpectedResult = 1)] + [TestCase(13, 1995, 1, 4, ExpectedResult = 2)] + [TestCase(14, 1999, 12, 29, ExpectedResult = 52)] + [TestCase(14, 1999, 12, 30, ExpectedResult = 53)] + [TestCase(14, 1999, 12, 31, ExpectedResult = 53)] + [TestCase(14, 2000, 1, 1, ExpectedResult = 1)] + [TestCase(14, 2000, 1, 5, ExpectedResult = 1)] + [TestCase(14, 2000, 1, 6, ExpectedResult = 2)] + [TestCase(15, 2004, 12, 24, ExpectedResult = 53)] + [TestCase(15, 2004, 12, 30, ExpectedResult = 53)] + [TestCase(15, 2004, 12, 31, ExpectedResult = 54)] + [TestCase(15, 2005, 1, 1, ExpectedResult = 1)] + [TestCase(15, 2005, 1, 6, ExpectedResult = 1)] + [TestCase(15, 2005, 1, 7, ExpectedResult = 2)] + [TestCase(16, 2008, 12, 26, ExpectedResult = 52)] + [TestCase(16, 2008, 12, 27, ExpectedResult = 53)] + [TestCase(16, 2008, 12, 31, ExpectedResult = 53)] + [TestCase(16, 2009, 1, 1, ExpectedResult = 1)] + [TestCase(16, 2009, 1, 2, ExpectedResult = 1)] + [TestCase(16, 2009, 1, 3, ExpectedResult = 2)] + [TestCase(16, 2009, 1, 9, ExpectedResult = 2)] + [TestCase(17, 1929, 12, 21, ExpectedResult = 51)] + [TestCase(17, 1929, 12, 22, ExpectedResult = 52)] + [TestCase(17, 1929, 12, 28, ExpectedResult = 52)] + [TestCase(17, 1929, 12, 29, ExpectedResult = 53)] + [TestCase(17, 1929, 12, 31, ExpectedResult = 53)] + [TestCase(17, 1930, 1, 1, ExpectedResult = 1)] + [TestCase(17, 1930, 1, 4, ExpectedResult = 1)] + [TestCase(17, 1930, 1, 5, ExpectedResult = 2)] + [TestCase(17, 1930, 1, 11, ExpectedResult = 2)] + [TestCase(21, 1964, 12, 27, ExpectedResult = 52)] + [TestCase(21, 1964, 12, 28, ExpectedResult = 53)] + [TestCase(21, 1964, 12, 31, ExpectedResult = 53)] + [TestCase(21, 1965, 1, 1, ExpectedResult = 53)] + [TestCase(21, 1965, 1, 3, ExpectedResult = 53)] + [TestCase(21, 1965, 1, 4, ExpectedResult = 1)] + [TestCase(21, 1968, 12, 29, ExpectedResult = 52)] + [TestCase(21, 1968, 12, 30, ExpectedResult = 1)] + [TestCase(21, 1968, 12, 31, ExpectedResult = 1)] + [TestCase(21, 1969, 1, 1, ExpectedResult = 1)] + [TestCase(21, 1969, 1, 5, ExpectedResult = 1)] + [TestCase(21, 1969, 1, 6, ExpectedResult = 2)] + public double Weeknum_returns_week_number_for_date(double weekStartFlag, double year, double month, double day) + { + return XLWorkbook.EvaluateExpr($"WEEKNUM(DATE({year},{month},{day}),{weekStartFlag})").GetNumber(); + } + + [Test] + public void Weeknum_default_week_starts_on_sunday() + { + for (var day = 14; day <= 20; day++) + { + var defaultValue = XLWorkbook.EvaluateExpr($"WEEKNUM(DATE(1967,5,{day}))"); + var sundayValue = XLWorkbook.EvaluateExpr($"WEEKNUM(DATE(1967,5,{day}),1)"); + Assert.AreEqual(sundayValue, defaultValue); + } + } + + [TestCase] + public void Weeknum_match_excel_behavior_and_returns_zero_for_serial_date_zero_when_week_starts_on_sunday() + { + Assert.AreEqual(0, XLWorkbook.EvaluateExpr("WEEKNUM(0,1)")); + Assert.AreEqual(0, XLWorkbook.EvaluateExpr("WEEKNUM(0,17)")); + } + + [TestCase] + public void Weeknum_returns_number_invalid_error_on_non_serial_dates() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("WEEKNUM(-0.1)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("WEEKNUM(DATE(9999,12,31)+1)")); + } + + [TestCase(-5)] + [TestCase(0)] + [TestCase(3)] + [TestCase(10)] + [TestCase(18)] + [TestCase(20)] + [TestCase(22)] + [TestCase(100)] + public void Weeknum_returns_number_invalid_error_on_non_specified_flags(double flag) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"WEEKNUM(DATE(200,1,1),{flag})")); } [Test] @@ -460,102 +903,149 @@ public void Workdays_MultipleHolidaysGiven() .CellBelow().SetValue(new DateTime(2008, 11, 26)) .CellBelow().SetValue(new DateTime(2008, 12, 4)) .CellBelow().SetValue(new DateTime(2009, 1, 21)); - Object actual = ws.Evaluate("Workday(A2,A3,A4:A6)"); - Assert.AreEqual(new DateTime(2009, 5, 5).ToOADate(), actual); + var actual = ws.Evaluate("Workday(A2,A3,A4:A6)"); + Assert.AreEqual(new DateTime(2009, 5, 5).ToSerialDateTime(), actual); } [Test] public void Workdays_NoHolidaysGiven() { - Object actual = XLWorkbook.EvaluateExpr("Workday(\"10/01/2008\", 151)"); - Assert.AreEqual(new DateTime(2009, 4, 30).ToOADate(), actual); + var actual = XLWorkbook.EvaluateExpr("Workday(\"10/01/2008\", 151)"); + Assert.AreEqual(new DateTime(2009, 4, 30).ToSerialDateTime(), actual); actual = XLWorkbook.EvaluateExpr("Workday(\"2016-01-01\", -10)"); - Assert.AreEqual(new DateTime(2015, 12, 18).ToOADate(), actual); + Assert.AreEqual(new DateTime(2015, 12, 18).ToSerialDateTime(), actual); } [Test] public void Workdays_OneHolidaysGiven() { - Object actual = XLWorkbook.EvaluateExpr("Workday(\"10/01/2008\", 152, \"11/26/2008\")"); - Assert.AreEqual(new DateTime(2009, 5, 4).ToOADate(), actual); + var actual = XLWorkbook.EvaluateExpr("Workday(\"10/01/2008\", 152, \"11/26/2008\")"); + Assert.AreEqual(new DateTime(2009, 5, 4).ToSerialDateTime(), actual); + } + + [TestCase(0, 0, 0)] + [TestCase(0, 1, 2)] + [TestCase(1, 1, 2)] + [TestCase(2, 1, 3)] + [TestCase(0, 5, 6)] + [TestCase(2, 8, 12)] + [TestCase(10, -1, 9)] + [TestCase(10, -2, 6)] + [TestCase(10, -3, 5)] + [TestCase(9, -1, 6)] + [TestCase(9, -2, 5)] + [TestCase(8, -1, 6)] + [TestCase(7, -1, 6)] + [TestCase(6, -1, 5)] + public void Workdays(int startDate, int dayOffset, int expected) + { + var actual = XLWorkbook.EvaluateExpr($"WORKDAY({startDate}, {dayOffset})"); + Assert.AreEqual(expected, actual); + } + + [TestCase(0, 1, new[] { 1 }, 2)] + [TestCase(0, 1, new[] { 2 }, 3)] + [TestCase(0, 5, new[] { 2, 4 }, 10)] + [TestCase(0, 4, new[] { 2, 4, 6 }, 10)] + [TestCase(0, 3, new[] { 2, 3, 4, 6 }, 10)] + [TestCase(0, 2, new[] { 2, 3, 4, 5, 6 }, 10)] + [TestCase(0, 1, new[] { 2, 3, 5 }, 4)] + [TestCase(0, 2, new[] { 2, 3, 5 }, 6)] + [TestCase(2, 1, new[] { 2 }, 3)] + [TestCase(15, -1, new[] { 13 }, 12)] // 15 = Sunday + [TestCase(100, -5, new[] { 82, 93, 94, 95, 94, 100 }, 88)] + [TestCase(98, -2, new[] { 97 }, 95)] + public void Workdays_with_holiday(int startDate, int dayOffset, int[] holidays, int expected) + { + var actual = XLWorkbook.EvaluateExpr($"WORKDAY({startDate}, {dayOffset}, {{{string.Join(",", holidays)}}})"); + Assert.AreEqual(expected, actual); + } + + [TestCase("\"8/22/2008\"", 2008)] + [TestCase("\"1/2/2006 10:45 AM\"", 2006)] + [TestCase("0", 1900)] + [TestCase("0.5", 1900)] + [TestCase("1", 1900)] + [TestCase("59", 1900)] + [TestCase("60", 1900)] + [TestCase("366", 1900)] + [TestCase("367", 1901)] + [TestCase("\"367\"", 1901)] // Test string in addition to number in TestCase before + [TestCase("DATE(9999,12,31)+0.9", 9999)] + [TestCase("DATE(9999,12,31)+1", XLError.NumberInvalid)] + [TestCase("-1", XLError.NumberInvalid)] + [TestCase("\"test\"", XLError.IncompatibleValue)] + [TestCase("IF(TRUE,)", 1900)] // Blank + [TestCase("TRUE", 1900)] + [TestCase("FALSE", 1900)] + [TestCase("#DIV/0!", XLError.DivisionByZero)] + [TestCase("\"\"", XLError.IncompatibleValue)] + public void Year(string value, object expected) + { + var actual = XLWorkbook.EvaluateExpr($"YEAR({value})"); + Assert.AreEqual(XLCellValue.FromObject(expected), actual); + } + + [Test] + public void Year_BlankValue() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = Blank.Value; + ws.Cell("A2").FormulaA1 = "YEAR(A1)"; + var valueA2 = ws.Cell("A2").Value; + Assert.AreEqual(1900, valueA2); + } + + [Test] + [Ignore("Excel accepts this but ClosedXML does not yet")] + public void Year_accepts_missing_year_and_substitutes_current_year() + { + // Test providing just month and day, which should fill the year as "current year" + double actual = XLWorkbook.EvaluateExpr("YEAR(\"8/22\")").GetNumber(); + Assert.AreEqual(DateTime.Now.Year, actual); + } + + [DefaultFloatingPointTolerance(XLHelper.Epsilon)] + [TestCase(0, 2008, 1, 1, 2008, 3, 31, ExpectedResult = 0.25)] + [TestCase(0, 2008, 1, 1, 2013, 3, 31, ExpectedResult = 5.25)] + [TestCase(1, 2008, 1, 1, 2008, 3, 31, ExpectedResult = 0.24590163934426229)] + [TestCase(1, 2008, 1, 1, 2013, 3, 31, ExpectedResult = 5.24452554744526)] + [TestCase(1, 1900, 1, 10, 2024, 2, 29, ExpectedResult = 124.137572279657)] + [TestCase(1, 1924, 6, 25, 2025, 2, 28, ExpectedResult = 100.67763581705)] + [TestCase(2, 2008, 1, 1, 2008, 3, 31, ExpectedResult = 0.25)] + [TestCase(2, 2008, 1, 1, 2013, 3, 31, ExpectedResult = 5.32222222222222)] + [TestCase(3, 2008, 1, 1, 2008, 3, 31, ExpectedResult = 0.24657534246575341)] + [TestCase(3, 2008, 1, 1, 2013, 3, 31, ExpectedResult = 5.24931506849315)] + [TestCase(4, 2008, 1, 1, 2008, 3, 31, ExpectedResult = 0.24722222222222223)] + [TestCase(4, 2008, 1, 1, 2013, 3, 31, ExpectedResult = 5.24722222222222)] + [TestCase(0, 2006, 1, 1, 2006, 3, 26, ExpectedResult = 0.23611111111)] + [TestCase(0, 2006, 3, 26, 2006, 1, 1, ExpectedResult = 0.23611111111)] + [TestCase(0, 2006, 1, 1, 2006, 7, 1, ExpectedResult = 0.5)] + [TestCase(0, 2006, 1, 1, 2007, 9, 1, ExpectedResult = 1.6666666666)] + [TestCase(1, 2006, 1, 1, 2006, 7, 1, ExpectedResult = 0.495890411)] + [TestCase(2, 2006, 1, 1, 2006, 7, 1, ExpectedResult = 0.5027777778)] + [TestCase(3, 2006, 1, 1, 2006, 7, 1, ExpectedResult = 0.495890411)] + [TestCase(4, 2006, 1, 1, 2006, 7, 1, ExpectedResult = 0.5)] + [TestCase(1, 2004, 3, 1, 2006, 3, 1, ExpectedResult = 1.9981751825)] + public double YearFrac_calculates_fraction_of_a_year(double basis, double startYear, double startMonth, double startDay, double endYear, double endMonth, double endDay) + { + return (double)XLWorkbook.EvaluateExpr($"YEARFRAC(DATE({startYear},{startMonth},{startDay}),DATE({endYear},{endMonth},{endDay}),{basis})"); + } + + [Test] + public void YearFrac_dates_must_fit_in_date_system_range() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("YEARFRAC(-0.1,10)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("YEARFRAC(0,-0.1)")); } - - [Test] - public void Year() - { - Object actual = XLWorkbook.EvaluateExpr("Year(\"8/22/2008\")"); - Assert.AreEqual(2008, actual); - } - - [Test] - public void Yearfrac_1_base0() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2008\",0)"); - Assert.IsTrue(XLHelper.AreEqual(0.25, (double)actual)); - } - - [Test] - public void Yearfrac_1_base1() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2008\",1)"); - Assert.IsTrue(XLHelper.AreEqual(0.24590163934426229, (double)actual)); - } - - [Test] - public void Yearfrac_1_base2() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2008\",2)"); - Assert.IsTrue(XLHelper.AreEqual(0.25, (double)actual)); - } - - [Test] - public void Yearfrac_1_base3() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2008\",3)"); - Assert.IsTrue(XLHelper.AreEqual(0.24657534246575341, (double)actual)); - } - - [Test] - public void Yearfrac_1_base4() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2008\",4)"); - Assert.IsTrue(XLHelper.AreEqual(0.24722222222222223, (double)actual)); - } - - [Test] - public void Yearfrac_2_base0() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2013\",0)"); - Assert.IsTrue(XLHelper.AreEqual(5.25, (double)actual)); - } - - [Test] - public void Yearfrac_2_base1() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2013\",1)"); - Assert.IsTrue(XLHelper.AreEqual(5.24452554744526, (double)actual)); - } - - [Test] - public void Yearfrac_2_base2() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2013\",2)"); - Assert.IsTrue(XLHelper.AreEqual(5.32222222222222, (double)actual)); - } - - [Test] - public void Yearfrac_2_base3() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2013\",3)"); - Assert.IsTrue(XLHelper.AreEqual(5.24931506849315, (double)actual)); - } - - [Test] - public void Yearfrac_2_base4() - { - Object actual = XLWorkbook.EvaluateExpr("Yearfrac(\"1/1/2008\", \"3/31/2013\",4)"); - Assert.IsTrue(XLHelper.AreEqual(5.24722222222222, (double)actual)); + + [Test] + public void YearFrac_basis_must_be_between_0_and_4() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("YEARFRAC(0,10,-0.1)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("YEARFRAC(0,10,5)")); } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/DependencyTreeTests.cs b/ClosedXML.Tests/Excel/CalcEngine/DependencyTreeTests.cs new file mode 100644 index 000000000..cf204ba36 --- /dev/null +++ b/ClosedXML.Tests/Excel/CalcEngine/DependencyTreeTests.cs @@ -0,0 +1,667 @@ +using System; +using System.Collections.Generic; +using ClosedXML.Excel; +using ClosedXML.Excel.CalcEngine; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.CalcEngine +{ + [TestFixture] + internal class DependencyTreeTests + { + #region Add formula to dependency tree + + [Test] + [TestCaseSource(nameof(AreaDependenciesTestCases))] + public void Area_dependencies_are_extracted_from_formula(string formula, IReadOnlyList expectedAreas) + { + var dependencies = GetDependencies(formula); + CollectionAssert.AreEquivalent(expectedAreas, dependencies.Areas); + } + + [Test] + [TestCaseSource(nameof(NameDependenciesTestCases))] + public void Name_dependencies_are_kept_for_dependencies_update(string formula, IReadOnlyList expectedNames) + { + var dependencies = GetDependencies(formula); + CollectionAssert.AreEquivalent(expectedNames, dependencies.Names); + } + + [Test] + public void Name_range_is_added_to_dependencies_of_formula() + { + var dependencies = GetDependencies("name + D2", init: wb => + { + wb.DefinedNames.Add("name", "Sheet!$B$4+Sheet!$C$6"); + }); + CollectionAssert.AreEquivalent(new XLBookArea[] + { + new("Sheet", XLSheetRange.Parse("D2")), + new("Sheet", XLSheetRange.Parse("B4")), + new("Sheet", XLSheetRange.Parse("C6")) + }, dependencies.Areas); + CollectionAssert.AreEquivalent(new[] { new XLName("name") }, dependencies.Names); + } + + [Test] + public void Name_range_that_is_reference_is_propagated_to_formula() + { + var dependencies = GetDependencies("B3:name", init: wb => + { + wb.DefinedNames.Add("name", "Sheet!$D$7"); + }); + CollectionAssert.AreEquivalent(new XLBookArea[] + { + new("Sheet", XLSheetRange.Parse("B3:D7")), + }, dependencies.Areas); + CollectionAssert.AreEquivalent(new[] { new XLName("name") }, dependencies.Names); + } + + [Test] + public void Name_range_can_used_another_name_range() + { + var dependencies = GetDependencies("outer", init: wb => + { + wb.DefinedNames.Add("outer", "Sheet!$D$7 + inner"); + wb.DefinedNames.Add("inner", "Sheet!$B$1"); + }); + CollectionAssert.AreEquivalent(new XLBookArea[] + { + new("Sheet", XLSheetRange.Parse("D7")), + new("Sheet", XLSheetRange.Parse("B1")), + }, dependencies.Areas); + CollectionAssert.AreEquivalent(new[] { new XLName("outer"), new XLName("inner") }, dependencies.Names); + } + + [Test] + public void Name_range_that_is_not_a_reference_can_be_added_to_dependency_tree_without_exception() + { + var dependencies = GetDependencies("name", init: wb => + { + wb.DefinedNames.Add("name", "1+3"); + }); + CollectionAssert.IsEmpty(dependencies.Areas); + CollectionAssert.AreEquivalent(new[] { new XLName("name") }, dependencies.Names); + } + + [Test] + public void Name_range_can_be_sheet_scoped_even_without_specified_sheet() + { + // Formula that references a name that is ambiguous between workbook and worksheet scoped one. + const string formula = "name"; + var dependencies = GetDependencies(formula, init: wb => + { + // Define two names, the local one should be selected + wb.Worksheet("Sheet").DefinedNames.Add("name", "Sheet!$A$1"); + wb.DefinedNames.Add("name", "Sheet!$B$10"); + }); + CollectionAssert.AreEquivalent(new XLBookArea[] + { + new("Sheet", XLSheetRange.Parse("A1")) + }, dependencies.Areas); + CollectionAssert.AreEquivalent(new[] { new XLName("name") }, dependencies.Names); + } + + [Test] + [Ignore("A1 to R1C1 conversion not yet implemented and the name formula must be parsed")] + public void Name_range_that_uses_relative_reference_determines_actual_precedent_areas_through_cell_location() + { + var dependencies = GetDependencies("name", "D8", init: wb => + { + wb.DefinedNames.Add("name", "Sheet!B4"); // equivalent of R[3]C[2] + }); + CollectionAssert.AreEquivalent(new XLBookArea[] + { + new("Sheet", XLSheetRange.Parse("F7")), // D4 (formula cell) + R[3]C[2] (name relative reference) = F7 + }, dependencies.Areas); + CollectionAssert.AreEquivalent(new[] { new XLName("name") }, dependencies.Names); + } + + #endregion + + #region Remove formula from dependency tree + + [Test] + public void Remove_formula_from_dependency_tree() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var tree = new DependencyTree(); + tree.AddSheetTree(ws); + var cellFormula = AddFormula(tree, ws, "B3", "=C4"); + Assert.False(tree.IsEmpty); + + // Remove inserted formula removes the dependent and also removes the precedent + // area from the tree because there is no formula depending on it. + tree.RemoveFormula(cellFormula); + Assert.True(tree.IsEmpty); + + // Removing already removed formula doesn't throw. + Assert.DoesNotThrow(() => tree.RemoveFormula(cellFormula)); + Assert.True(tree.IsEmpty); + } + + [Test] + public void Removing_formula_doesnt_remove_precedent_area_from_tree_when_another_formula_depends_on_the_area() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var tree = new DependencyTree(); + tree.AddSheetTree(ws); + var cellFormulaA1 = AddFormula(tree, ws, "A1", "=C4 + B1"); + var cellFormulaA2 = AddFormula(tree, ws, "A2", "=B1 / C4"); + Assert.False(tree.IsEmpty); + + // Remove first formula, but the precedent area is still used + // by second formula so it is not removed. + tree.RemoveFormula(cellFormulaA1); + Assert.False(tree.IsEmpty); + + // Remove second formula + tree.RemoveFormula(cellFormulaA2); + Assert.True(tree.IsEmpty); + } + + #endregion + + #region Mark dirty + + [Test] + public void Mark_dirty_single_chain_is_fully_marked() + { + using var wb = new XLWorkbook(); + var tree = new DependencyTree(); + var ws = wb.AddWorksheet(); + tree.AddSheetTree(ws); + AddFormula(tree, ws, "A2", "=A1"); + AddFormula(tree, ws, "A3", "=A2"); + AddFormula(tree, ws, "A4", "=A3"); + + MarkDirty(tree, ws, "A1"); + AssertDirty(ws, "A2", "A3", "A4"); + } + + [Test] + public void Mark_dirty_split_and_join_is_fully_marked() + { + using var wb = new XLWorkbook(); + var tree = new DependencyTree(); + var ws = wb.AddWorksheet(); + tree.AddSheetTree(ws); + AddFormula(tree, ws, "B2", "=B1"); + AddFormula(tree, ws, "C1", "=B2"); + AddFormula(tree, ws, "C3", "=B2"); + AddFormula(tree, ws, "D2", "=C1 + C3"); + + MarkDirty(tree, ws, "B1"); + AssertDirty(ws, "B2", "C1", "C3", "D2"); + } + + [Test] + public void Mark_dirty_uses_correct_sheet() + { + using var wb = new XLWorkbook(); + var tree = new DependencyTree(); + var ws1 = wb.AddWorksheet("Sheet1"); + tree.AddSheetTree(ws1); + var ws2 = wb.AddWorksheet("Sheet2"); + tree.AddSheetTree(ws2); + + // Make a chain, where each cell is on an opposite sheet + AddFormula(tree, ws1, "B1", "=Sheet2!A1"); + AddFormula(tree, ws2, "C1", "=Sheet1!B1"); + AddFormula(tree, ws1, "D1", "=Sheet2!C1"); + AddFormula(tree, ws2, "E1", "=Sheet1!D1"); + + // Formulas on opposite sheet + AddFormula(tree, ws2, "B1", "=Sheet1!A1"); + AddFormula(tree, ws1, "C1", "=Sheet2!B1"); + AddFormula(tree, ws2, "D1", "=Sheet1!C1"); + AddFormula(tree, ws1, "E1", "=Sheet2!D1"); + + MarkDirty(tree, ws2, "A1"); + AssertDirty(ws1, "B1", "D1"); + AssertDirty(ws2, "C1", "E1"); + + AssertNotDirty(ws1, "C1", "E1"); + AssertNotDirty(ws2, "B1", "D1"); + } + + [Test] + public void Mark_dirty_stops_at_dirty_cell() + { + using var wb = new XLWorkbook(); + var tree = new DependencyTree(); + var ws = wb.AddWorksheet(); + tree.AddSheetTree(ws); + AddFormula(tree, ws, "A2", "=A1"); + AddFormula(tree, ws, "A3", "=A2"); + AddFormula(tree, ws, "A4", "=A3"); + + // Mark the middle one dirty, but A4 is still clear + ((XLCell)ws.Cell("A3")).Formula.IsDirty = true; + + MarkDirty(tree, ws, "A1"); + AssertDirty(ws, "A2", "A3"); + AssertNotDirty(ws, "A4"); // Propagation stopped at the dirty cell A3. + } + + [Test] + public void Mark_dirty_wont_crash_on_cycle() + { + using var wb = new XLWorkbook(); + var tree = new DependencyTree(); + var ws = wb.AddWorksheet(); + tree.AddSheetTree(ws); + AddFormula(tree, ws, "B1", "=D1 + A1"); + AddFormula(tree, ws, "C1", "=B1"); + AddFormula(tree, ws, "D1", "=C1"); + + // Tail depending on the cycle + AddFormula(tree, ws, "E1", "=D1"); + + MarkDirty(tree, ws, "A1"); + AssertDirty(ws, "B1", "C1", "D1", "E1"); + } + + [Test] + public void Mark_dirty_affects_precedents_with_partial_overlap() + { + using var wb = new XLWorkbook(); + var tree = new DependencyTree(); + var ws = wb.AddWorksheet(); + tree.AddSheetTree(ws); + AddFormula(tree, ws, "D1", "=A1:B3"); + + // B3:D4 overlaps with A1:B3 in B3 + MarkDirty(tree, ws, "B3:D4"); + AssertDirty(ws, "D1"); + } + + [Test] + public void Mark_dirty_can_affect_multiple_chains_at_once() + { + using var wb = new XLWorkbook(); + var tree = new DependencyTree(); + var ws = wb.AddWorksheet(); + tree.AddSheetTree(ws); + AddFormula(tree, ws, "B1", "=A1"); + AddFormula(tree, ws, "B2", "=A2"); + AddFormula(tree, ws, "B3", "=A3"); + + MarkDirty(tree, ws, "A2:A3"); + AssertDirty(ws, "B2", "B3"); + AssertNotDirty(ws, "B1"); + } + + #endregion + + #region Rename sheet + + [Test] + public void Sheet_rename_keeps_tree_same_only_with_changed_sheet_name() + { + using var wb = new XLWorkbook(); + var renamedSheet = wb.AddWorksheet("Original"); + var unchangedSheet = wb.AddWorksheet("Unchanged"); + + renamedSheet.Cell("A1").Value = 1; + renamedSheet.Cell("A2").Value = 2; + renamedSheet.Cell("A3").Value = 3; + renamedSheet.Cell("A4").FormulaA1 = "SUM(Original!A1:A2, A3, Unchanged!A1:A2)"; + unchangedSheet.Cell("A1").Value = 10; + unchangedSheet.Cell("A2").Value = 20; + unchangedSheet.Cell("A3").Value = 30; + unchangedSheet.Cell("A4").FormulaA1 = "SUM(Unchanged!A1:A2, A3, Original!A1:A2)"; + Recalculate(); + + renamedSheet.Name = "Renamed"; + + Assert.AreEqual("SUM(Renamed!A1:A2, A3, Unchanged!A1:A2)", renamedSheet.Cell("A4").FormulaA1); + Assert.AreEqual("SUM(Unchanged!A1:A2, A3, Renamed!A1:A2)", unchangedSheet.Cell("A4").FormulaA1); + + Recalculate(); + Assert.False(renamedSheet.Cell("A4").NeedsRecalculation); + Assert.False(unchangedSheet.Cell("A4").NeedsRecalculation); + + // Both depend on Unchanged!A1 + unchangedSheet.Cell("A1").Value = 110; + Assert.True(renamedSheet.Cell("A4").NeedsRecalculation); + Assert.True(unchangedSheet.Cell("A4").NeedsRecalculation); + Recalculate(); + Assert.AreEqual(136, renamedSheet.Cell("A4").CachedValue); + Assert.AreEqual(163, unchangedSheet.Cell("A4").CachedValue); + + // Both depend on Renamed!A1 + renamedSheet.Cell("A1").Value = 201; + Assert.True(renamedSheet.Cell("A4").NeedsRecalculation); + Assert.True(unchangedSheet.Cell("A4").NeedsRecalculation); + Recalculate(); + Assert.AreEqual(336, renamedSheet.Cell("A4").CachedValue); + Assert.AreEqual(363, unchangedSheet.Cell("A4").CachedValue); + + // Only unchanged depends on Unchanged!A3. The renamed formula keeps value. + unchangedSheet.Cell("A3").Value = 330; + Assert.False(renamedSheet.Cell("A4").NeedsRecalculation); + Assert.True(unchangedSheet.Cell("A4").NeedsRecalculation); + Recalculate(); + Assert.AreEqual(336, renamedSheet.Cell("A4").CachedValue); + Assert.AreEqual(663, unchangedSheet.Cell("A4").CachedValue); + + // Only renamed depends on Renamed!A3. The unchanged formula keeps value. + renamedSheet.Cell("A3").Value = 403; + Assert.True(renamedSheet.Cell("A4").NeedsRecalculation); + Assert.False(unchangedSheet.Cell("A4").NeedsRecalculation); + Recalculate(); + Assert.AreEqual(736, renamedSheet.Cell("A4").CachedValue); + Assert.AreEqual(663, unchangedSheet.Cell("A4").CachedValue); + + void Recalculate() + { + // Force recalculation to clear dirty flag. Recalculation always happens for whole + // calculation chain. + wb.CalcEngine.Recalculate(wb, null); + } + } + + #endregion + + private static XLCellFormula AddFormula(DependencyTree tree, IXLWorksheet sheet, string address, string formula) + { + // Set directly, so the cell is not marked as a dirty. + var cell = (XLCell)sheet.Cell(address); + cell.Formula = XLCellFormula.NormalA1(formula); + var cellArea = new XLBookArea(sheet.Name, new XLSheetRange(cell.SheetPoint, cell.SheetPoint)); + tree.AddFormula(cellArea, cell.Formula, sheet.Workbook); + return cell.Formula; + } + + private static void MarkDirty(DependencyTree tree, IXLWorksheet sheet, string range) + { + var area = new XLBookArea(sheet.Name, XLSheetRange.Parse(range)); + tree.MarkDirty(area); + } + + private static void AssertDirty(IXLWorksheet sheet, params string[] dirtyRanges) + { + AssertDirtyFlag(true, sheet, dirtyRanges); + } + private static void AssertNotDirty(IXLWorksheet sheet, params string[] dirtyRanges) + { + AssertDirtyFlag(false, sheet, dirtyRanges); + } + + private static void AssertDirtyFlag(bool expectedDirtyFlag, IXLWorksheet sheet, params string[] dirtyRanges) + { + var ws = (XLWorksheet)sheet; + foreach (var dirtyRange in dirtyRanges) + { + foreach (var dirtyCell in ws.Cells(dirtyRange)) + { + Assert.AreEqual(expectedDirtyFlag, dirtyCell.Formula?.IsDirty); + } + } + } + + private static FormulaDependencies GetDependencies(string formula, string formulaAddress = "A1", Action init = null) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); + init?.Invoke(wb); + var tree = new DependencyTree(); + var cell = ws.Cell(formulaAddress); + cell.SetFormulaA1(formula); + + var cellFormula = ((XLCell)cell).Formula; + var dependencies = tree.AddFormula(new XLBookArea(ws.Name, cellFormula.Range), cellFormula, wb); + return dependencies; + } + + public static IEnumerable AreaDependenciesTestCases + { + get + { + // When a visitor visits a node, there are two choices for found references: + // * propagate the reference to parent node (in most cases checked by range operator) + // * add the reference directly to the dependencies + + // A formula that is a simple reference is propagated to the root + yield return new object[] + { + "A1", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1")) + } + }; + + // References are in a multiple levels of an expression without ref expression or + // a function are added + yield return new object[] + { + "7+A1/(B1+C1)", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1")), + new XLBookArea("Sheet", XLSheetRange.Parse("B1")), + new XLBookArea("Sheet", XLSheetRange.Parse("C1")) + } + }; + + // Unary implicit intersection is propagated + yield return new object[] + { + // Due to issue ClosedParser#1, implicit intersection is not a part + // of ref_expression and I can't use `D3:@A1:C2` as a test case + "@A1:A4", + new[] + { + // Implicit intersection + new XLBookArea("Sheet", XLSheetRange.Parse("A1:A4")), + } + }; + + // Unary spill operator propagates a reference + yield return new object[] + { + "F2#:A7", + new[] + { + // This is not correct, but until spill operator works, + // but for now it provides best approximate for now. + new XLBookArea("Sheet", XLSheetRange.Parse("A2:F7")), + } + }; + + // Unary value operators (in this case percent) applied on reference adds it + yield return new object[] + { + "4+A4%", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A4")), + } + }; + + // Union operation propagates references + yield return new object[] + { + "(A1:B2,C1:D2):E3", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1:E3")) + } + }; + + // Range operation propagates + yield return new object[] + { + // Due to greedy nature, the A1:C4 is the first reference and D2 is the second + "A1:C4:D2", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1:D4")), + } + }; + + // Range operation with multiple operands + yield return new object[] + { + "A1:C4:IF(E10, D2, A10)", + new[] + { + // E10 is a value argument, thus isn't propagated, only added + new XLBookArea("Sheet", XLSheetRange.Parse("E10")), + // Areas from same sheet are unified into a single larger area + new XLBookArea("Sheet", XLSheetRange.Parse("A1:D10")) + } + }; + + // Range operator with multiple combinations + yield return new object[] + { + "IF(G4,Sheet!A1,Other!A2):IF(H3,Other!C4,C5)", + new[] + { + // G4 and H3 are not propagated to range operation, only added + new XLBookArea("Sheet", XLSheetRange.Parse("G4")), + new XLBookArea("Sheet", XLSheetRange.Parse("H3")), + + // Largest possible area in each sheet, based on references in the sheet + new XLBookArea("Sheet", XLSheetRange.Parse("A1:C5")), + new XLBookArea("Other", XLSheetRange.Parse("A2:C4")) + } + }; + + // Range operation when an argument isn't a reference doesn't + // create a range from both, adds + yield return new object[] + { + "INDEX({1},1,1):D2", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("D2")), + } + }; + + // Intersection - special case of one area against another area + yield return new object[] + { + "A1:C3 B2:D2", + new[] + { + // In this special case, intersection is evaluated + new XLBookArea("Sheet", XLSheetRange.Parse("B2:C2")), + } + }; + + // Intersection - multi area operands. Due to complexity, keep + // original ranges as dependencies. + yield return new object[] + { + "A1:E10 IF(TRUE,A1:C3,B2:D2)", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1:C3")), + new XLBookArea("Sheet", XLSheetRange.Parse("B2:D2")), + new XLBookArea("Sheet", XLSheetRange.Parse("A1:E10")), + } + }; + + // Value binary operation on references adds the references + yield return new object[] + { + "A1:B2 + A1:C4", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1:B2")), + new XLBookArea("Sheet", XLSheetRange.Parse("A1:C4")), + } + }; + + // IF function - value is added and true/false values are propagated + yield return new object[] + { + "IF(A1,B1,C1):D2", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1")), + new XLBookArea("Sheet", XLSheetRange.Parse("B1:D2")), + } + }; + + // IF function, but only false argument is reference + yield return new object[] + { + "IF(A1,5,B1):D2", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1")), + new XLBookArea("Sheet", XLSheetRange.Parse("B1:D2")), + } + }; + + // IF function, but only true argument is reference and is propagated + yield return new object[] + { + "IF(A1,B1):D2", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1")), + new XLBookArea("Sheet", XLSheetRange.Parse("B1:D2")), + } + }; + + // INDEX function propagates whole range of first argument + yield return new object[] + { + "INDEX(A1:C4,2,5):D2", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1:D4")), + } + }; + + // CHOOSE function adds first argument and propagates remaining arguments + yield return new object[] + { + "CHOOSE(A1,B1,5,C1):D2", + new[] + { + new XLBookArea("Sheet", XLSheetRange.Parse("A1")), + new XLBookArea("Sheet", XLSheetRange.Parse("B1:D2")), + } + }; + + // Non-ref functions add arguments + yield return new object[] + { + "POWER(SomeSheet!C4,Other!B1)", + new[] + { + new XLBookArea("SomeSheet", XLSheetRange.Parse("C4")), + new XLBookArea("Other", XLSheetRange.Parse("B1")), + } + }; + } + } + + public static IEnumerable NameDependenciesTestCases + { + get + { + yield return new object[] + { + "WorkbookName + 5", + new[] { new XLName("WorkbookName") } + }; + + yield return new object[] + { + "Sheet!Name", + new[] { new XLName("Sheet", "Name") } + }; + } + } + } +} diff --git a/ClosedXML.Tests/Excel/CalcEngine/FinancialTests.cs b/ClosedXML.Tests/Excel/CalcEngine/FinancialTests.cs index ce3ff8a37..4a7cef275 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/FinancialTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/FinancialTests.cs @@ -1,5 +1,4 @@ using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine.Exceptions; using NUnit.Framework; namespace ClosedXML.Tests.Excel.CalcEngine @@ -7,14 +6,110 @@ namespace ClosedXML.Tests.Excel.CalcEngine [TestFixture] public class FinancialTests { - private readonly double tolerance = 1e-10; + [TestCase("FV(0.06/12,10,-200,-500,1)", 2581.4033740601362)] + [TestCase("FV(0.12/12,12,-1000)", 12682.503013196976)] + [TestCase("FV(0.11/12,35,-2000,,1)", 82846.24637190059)] + [TestCase("FV(0.06/12,12,-100,-1000,1)", 2301.4018303409139)] + public void Fv_ReferenceExamplesFromExcelDocumentations(string formula, double expectedResult) + { + var actual = (double)XLWorkbook.EvaluateExpr(formula); + Assert.AreEqual(expectedResult, actual, XLHelper.Epsilon); + } + + [TestCase("FV(0,1,1000)", -1000)] // Zero interest rate + [TestCase("FV(0,5,10000,5000)", -55000.00)] // Zero interest rate with present value + [TestCase("FV(-0.4,2,1000)", -1600.00)] // Negative interest rate + [TestCase("FV(0.01,0.5,1000)", -498.75621120889502)] // Non-integer period + [TestCase("FV(0.1,-2,1000)", 1735.5371900826453)] // Negative periods + [TestCase("FV(0.1,2,0,4)", -4.84)] // No PMT, but present value + [TestCase("FV(0,2,-1000)", 2000.00)] // Negative PMT - money is paid to us + [TestCase("FV(0.000001,1000,1000)", -1000499.6661261424)] // Small number and high number of periods, check for stability + public void Fv_EdgeCases(string formula, double expectedResult) + { + var actual = (double)XLWorkbook.EvaluateExpr(formula); + Assert.AreEqual(expectedResult, actual, XLHelper.Epsilon); + } + + [Test] + public void Fv_DefaultFutureValueIsZero() + { + Assert.AreEqual(XLWorkbook.EvaluateExpr("FV(0.1,2,1000)"), XLWorkbook.EvaluateExpr("FV(0.1,2,1000,0)")); + } + + [Test] + public void Fv_DefaultTypeIsZero() + { + Assert.AreEqual(XLWorkbook.EvaluateExpr("FV(0.1,5,1000)"), XLWorkbook.EvaluateExpr("FV(0.1,5,1000,0,0)")); + } + + [Test] + public void Fv_ZeroPeriodsReturnsPresentValue() + { + Assert.AreEqual(-100, XLWorkbook.EvaluateExpr("FV(0.1,0,1000, 100)")); + } + + [TestCase("IPMT(0.1/12,1,3*12,8000)", -66.666666666666686)] + [TestCase("IPMT(0.1,3,3,8000)", -292.4471299093658)] + public void Ipmt_ReferenceExamplesFromExcelDocumentations(string formula, double expectedResult) + { + var actual = (double)XLWorkbook.EvaluateExpr(formula); + Assert.AreEqual(expectedResult, actual, XLHelper.Epsilon); + } + + [TestCase("IPMT(0,1,1,1000)", 0)] // Zero interest rate + [TestCase("IPMT(0,1,5,10000,5000)", 0)] // Zero interest rate with future value + [TestCase("IPMT(-0.4,1,2,1000)", 400.00)] // Negative interest rate + [TestCase("IPMT(0.01,1,0.5,1000)", -10.00)] // Non-integer period + [TestCase("IPMT(0.01,1,1.4,1000)", -10.00)] // Different non-integer period + [TestCase("IPMT(0.1,1,2,0,4)", 0)] // No principal, but future value + [TestCase("IPMT(0.1,1,2,-1000)", 100)] // Negative principal - money is paid to us + [TestCase("IPMT(0.000001,1,1000,1000)", -0.001)] // Small number and high number of periods, check for stability + public void Ipmt_EdgeCases(string formula, double expectedResult) + { + var actual = (double)XLWorkbook.EvaluateExpr(formula); + Assert.AreEqual(expectedResult, actual, XLHelper.Epsilon); + } + + [Test] + public void Ipmt_DefaultFutureValueIsZero() + { + Assert.AreEqual(XLWorkbook.EvaluateExpr("IPMT(0.1,1,2,1000)"), XLWorkbook.EvaluateExpr("IPMT(0.1,1,2,1000,0)")); + } + + [Test] + public void Ipmt_DefaultTypeIsZero() + { + Assert.AreEqual(XLWorkbook.EvaluateExpr("IPMT(0.1,1,5,1000)"), XLWorkbook.EvaluateExpr("IPMT(0.1,1,5,1000,0,0)")); + } + + [Test] + public void Ipmt_ZeroOrNegativePeriodsReturnsNumError() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("IPMT(0.1,1,0,1000)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("IPMT(0.1,1,-1,1000)")); + } + + [TestCase(-1)] + [TestCase(-1.5)] + [TestCase(-100)] + public void Ipmt_RateLessOrEqualMinusOneReturnsNumError(double rate) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"IPMT({rate},2,3,1000,10000,1)")); + } + + [Test] + public void Ipmt_PeriodOutOfRangeReturnsNumError() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("IPMT(0.1,0,1,1000)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("IPMT(0.1,2,1,1000)")); + } [TestCase("PMT(0.08/12,10,10000)", -1037.03208935915)] [TestCase("PMT(0.08/12,10,10000,0,1)", -1030.16432717797)] public void Pmt_ReferenceExamplesFromExcelDocumentations(string formula, double expectedResult) { var actual = (double)XLWorkbook.EvaluateExpr(formula); - Assert.AreEqual(expectedResult, actual, tolerance); + Assert.AreEqual(expectedResult, actual, XLHelper.Epsilon); } [Test] @@ -35,7 +130,7 @@ public void Pmt_PaymentsMustPayForPrincipalAndFutureValue() public void Pmt_EdgeCases(string formula, double expectedResult) { var actual = (double)XLWorkbook.EvaluateExpr(formula); - Assert.AreEqual(expectedResult, actual, tolerance); + Assert.AreEqual(expectedResult, actual, XLHelper.Epsilon); } [Test] @@ -66,7 +161,15 @@ public void Pmt_DefaultTypeIsZero() [Test] public void Pmt_ZeroPeriodsReturnsNumError() { - Assert.Throws(() => XLWorkbook.EvaluateExpr("PMT(0.1,0,1000)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("PMT(0.1,0,1000)")); + } + + [TestCase(-1)] + [TestCase(-1.5)] + [TestCase(-100)] + public void Pmt_RateLessOrEqualMinusOneReturnsNumError(double rate) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"PMT({rate},1,1000,5000,1)")); } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/FormulaCachingTests.cs b/ClosedXML.Tests/Excel/CalcEngine/FormulaCachingTests.cs index 41e212682..5204e5f01 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/FormulaCachingTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/FormulaCachingTests.cs @@ -1,5 +1,4 @@ using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine; using NUnit.Framework; using System; using System.Linq; @@ -9,32 +8,6 @@ namespace ClosedXML.Tests.Excel.CalcEngine [TestFixture] public class FormulaCachingTests { - [Test] - public void NewWorkbookDoesNotNeedRecalculation() - { - using (var wb = new XLWorkbook()) - { - var sheet = wb.Worksheets.Add("TestSheet"); - var cell = sheet.Cell(1, 1); - - Assert.AreEqual(0, wb.RecalculationCounter); - Assert.IsFalse(cell.NeedsRecalculation); - } - } - - [Test] - public void EditCellCausesCounterIncreasing() - { - using (var wb = new XLWorkbook()) - { - var sheet = wb.Worksheets.Add("TestSheet"); - var cell = sheet.Cell(1, 1); - cell.Value = "1234567"; - - Assert.Greater(wb.RecalculationCounter, 0); - } - } - [Test] public void StaticCellDoesNotNeedRecalculation() { @@ -122,30 +95,30 @@ public void InsertRowInvalidatesValues() var a4 = sheet.Cell("A4"); a4.FormulaA1 = "=COUNTBLANK(A1:A3)"; - var res1 = a4.Value; + Assert.AreEqual(3, a4.Value); + sheet.Row(2).InsertRowsAbove(2); - var res2 = a4.Value; - Assert.AreEqual(3, res1); - Assert.AreEqual(5, res2); + Assert.AreEqual(5, sheet.Cell("A6").Value); } } [Test] - public void DeleteRowInvalidatesValues() + public void DeleteRowModifiesFormulaAndInvalidatesValues() { using (var wb = new XLWorkbook()) { var sheet = wb.Worksheets.Add("TestSheet"); - var a4 = sheet.Cell("A4"); - a4.FormulaA1 = "=COUNTBLANK(A1:A3)"; + var original = sheet.Cell("A4"); + original.FormulaA1 = "=COUNTBLANK(A1:A3)"; + + Assert.AreEqual(3, original.Value); - var res1 = a4.Value; sheet.Row(2).Delete(); - var res2 = a4.Value; - Assert.AreEqual(3, res1); - Assert.AreEqual(2, res2); + var shifted = sheet.Cell("A3"); + Assert.AreEqual("COUNTBLANK(A1:A2)", shifted.FormulaA1); + Assert.AreEqual(2, shifted.Value); } } @@ -309,32 +282,11 @@ public void DeleteWorksheetInvalidatesValues() sheet2.Delete(); var valueAfterDeletion = sheet1_a1.Value; - Assert.AreEqual("TestValue", valueBeforeDeletion.ToString()); + Assert.AreEqual("TestValue", valueBeforeDeletion); Assert.AreEqual(XLError.CellReference, valueAfterDeletion); } } - [Test] - public void TestValueCellsCachedValue() - { - using (var wb = new XLWorkbook()) - { - var sheet = wb.Worksheets.Add("TestSheet"); - var cell = sheet.Cell(1, 1); - - var date = new DateTime(2018, 4, 19); ; - cell.Value = date; - - Assert.AreEqual(XLDataType.DateTime, cell.DataType); - Assert.AreEqual(date, cell.CachedValue); - - cell.DataType = XLDataType.Number; - - Assert.AreEqual(XLDataType.Number, cell.DataType); - Assert.AreEqual(date.ToOADate(), cell.CachedValue); - } - } - [Test] public void CachedValueToExternalWorkbook() { @@ -359,32 +311,21 @@ public void CachedValueToExternalWorkbook() } [Test] - public void ChangingDataTypeChangesCachedValue() + public void ChangingValueChangesCachedValue() { using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet("Test"); - ws.Cell(1, 1).Value = new DateTime(2019, 1, 1, 14, 0, 0); - ws.Cell(1, 2).Value = new DateTime(2019, 1, 1, 17, 45, 0); - var cell = ws.Cell(1, 3); - cell.FormulaA1 = "=B1-A1"; - - Assert.IsNull(cell.CachedValue); - - double value = (double)cell.Value; - Assert.AreEqual(value, cell.CachedValue); + var cell = ws.Cell(1, 1); - cell.DataType = XLDataType.DateTime; - Assert.AreEqual(DateTime.FromOADate(value), cell.CachedValue); - Assert.AreEqual("12/30/1899 03:45:00", cell.GetFormattedString()); + cell.Value = "Hello"; + Assert.AreEqual("Hello", cell.CachedValue); - cell.DataType = XLDataType.Number; - Assert.AreEqual(value, (double)cell.CachedValue, 1e-10); - Assert.AreEqual("0.15625", cell.GetFormattedString()); + cell.Value = 74.0; + Assert.AreEqual(74.0, cell.CachedValue); - cell.DataType = XLDataType.TimeSpan; - Assert.AreEqual(TimeSpan.FromDays(value), (TimeSpan)cell.CachedValue); - Assert.AreEqual("03:45:00", cell.GetFormattedString()); // I think the seconds in this string is due to a shortcoming in the ExcelNumberFormat library + cell.Value = new DateTime(2019, 1, 1, 14, 0, 0); + Assert.AreEqual(new DateTime(2019, 1, 1, 14, 0, 0), cell.CachedValue); } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/FormulaCstToAstTests.cs b/ClosedXML.Tests/Excel/CalcEngine/FormulaCstToAstTests.cs deleted file mode 100644 index 1089e1d25..000000000 --- a/ClosedXML.Tests/Excel/CalcEngine/FormulaCstToAstTests.cs +++ /dev/null @@ -1,790 +0,0 @@ -using Irony.Parsing; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using ClosedXML.Excel.CalcEngine; -using XLParser; -using ScalarNode = ClosedXML.Excel.CalcEngine.ScalarNode; -using NotSupportedNode = ClosedXML.Excel.CalcEngine.NotSupportedNode; -using ReferenceNode = ClosedXML.Excel.CalcEngine.ReferenceNode; -using AstNode = ClosedXML.Excel.CalcEngine.AstNode; -using PrefixNode = ClosedXML.Excel.CalcEngine.PrefixNode; -using FileNode = ClosedXML.Excel.CalcEngine.FileNode; -using FormulaParser = ClosedXML.Excel.CalcEngine.FormulaParser; -using StructuredReferenceNode = ClosedXML.Excel.CalcEngine.StructuredReferenceNode; -using static XLParser.GrammarNames; - -namespace ClosedXML.Tests.Excel.CalcEngine -{ - /// - /// Tests checking conversion from concrete syntax tree produced by XLParser to abstract syntax tree used by CalcEngine. - /// Only shape of CST and AST is checked. This is protection against changes of the grammar and verification that AST if correctly created from CST. - /// - [TestFixture] - public class FormulaCstToAstTests - { - [Test] - [TestCaseSource(nameof(FormulaWithCstAndAst))] - public void FormulaProducesCorrectCstAndAst(string formula, string[] expectedCst, Type[] expectedAst) - { - var dummyFunctions = new FunctionRegistry(); - dummyFunctions.RegisterFunction("SUM", 0, 255, x => throw new InvalidOperationException()); - dummyFunctions.RegisterFunction("SIN", 1, 1, x => throw new InvalidOperationException()); - dummyFunctions.RegisterFunction("RAND", 0, 0, x => throw new InvalidOperationException()); - dummyFunctions.RegisterFunction("IF", 0, 3, x => throw new InvalidOperationException()); - dummyFunctions.RegisterFunction("INDEX", 1, 3, x => throw new InvalidOperationException()); - var parser = new FormulaParser(dummyFunctions); - - var cst = parser.ParseCst(formula); - var linearizedCst = LinearizeCst(cst); - CollectionAssert.AreEqual(expectedCst, linearizedCst); - - var ast = parser.ConvertToAst(cst); - var linearizedAst = LinearizeAst(ast.AstRoot); - CollectionAssert.AreEqual(expectedAst, linearizedAst); - } - - private static System.Collections.IEnumerable FormulaWithCstAndAst() - { - // Trees are serialized using standard tree linearization algorithm - // non-null value - create a new child of current node and move to the child - // null - go to parent of current node - // nulls at the end of traversal are omitted - - // Keep order of test cases same as the order of tested rules in the ExcelFormulaGrammar. Complex ad hoc formulas should go to the end. - // A lot of test seem like duplicates, but keep them - goal is to have at least one test for each rule . - // During XLparser update, compare original grammar with new one and update these tests according to changes. - - // Test are in sync with XLParser 1.5.2 - - // Start.Rule = FormulaWithEq - yield return new TestCaseData( - "=1", - new[] { FormulaWithEq, "=", null, GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(ScalarNode) }); - - // Start.Rule = Formula - yield return new TestCaseData( - "1", - new[] { GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(ScalarNode) }); - - // Start.Rule = ArrayFormula - yield return new TestCaseData( - "{=1}", - new[] { ArrayFormula, "=", null, GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(NotSupportedNode) }); - - // Start.Rule = MultiRangeFormula - yield return new TestCaseData( - "=A1,B5", - new[] { MultiRangeFormula, "=", null, Union, GrammarNames.Reference, Cell, TokenCell, null, null, null, GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(BinaryNode), typeof(ReferenceNode), null, typeof(ReferenceNode) }); - - // ArrayFormula.Rule = OpenCurlyParen + eqop + Formula + CloseCurlyParen; - yield return new TestCaseData( - "{=1}", - new[] { ArrayFormula, "=", null, GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(NotSupportedNode) }); - - // MultiRangeFormula.Rule = eqop + Union; - yield return new TestCaseData( - "=FirstRange,A1B1", - new[] { MultiRangeFormula, "=", null, Union, GrammarNames.Reference, NamedRange, TokenName, null, null, null, GrammarNames.Reference, NamedRange, TokenNamedRangeCombination }, - new[] { typeof(BinaryNode), typeof(NameNode), null, typeof(NameNode) }); - - // FormulaWithEq.Rule = eqop + Formula; - yield return new TestCaseData( - "=1", - new[] { FormulaWithEq, "=", null, GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(ScalarNode) }); - - // Formula.Rule = Reference - yield return new TestCaseData( - "A1", - new[] { GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(ReferenceNode) }); - - // Formula.Rule = Constant - yield return new TestCaseData( - "1", - new[] { GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(ScalarNode) }); - - // Formula.Rule = FunctionCall - yield return new TestCaseData( - "+1", - new[] { GrammarNames.Formula, FunctionCall, "+", null, GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(UnaryNode), typeof(ScalarNode) }); - - // Formula.Rule = ConstantArray - yield return new TestCaseData( - "{1}", - new[] { GrammarNames.Formula, ConstantArray, ArrayColumns, ArrayRows, ArrayConstant, Constant, Number, TokenNumber }, - new[] { typeof(NotSupportedNode) }); - - // Formula.Rule = OpenParen + Formula + CloseParen - yield return new TestCaseData( - "(1)", - new[] { GrammarNames.Formula, /* ")" is transient */ GrammarNames.Formula /* ")" is transient */, Constant, Number, TokenNumber }, - new[] { typeof(ScalarNode) }); - - // Formula.Rule = ReservedName - yield return new TestCaseData( - "_xlnm.SomeName", - new[] { GrammarNames.Formula, ReservedName, TokenReservedName }, - new[] { typeof(NotSupportedNode) }); - - // ReservedName.Rule = ReservedNameToken - yield return new TestCaseData( - "_xlnm.OtherName", - new[] { GrammarNames.Formula, ReservedName, TokenReservedName }, - new[] { typeof(NotSupportedNode) }); - - // Constant.Rule = Number - yield return new TestCaseData( - "1", - new[] { GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(ScalarNode) }); - - // Constant.Rule = Text - yield return new TestCaseData( - "\"\"", - new[] { GrammarNames.Formula, Constant, GrammarNames.Text, TokenText }, - new[] { typeof(ScalarNode) }); - - // Constant.Rule = Bool - yield return new TestCaseData( - "TRUE", - new[] { GrammarNames.Formula, Constant, Bool, TokenBool }, - new[] { typeof(ScalarNode) }); - - // Constant.Rule = Error - yield return new TestCaseData( - "#DIV/0!", - new[] { GrammarNames.Formula, Constant, GrammarNames.Error, TokenError }, - new[] { typeof(ScalarNode) }); - - // Text.Rule = TextToken; - yield return new TestCaseData( - "\"Some text with \"\"enclosed\"\" quotes \"", - new[] { GrammarNames.Formula, Constant, GrammarNames.Text, TokenText }, - new[] { typeof(ScalarNode) }); - - // Number.Rule = NumberToken; - yield return new TestCaseData( - "123.4e-1", - new[] { GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(ScalarNode) }); - - // Bool.Rule = BoolToken; - yield return new TestCaseData( - "TRUE", - new[] { GrammarNames.Formula, Constant, Bool, TokenBool }, - new[] { typeof(ScalarNode) }); - - // Error.Rule = ErrorToken; - yield return new TestCaseData( - "#VALUE!", - new[] { GrammarNames.Formula, Constant, GrammarNames.Error, TokenError }, - new[] { typeof(ScalarNode) }); - - // RefError.Rule = RefErrorToken; - yield return new TestCaseData( - "#REF!", - new[] { GrammarNames.Formula, GrammarNames.Reference, RefError, TokenRefError }, - new[] { typeof(ScalarNode) }); - - // FunctionCall.Rule = FunctionName + Arguments + CloseParen - yield return new TestCaseData( - "SUM(1)", - new[] { GrammarNames.Formula, FunctionCall, FunctionName, ExcelFunction, null, null, Arguments, Argument, GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(FunctionNode), typeof(ScalarNode) }); - - // FunctionCall.Rule = PrefixOp + Formula - yield return new TestCaseData( - "-1", - new[] { GrammarNames.Formula, FunctionCall, "-", null, GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(UnaryNode), typeof(ScalarNode) }); - - // FunctionCall.Rule = Formula + PostfixOp - yield return new TestCaseData( - "1%", - new[] { GrammarNames.Formula, FunctionCall, GrammarNames.Formula, Constant, Number, TokenNumber, null, null, null, null, "%" }, - new[] { typeof(UnaryNode), typeof(ScalarNode) }); - - // FunctionCall.Rule = Formula + InfixOp + Formula - yield return new TestCaseData( - "1+2", - new[] { GrammarNames.Formula, FunctionCall, GrammarNames.Formula, Constant, Number, TokenNumber, null, null, null, null, "+", null, GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(BinaryNode), typeof(ScalarNode), null, typeof(ScalarNode) }); - - // FunctionName.Rule = ExcelFunction; - yield return new TestCaseData( - "RAND()", - new[] { GrammarNames.Formula, FunctionCall, FunctionName, ExcelFunction, null, null, Arguments }, - new[] { typeof(FunctionNode) }); - - // Arguments.Rule = MakeStarRule(Arguments, comma, Argument); - yield return new TestCaseData( - "SUM(\"1\", TRUE)", - new[] { GrammarNames.Formula, FunctionCall, FunctionName, ExcelFunction, null, null, Arguments, - Argument, GrammarNames.Formula, Constant, GrammarNames.Text, TokenText, null, null, null, null, null, - Argument, GrammarNames.Formula, Constant, Bool, TokenBool }, - new[] { typeof(FunctionNode), typeof(ScalarNode), null, typeof(ScalarNode) }); - - // EmptyArgument.Rule = EmptyArgumentToken; - yield return new TestCaseData( - "SUM(,)", - new[] { GrammarNames.Formula, FunctionCall, FunctionName, ExcelFunction, null, null, Arguments, - Argument, EmptyArgument, TokenEmptyArgument, null, null, null, - Argument, EmptyArgument, TokenEmptyArgument }, - new[] { typeof(FunctionNode), typeof(ScalarNode), null, typeof(ScalarNode) }); - - // Argument.Rule = Formula | EmptyArgument; - yield return new TestCaseData( - "IF(,1,)", - new[] { GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, RefFunctionName, TokenExcelConditionalRefFunction, null, null, Arguments, - Argument, EmptyArgument, TokenEmptyArgument, null, null, null, - Argument, GrammarNames.Formula, Constant, Number, TokenNumber , null, null, null, null, null, - Argument, EmptyArgument, TokenEmptyArgument }, - new[] { typeof(FunctionNode), typeof(ScalarNode), null, typeof(ScalarNode), null, typeof(ScalarNode) }); - - // PrefixOp.Rule = ImplyPrecedenceHere(Precedence.UnaryPreFix) + plusop | ImplyPrecedenceHere(Precedence.UnaryPreFix) + minop | ImplyPrecedenceHere(Precedence.UnaryPreFix) + at; - yield return new TestCaseData( - "@A1", - new[] { GrammarNames.Formula, FunctionCall, "@", null, GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(UnaryNode), typeof(ReferenceNode) }); - - // InfixOp.Rule = expop | mulop | divop | plusop | minop | concatop | gtop | eqop | ltop | neqop | gteop | lteop; - yield return new TestCaseData( - "A1^2", - new[] { GrammarNames.Formula, FunctionCall, - GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell, null, null, null, null, - "^", null, - GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(BinaryNode), typeof(ReferenceNode), null, typeof(ScalarNode) }); - - // PostfixOp.Rule = PreferShiftHere() + percentop; - yield return new TestCaseData( - "A1%", - new[] { GrammarNames.Formula, FunctionCall, GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell, null, null, null, null, "%" }, - new[] { typeof(UnaryNode), typeof(ReferenceNode) }); - - // Reference.Rule = ReferenceItem - yield return new TestCaseData( - "=A1", - new[] { FormulaWithEq, "=", null, GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(ReferenceNode) }); - - // Reference.Rule = ReferenceFunctionCall - yield return new TestCaseData( - "A1:D5", - new[] { GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, GrammarNames.Reference, Cell, TokenCell, null, null, null, ":", null,GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(BinaryNode), typeof(ReferenceNode), null, typeof(ReferenceNode) }); - - // ReferenceFunctionCall.Rule = Reference + intersectop + Reference - yield return new TestCaseData( - "A1 D5", - new[] { GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, GrammarNames.Reference, Cell, TokenCell, null, null, null, TokenIntersect, null, GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(BinaryNode), typeof(ReferenceNode), null, typeof(ReferenceNode) }); - - // ReferenceFunctionCall.Rule = OpenParen + Union + CloseParen - yield return new TestCaseData( - "(A1,A2)", - new[] { GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, Union, GrammarNames.Reference, Cell, TokenCell, null, null, null, GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(BinaryNode), typeof(ReferenceNode), null, typeof(ReferenceNode) }); - - // XLParser considers the 5 functions that can return reference to be special. - // ReferenceFunctionCall.Rule = RefFunctionName + Arguments + CloseParen - yield return new TestCaseData( - "IF(TRUE, A1, B2)", - new[] { GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, RefFunctionName, TokenExcelConditionalRefFunction, null, null, Arguments, - Argument, GrammarNames.Formula, Constant, Bool, TokenBool, null, null, null, null, null, - Argument, GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell, null, null, null, null, null, - Argument, GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(FunctionNode), typeof(ScalarNode), null, typeof(ReferenceNode), null, typeof(ReferenceNode) }); - - // ReferenceFunctionCall.Rule = Reference + hash - yield return new TestCaseData( - "A1#", - new[] { GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, GrammarNames.Reference, Cell, TokenCell, null, null, null, "#" }, - new[] { typeof(UnaryNode), typeof(ReferenceNode) }); - - // RefFunctionName.Rule = ExcelRefFunctionToken | ExcelConditionalRefFunctionToken; - yield return new TestCaseData( - "INDEX(A1,1,1)", - new[] { GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, RefFunctionName, TokenExcelRefFunction, null, null, Arguments, - Argument, GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell, null, null, null, null, null, - Argument, GrammarNames.Formula, Constant, Number, TokenNumber, null, null, null, null, null, - Argument, GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(FunctionNode), typeof(ReferenceNode), null, typeof(ScalarNode), null, typeof(ScalarNode) }); - - // Union.Rule = MakePlusRule(Union, comma, Reference); - yield return new TestCaseData( - "(A1,A2,A3)", - new[] { GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, Union, - GrammarNames.Reference, Cell, TokenCell, null, null, null, - GrammarNames.Reference, Cell, TokenCell, null, null, null, - GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(BinaryNode), typeof(BinaryNode), typeof(ReferenceNode), null, typeof(ReferenceNode), null, null, typeof(ReferenceNode) }); - - // ReferenceItem.Rule = Cell - yield return new TestCaseData( - "ZZ256", - new[] { GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(ReferenceNode) }); - - // ReferenceItem.Rule = NamedRange - yield return new TestCaseData( - "SomeRange", - new[] { GrammarNames.Formula, GrammarNames.Reference, NamedRange, TokenName }, - new[] { typeof(NameNode) }); - - // ReferenceItem.Rule = VRange - yield return new TestCaseData( - "A:ZZ", - new[] { GrammarNames.Formula, GrammarNames.Reference, VerticalRange, TokenVRange }, - new[] { typeof(ReferenceNode) }); - - // ReferenceItem.Rule = HRange - yield return new TestCaseData( - "15:40", - new[] { GrammarNames.Formula, GrammarNames.Reference, HorizontalRange, TokenHRange }, - new[] { typeof(ReferenceNode) }); - - // ReferenceItem.Rule = RefError - yield return new TestCaseData( - "#REF!", - new[] { GrammarNames.Formula, GrammarNames.Reference, RefError, TokenRefError }, - new[] { typeof(ScalarNode) }); - - yield return new TestCaseData( - "A1:(#REF!)", - new[] { GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, GrammarNames.Reference, Cell, TokenCell, null, null, null, ":", null, GrammarNames.Reference, GrammarNames.Reference, RefError, TokenRefError }, - new[] { typeof(BinaryNode), typeof(ReferenceNode), null, typeof(ScalarNode) }); - - // ReferenceItem.Rule = UDFunctionCall - yield return new TestCaseData( - "Fun()", - new[] { GrammarNames.Formula, GrammarNames.Reference, UDFunctionCall, UDFName, TokenUDF, null, null, Arguments }, - new[] { typeof(FunctionNode) }); - - // ReferenceItem.Rule = StructuredReference - yield return new TestCaseData( - "[#All]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // UDFunctionCall.Rule = UDFName + Arguments + CloseParen; - yield return new TestCaseData( - "CustomUdfFunction(TRUE)", - new[] { GrammarNames.Formula, GrammarNames.Reference, UDFunctionCall, UDFName, TokenUDF, null, null, Arguments, Argument, GrammarNames.Formula, Constant, Bool, TokenBool }, - new[] { typeof(FunctionNode), typeof(ScalarNode) }); - - // UDFName.Rule = UDFToken; - yield return new TestCaseData( - "_xll.CustomFunc()", - new[] { GrammarNames.Formula, GrammarNames.Reference, UDFunctionCall, UDFName, TokenUDF, null, null, Arguments }, - new[] { typeof(FunctionNode) }); - - // VRange.Rule = VRangeToken; - // BUG in XLParser 1.5.2, it considers A:XFD as A:XF union D (named token) - // yield return new TestCaseData( - // "A:XFD", - // new[] { Formula, Reference, ReferenceFunctionCall, VerticalRange, TokenVRange }, - // new[] { typeof(ReferenceNode) }); - - // HRange.Rule = HRangeToken; - yield return new TestCaseData( - "1:1048576", - new[] { GrammarNames.Formula, GrammarNames.Reference, HorizontalRange, TokenHRange }, - new[] { typeof(ReferenceNode) }); - - // Cell.Rule = CellToken; - yield return new TestCaseData( - "$XFD$1048576", - new[] { GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell }, - new[] { typeof(ReferenceNode) }); - - // File.Rule = FileNameNumericToken - yield return new TestCaseData( - "[1]!NamedRange", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, File, TokenFileNameNumeric, null, null, "!", null, null, NamedRange, TokenName }, - new[] { typeof(NameNode), typeof(PrefixNode), typeof(FileNode) }); - - // File.Rule = FileNameEnclosedInBracketsToken - yield return new TestCaseData( - "[file with space.xlsx]!NamedRange", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, File, TokenFileNameEnclosedInBrackets, null, null, "!", null, null, NamedRange, TokenName }, - new[] { typeof(NameNode), typeof(PrefixNode), typeof(FileNode) }); - - // File.Rule = FilePathToken + FileNameEnclosedInBracketsToken - yield return new TestCaseData( - @"C:\temp\[file with space.xlsx]!NamedRange", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, File, TokenFilePath, null, TokenFileNameEnclosedInBrackets, null, null, "!", null, null, NamedRange, TokenName }, - new[] { typeof(NameNode), typeof(PrefixNode), typeof(FileNode) }); - - // File.Rule = FilePathToken + FileName - yield return new TestCaseData( - @"C:\temp\file.xlsx!NamedRange", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, File, TokenFilePath, null, TokenFileName, null, null, "!", null, null, NamedRange, TokenName }, - new[] { typeof(NameNode), typeof(PrefixNode), typeof(FileNode) }); - - // DDX - Windows only interprocess communication standard that uses a shared memory - that is the future :) - // DynamicDataExchange.Rule = File + exclamationMark + SingleQuotedStringToken; - yield return new TestCaseData( - @"[C:\Program files\Company\program.exe]!'arg0,1'", - new[] { GrammarNames.Formula, GrammarNames.Reference, DynamicDataExchange, File, TokenFileNameEnclosedInBrackets, null, null, "!", null, TokenSingleQuotedString }, - new[] { typeof(NotSupportedNode) }); - - // NamedRange.Rule = NameToken | NamedRangeCombinationToken; - yield return new TestCaseData( - "A1Z5", - new[] { GrammarNames.Formula, GrammarNames.Reference, NamedRange, TokenNamedRangeCombination }, - new[] { typeof(NameNode) }); - - // Prefix.Rule = SheetToken - yield return new TestCaseData( - "Sheet1!A1", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, TokenSheet, null, null, Cell, TokenCell }, - new[] { typeof(ReferenceNode), typeof(PrefixNode) }); - - // Prefix.Rule = QuoteS + SheetQuotedToken - yield return new TestCaseData( - "'Name with space'!NamedRange", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, "'", null, TokenSheetQuoted, null, null, NamedRange, TokenName }, - new[] { typeof(NameNode), typeof(PrefixNode) }); - - // Prefix.Rule = File + SheetToken - yield return new TestCaseData( - "[1]Sheet!A1", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, File, TokenFileNameNumeric, null, null, TokenSheet, null, null, Cell, TokenCell }, - new[] { typeof(ReferenceNode), typeof(PrefixNode), typeof(FileNode) }); - - // Prefix.Rule = QuoteS + File + SheetQuotedToken - yield return new TestCaseData( - @"'C:\temp\[file.xlsx]Sheet1'!A1", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, "'", null, File, TokenFilePath, null, TokenFileNameEnclosedInBrackets, null, null, TokenSheetQuoted, null, null, Cell, TokenCell }, - new[] { typeof(ReferenceNode), typeof(PrefixNode), typeof(FileNode) }); - - // Prefix.Rule = File + exclamationMark - yield return new TestCaseData( - "[file.xlsx]!NamedRange", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, File, TokenFileNameEnclosedInBrackets, null, null, "!", null, null, NamedRange, TokenName }, - new[] { typeof(NameNode), typeof(PrefixNode), typeof(FileNode) }); - - // Prefix.Rule = MultipleSheetsToken - yield return new TestCaseData( - "Jan:Feb!A1", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, TokenMultipleSheets, null, null, Cell, TokenCell }, - new[] { typeof(ReferenceNode), typeof(PrefixNode) }); - - // Prefix.Rule = QuoteS + MultipleSheetsQuotedToken - yield return new TestCaseData( - "'Human Resources:Facility Management'!A1", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, "'", null, TokenMultipleSheetsQuoted, null, null, Cell, TokenCell }, - new[] { typeof(ReferenceNode), typeof(PrefixNode) }); - - // Prefix.Rule = File + MultipleSheetsToken - yield return new TestCaseData( - "[1]Jan:Dec!A1", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, File, TokenFileNameNumeric, null, null, TokenMultipleSheets, null, null, Cell, TokenCell }, - new[] { typeof(ReferenceNode), typeof(PrefixNode), typeof(FileNode) }); - - // Prefix.Rule = QuoteS + File + MultipleSheetsQuotedToken - yield return new TestCaseData( - "'[7]Human Resources:Facility Management'!A1", - new[] { GrammarNames.Formula, GrammarNames.Reference, Prefix, "'", null, File, TokenFileNameNumeric, null, null, TokenMultipleSheetsQuoted, null, null, Cell, TokenCell }, - new[] { typeof(ReferenceNode), typeof(PrefixNode), typeof(FileNode) }); - - // Prefix.Rule = RefErrorToken - yield return new TestCaseData( - "#REF!", - new[] { GrammarNames.Formula, GrammarNames.Reference, RefError, TokenRefError }, - new[] { typeof(ScalarNode) }); - - // StructuredReferenceElement.Rule = OpenSquareParen + SRColumnToken + CloseSquareParen - // BUG in XLParser 1.5.2, FileNameEnclosedInBracketsToken will always take preference, this can never happen. Square parenthesis are transient - // yield return new TestCaseData( - // "[[ColumnName]]", - // new[] { }, - // new[] { typeof() }); - - // StructuredReferenceElement.Rule = OpenSquareParen + NameToken + CloseSquareParen - // BUG in XLParser 1.5.2, FileNameEnclosedInBracketsToken will always take preference, this can never happen. Square parenthesis are transient - // yield return new TestCaseData( - // "[[ColumnName]]", - // new[] { }, - // new[] { typeof() }); - - // StructuredReferenceElement.Rule = FileNameEnclosedInBracketsToken - yield return new TestCaseData( - "[[Column Name]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReferenceTable.Rule = NameToken; - yield return new TestCaseData( - "SomeTable[]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceTable, TokenName }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReferenceExpression.Rule = StructuredReferenceElement - yield return new TestCaseData( - "[[Column Name]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReferenceExpression.Rule = at + StructuredReferenceElement - yield return new TestCaseData( - "[@[Sales Amount]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, "@", null, StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReferenceExpression.Rule = StructuredReferenceElement + colon + StructuredReferenceElement - yield return new TestCaseData( - "[[Sales Person]:[Region]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ":", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReferenceExpression.Rule = at + StructuredReferenceElement + colon + StructuredReferenceElement - yield return new TestCaseData( - "[@[Q1]:[Q4]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, - "@", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ":", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReferenceExpression.Rule = StructuredReferenceElement + comma + StructuredReferenceElement - yield return new TestCaseData( - "[[Europe],[Asia]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ",", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReferenceExpression.Rule = StructuredReferenceElement + comma + StructuredReferenceElement + colon + StructuredReferenceElement - yield return new TestCaseData( - "[[Last Year],[Jan]:[Dec]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ",", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ":", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // I have no idea why this term is in the XLGrammar grammar. It limits structural references to three columns.... - // StructuredReferenceExpression.Rule = StructuredReferenceElement + comma + StructuredReferenceElement + comma + StructuredReferenceElement - yield return new TestCaseData( - "[[First Column], [Second Column], [Third Column]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ",", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ",", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // More strangeness - // StructuredReferenceExpression.Rule = StructuredReferenceElement + comma + StructuredReferenceElement + comma + StructuredReferenceElement + colon + StructuredReferenceElement - yield return new TestCaseData( - "[[First Column], [Second Column], [Start Range Column]:[End Range Column]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ",", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ",", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ":", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReference.Rule = StructuredReferenceElement - yield return new TestCaseData( - "[Column]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReference.Rule = OpenSquareParen + StructuredReferenceExpression + CloseSquareParen - yield return new TestCaseData( - "[[Column]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceExpression, StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReference.Rule = StructuredReferenceTable + StructuredReferenceElement - yield return new TestCaseData( - "Sales[Jan]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceTable, TokenName, null, null, StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReference.Rule = StructuredReferenceTable + OpenSquareParen + CloseSquareParen - yield return new TestCaseData( - "Sales[]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceTable, TokenName }, - new[] { typeof(StructuredReferenceNode) }); - - // StructuredReference.Rule = StructuredReferenceTable + OpenSquareParen + StructuredReferenceExpression + CloseSquareParen - yield return new TestCaseData( - "DeptSales[[#Totals],[Sales Amount]:[Commission Amount]]", - new[] { GrammarNames.Formula, GrammarNames.Reference, StructuredReference, StructuredReferenceTable, TokenName, null, null, StructuredReferenceExpression, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ",", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets, null, null, - ":", null, - StructuredReferenceElement, TokenFileNameEnclosedInBrackets }, - new[] { typeof(StructuredReferenceNode) }); - - // ConstantArray.Rule = OpenCurlyParen + ArrayColumns + CloseCurlyParen; - yield return new TestCaseData( - "{1}", - new[] { GrammarNames.Formula, ConstantArray, ArrayColumns, ArrayRows, ArrayConstant, Constant, Number, TokenNumber }, - new[] { typeof(NotSupportedNode) }); - - // ArrayColumns.Rule = MakePlusRule(ArrayColumns, semicolon, ArrayRows); - yield return new TestCaseData( - "{1;TRUE;#DIV/0!}", - new[] { GrammarNames.Formula, ConstantArray, ArrayColumns, - ArrayRows, ArrayConstant, Constant, Number, TokenNumber, null, null, null, null, null, - ArrayRows, ArrayConstant, Constant, Bool, TokenBool, null, null, null, null, null, - ArrayRows, ArrayConstant, Constant, GrammarNames.Error, TokenError }, - new[] { typeof(NotSupportedNode) }); - - // ArrayRows.Rule = MakePlusRule(ArrayRows, comma, ArrayConstant); - yield return new TestCaseData( - "{1,TRUE,#DIV/0!}", - new[] { GrammarNames.Formula, ConstantArray, ArrayColumns, ArrayRows, - ArrayConstant, Constant, Number, TokenNumber, null, null, null, null, - ArrayConstant, Constant, Bool, TokenBool, null, null, null, null, - ArrayConstant, Constant, GrammarNames.Error, TokenError }, - new[] { typeof(NotSupportedNode) }); - - // ArrayConstant.Rule = Constant | PrefixOp + Number | RefError; - yield return new TestCaseData( - "{#DIV/0!,-1,#REF!}", - new[] { GrammarNames.Formula, ConstantArray, ArrayColumns, ArrayRows, - ArrayConstant, Constant, GrammarNames.Error, TokenError, null, null, null, null, - ArrayConstant, "-", null, Number, TokenNumber, null, null, null, - ArrayConstant, RefError, TokenRefError }, - new[] { typeof(NotSupportedNode) }); - - // -------------- Complex ad hoc test cases -------------- - - // Function within function - yield return new TestCaseData( - "=SUM(SIN(IF(A1,1,2)),3)", - new[] { FormulaWithEq, "=", null, GrammarNames.Formula, - FunctionCall /* SUM*/, FunctionName, ExcelFunction, null, null, Arguments, - Argument, GrammarNames.Formula, - FunctionCall /* SIN */, FunctionName, ExcelFunction, null, null, Arguments, - Argument, GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall /* IF*/ , RefFunctionName, TokenExcelConditionalRefFunction, null, null, Arguments, - Argument, GrammarNames.Formula, GrammarNames.Reference, Cell, TokenCell /* A1*/ , null, null, null, null, null, - Argument, GrammarNames.Formula, Constant, Number, TokenNumber /* 1 */, null, null, null, null, null, - Argument, GrammarNames.Formula, Constant, Number, TokenNumber /* 2 */, null, null, null, null, null, - null, null, null, null, null, null, null, null, null, - Argument, GrammarNames.Formula, Constant, Number, TokenNumber /* 3 */ }, - new[] { typeof(FunctionNode), /* SUM */ - typeof(FunctionNode), /* SIN */ - typeof(FunctionNode), /* IF */ - typeof(ReferenceNode), null, /* A1 */ - typeof(ScalarNode), null, /* 1 */ - typeof(ScalarNode), null, /* 2 */ - null, - null, - typeof(ScalarNode) /* 3 */ }); - - // Multiply reference area with a number - yield return new TestCaseData( - "=A1:B2 * 5", - new[] { FormulaWithEq, "=", null, GrammarNames.Formula, - FunctionCall, - GrammarNames.Formula, GrammarNames.Reference, ReferenceFunctionCall, - GrammarNames.Reference, Cell, TokenCell, null, null, null, - ":", null, - GrammarNames.Reference, Cell, TokenCell, null, null, null, null, null, null, - "*", null, - GrammarNames.Formula, Constant, Number, TokenNumber }, - new[] { typeof(BinaryNode), typeof(BinaryNode), typeof(ReferenceNode), null, typeof(ReferenceNode), null, null, typeof(ScalarNode) }); - } - - private static LinkedList LinearizeCst(ParseTree tree) - { - var result = new LinkedList(); - LinearizeCstNode(tree.Root, result); - RemoveNullsAtEnd(result); - return result; - - static void LinearizeCstNode(ParseTreeNode node, LinkedList linearized) - { - linearized.AddLast(node.Term.Name); - foreach (var child in node.ChildNodes) - LinearizeCstNode(child, linearized); - linearized.AddLast((string)null); - } - } - - private static readonly LinearizeVisitor _linearizeAstVisitor = new(); - - private static LinkedList LinearizeAst(AstNode root) - { - var result = new LinkedList(); - root.Accept(result, _linearizeAstVisitor); - RemoveNullsAtEnd(result); - return result; - } - - private static void RemoveNullsAtEnd(LinkedList list) - { - while (list.Count > 0 && list.Last.Value is null) - list.RemoveLast(); - } - - private class LinearizeVisitor : DefaultFormulaVisitor> - { - public override AstNode Visit(LinkedList context, ScalarNode node) - => LinearizeNode(context, typeof(ScalarNode), () => base.Visit(context, node)); - - public override AstNode Visit(LinkedList context, UnaryNode node) - => LinearizeNode(context, typeof(UnaryNode), () => base.Visit(context, node)); - - public override AstNode Visit(LinkedList context, BinaryNode node) - => LinearizeNode(context, typeof(BinaryNode), () => base.Visit(context, node)); - - public override AstNode Visit(LinkedList context, FunctionNode node) - => LinearizeNode(context, typeof(FunctionNode), () => base.Visit(context, node)); - - public override AstNode Visit(LinkedList context, NotSupportedNode node) - => LinearizeNode(context, typeof(NotSupportedNode), () => base.Visit(context, node)); - - public override AstNode Visit(LinkedList context, ReferenceNode node) - => LinearizeNode(context, typeof(ReferenceNode), () => base.Visit(context, node)); - - public override AstNode Visit(LinkedList context, NameNode node) - => LinearizeNode(context, typeof(NameNode), () => base.Visit(context, node)); - - public override AstNode Visit(LinkedList context, StructuredReferenceNode node) - => LinearizeNode(context, typeof(StructuredReferenceNode), () => base.Visit(context, node)); - - public override AstNode Visit(LinkedList context, PrefixNode node) - => LinearizeNode(context, typeof(PrefixNode), () => base.Visit(context, node)); - - public override AstNode Visit(LinkedList context, FileNode node) - => LinearizeNode(context, typeof(FileNode), () => base.Visit(context, node)); - - private AstNode LinearizeNode(LinkedList context, Type nodeType, Func func) - { - context.AddLast(nodeType); - var result = func(); - context.AddLast((Type)null); - return result; - } - } - } -} diff --git a/ClosedXML.Tests/Excel/CalcEngine/FormulaParserTests.cs b/ClosedXML.Tests/Excel/CalcEngine/FormulaParserTests.cs index b80ab4564..53ad1f4da 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/FormulaParserTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/FormulaParserTests.cs @@ -2,6 +2,7 @@ using ClosedXML.Excel.CalcEngine; using NUnit.Framework; using System; +using System.Collections.Generic; using System.Globalization; namespace ClosedXML.Tests.Excel.CalcEngine @@ -26,12 +27,6 @@ public void Formula_string_can_omit_starting_equal_sign() Assert.AreEqual(1, XLWorkbook.EvaluateExpr("1")); } - [TestCase] - public void Formula_string_can_be_an_array_formula() - { - AssertCanParseButNotEvaluate("{=1}", "Evaluation of array formula is not implemented."); - } - [TestCase] public void Root_formula_string_can_be_union_without_parenthesis() { @@ -75,7 +70,8 @@ public void Formula_can_be_function_call(string formula, object expectedValue) [TestCase] public void Formula_can_be_constant_array() { - AssertCanParseButNotEvaluate("={1,2,3;4,5,6}", "Evaluation of constant array is not implemented."); + // 1 is determined through implicit intersection (first element) + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("={1,2,3;4,5,6}")); } [TestCase("=(1)", 1)] @@ -87,13 +83,16 @@ public void Formula_can_be_another_formula_in_parenthesis(string formula, object #endregion #region Constant.Rule - [TestCase("=1", 1)] - [TestCase("=1.5", 1.5)] + [TestCase("=1", 1)] // int + [TestCase("=1.5", 1.5)] // double [TestCase("=1.23e2", 123)] [TestCase("=1.23e-1", 0.123)] [TestCase("=1.23e+3", 1230)] + [TestCase("=032399977109", 32399977109)] // long + [TestCase("=9223372036854775808", 9223372036854775808)] // BigInteger (long value + 1) public void Constant_can_be_number(string formula, double expectedNumber) { + // Irony returns number as an object of various types, e.g. int or double Assert.AreEqual(expectedNumber, XLWorkbook.EvaluateExpr(formula)); } @@ -147,7 +146,6 @@ public void FunctionCall_can_be_unary_prefix_operation(string formula, object ex } [TestCase("=75%", 0.75)] - [Ignore("Percent operation not yet implemented.")] public void FunctionCall_can_be_unary_postfix_operation(string formula, object expectedValue) { Assert.AreEqual(expectedValue, XLWorkbook.EvaluateExpr(formula)); @@ -160,7 +158,7 @@ public void FunctionCall_can_be_unary_postfix_operation(string formula, object e [TestCase("=3/2", 1.5)] [TestCase("=1+2", 3)] [TestCase("=3-5", -2)] - // [TestCase(@"=""A"" & ""B""", "AB")] + [TestCase(@"=""A"" & ""B""", "AB")] [TestCase("=2>1", true)] [TestCase("=1>2", false)] [TestCase("=5=5", true)] @@ -366,20 +364,86 @@ public void Reference_item_can_be_user_defined_function_call() [TestCase] public void Reference_item_can_be_structured_reference() { - AssertCanParseButNotEvaluate("=SomeTable[#Data]", "Evaluation of structured references is not implemented."); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertTable(new[] { new { Amount = 1 }, new { Amount = 2 } }); + + Assert.AreEqual(3, ws.Evaluate("SUM(Table1[#Data])")); } #endregion #region ConstantArray.Rule - [TestCase("={1}")] - [TestCase("={1,2,3,4}")] - [TestCase("={1,2;3,4}")] - [TestCase("={+1,#REF!,\"Text\";FALSE,#DIV/0!,-1.5}")] - public void Const_array_can_have_only_scalars(string formula) + [Test] + public void Const_array_must_have_same_number_of_columns() + { + var calcEngine = new XLCalcEngine(CultureInfo.InvariantCulture); + var ex = Assert.Throws(() => calcEngine.Parse("{1;2,3}"))!; + StringAssert.Contains("Rows of an array don't have same size.", ex.Message); + } + + [Test] + public void Const_array_cant_contain_implicit_intersection_operator() { - AssertCanParseButNotEvaluate(formula, "Evaluation of constant array is not implemented."); + // XLParser allows @ for number through 'PrefixOp + Number' + var calcEngine = new XLCalcEngine(CultureInfo.InvariantCulture); + var ex = Assert.Throws(() => calcEngine.Parse("{@1}"))!; + StringAssert.Contains("Unexpected token INTERSECT.", ex.Message); + } + + [TestCaseSource(nameof(ArrayCases))] + public void Const_array_can_have_only_scalars(string formula, object expected) + { + var expectedArray = (ConstArray)expected; + var calcEngine = new XLCalcEngine(CultureInfo.InvariantCulture); + + var ast = calcEngine.Parse(formula); + + var actual = ((ArrayNode)ast.AstRoot).Value; + Assert.AreEqual(expectedArray.Width, actual.Width); + Assert.AreEqual(expectedArray.Height, actual.Height); + for (var row = 0; row < actual.Height; ++row) + { + for (var col = 0; col < actual.Width; ++col) + { + var actualElement = actual[row, col]; + var expectedElement = expectedArray[row, col]; + Assert.AreEqual(expectedElement, actualElement); + } + } + } + + private static IEnumerable ArrayCases + { + get + { + yield return new object[] + { + "{1}", + new ConstArray(new ScalarValue[,] { { 1 } }) + }; + yield return new object[] + { + "{#REF!}", + new ConstArray(new ScalarValue[,] { { XLError.CellReference } }) + }; + yield return new object[] + { + "{1,2,3,4}", + new ConstArray(new ScalarValue[,] { { 1, 2, 3, 4 } }) + }; + yield return new object[] + { + "{1,2;3,4}", + new ConstArray(new ScalarValue[,] { { 1, 2}, { 3, 4 } }) + }; + yield return new object[] + { + "{+1,#REF!,\"Text\";FALSE,#DIV/0!,-1.5}", + new ConstArray(new ScalarValue[,] { { 1, XLError.CellReference, "Text" }, { false, XLError.DivisionByZero, -1.5 } }) + }; + } } #endregion @@ -393,8 +457,8 @@ public void Const_array_can_have_only_scalars(string formula) [TestCase("='Test Sheet'!A1", "Test Sheet")] [TestCase("='Test-Sheet'!A1", "Test-Sheet")] [TestCase("='^%>;-+'!A1", "^%>;-+")] - // Sheet can be named as #REF! error - [TestCase("=#REF!A1", "#REF")] + // Sheet can be named as #REF! error, but sheet reference must be escaped + [TestCase("='#REF'!A1", "#REF")] public void Prefix_can_be_sheet_token(string formula, string sheetName) { using var wb = new XLWorkbook(); @@ -411,7 +475,6 @@ public void Prefix_can_be_sheets_for_3d_reference(string formula) } [TestCase("=[1]Sheet4!A1")] - [TestCase("=[C:\\file.xlsx]Sheet1!A1")] public void Prefix_can_be_file_and_sheet_token(string formula) { AssertCanParseButNotEvaluate(formula, "References from other files are not yet implemented."); diff --git a/ClosedXML.Tests/Excel/CalcEngine/FunctionsTests.cs b/ClosedXML.Tests/Excel/CalcEngine/FunctionsTests.cs index 89b55e6dd..0f8bacc33 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/FunctionsTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/FunctionsTests.cs @@ -35,23 +35,42 @@ public void Clean() [Test] public void Dollar() { - object actual = XLWorkbook.EvaluateExpr("Dollar(12345.123)"); + using var wb = new XLWorkbook(); + object actual = wb.Evaluate("DOLLAR(12345.123)"); Assert.AreEqual(TestHelper.CurrencySymbol + "12,345.12", actual); - actual = XLWorkbook.EvaluateExpr("Dollar(12345.123, 1)"); + actual = wb.Evaluate("DOLLAR(12345.123, 1)"); Assert.AreEqual(TestHelper.CurrencySymbol + "12,345.1", actual); } - [Test] - public void Exact() + [TestCase("A", "A", true)] + [TestCase("A", "a", false)] + [TestCase("", "", true)] + public void Exact(string lhs, string rhs, bool result) { - Object actual; + var actual = XLWorkbook.EvaluateExpr($"EXACT(\"{lhs}\", \"{rhs}\")"); + Assert.AreEqual(result, actual); + } - actual = XLWorkbook.EvaluateExpr("Exact(\"A\", \"A\")"); - Assert.AreEqual(true, actual); + [Test] + public void Exact_converts_values_to_text() + { + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("EXACT(TRUE, \"true\")")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("EXACT(TRUE, \"TRUE\")")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("EXACT(1, \"1\")")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("EXACT(IF(TRUE,), \"\")")); + + // Check blank cell + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(true, ws.Evaluate("EXACT(A1, \"\")")); + } - actual = XLWorkbook.EvaluateExpr("Exact(\"A\", \"a\")"); - Assert.AreEqual(false, actual); + [Test] + public void Exact_propagates_errors() + { + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("EXACT(#DIV/0!, \"A\")")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("EXACT(\"A\", #DIV/0!)")); } [Test] @@ -94,7 +113,7 @@ public void TextConcat() ws.Cell("C1").FormulaA1 = "\"The total value is: \" & SUM(A1:B2)"; object r = ws.Cell("C1").Value; - Assert.AreEqual("The total value is: 4", r.ToString()); + Assert.AreEqual("The total value is: 4", r); } [Test] @@ -119,7 +138,6 @@ public void TestEmptyTallyOperations() Assert.AreEqual(0, cell.Value); cell = wb.Worksheet(1).Cell(3, 1).SetFormulaA1("=SUM(D1,D2)"); Assert.AreEqual(0, cell.Value); - Assert.That(() => wb.Worksheet(1).Cell(3, 1).SetFormulaA1("=AVERAGE(D1,D2)").Value, Throws.TypeOf()); } [Test] @@ -134,8 +152,8 @@ public void TestOmittedParameters() value = wb.Evaluate("=IF(TRUE,1,)"); Assert.AreEqual(1, value); - value = wb.Evaluate("=IF(FALSE,1,)"); - Assert.AreEqual(false, value); + value = wb.Evaluate("=ISBLANK(IF(FALSE,1,))"); + Assert.AreEqual(true, value); value = wb.Evaluate("=IF(FALSE,,2)"); Assert.AreEqual(2, value); @@ -187,5 +205,15 @@ public void TestStringSubExpression(string formula, object expectedResult) Assert.AreEqual(expectedResult, actual); } + + [Test] + public void Cell_function_is_evaluated_to_reference_error() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").FormulaA1 = "$B$4(5)"; + + Assert.AreEqual(XLError.CellReference, ws.Cell("A1").Value); + } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/InformationTests.cs b/ClosedXML.Tests/Excel/CalcEngine/InformationTests.cs index 0b8caac55..3a16f5c08 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/InformationTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/InformationTests.cs @@ -1,216 +1,297 @@ using ClosedXML.Excel; using NUnit.Framework; using System; -using System.Globalization; -using System.Threading; namespace ClosedXML.Tests.Excel.CalcEngine { [TestFixture] + [SetCulture("en-US")] public class InformationTests { - [SetUp] - public void SetCultureInfo() + [TestCase("A1")] // blank + [TestCase("TRUE")] + [TestCase("14.5")] + [TestCase("\"text\"")] + public void ErrorType_NonErrorsAreNA(string argumentFormula) { - Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate($"ERROR.TYPE({argumentFormula})")); + } + + [TestCase("#NULL!", 1)] + [TestCase("#DIV/0!", 2)] + [TestCase("#VALUE!", 3)] + [TestCase("#REF!", 4)] + [TestCase("#NAME?", 5)] + [TestCase("#NUM!", 6)] + [TestCase("#N/A", 7)] + //[TestCase("#GETTING_DATA", 8)] OLAP Cube not supported + public void ErrorType_ReturnsNumberForError(string error, int expectedNumber) + { + Assert.AreEqual(expectedNumber, XLWorkbook.EvaluateExpr($"ERROR.TYPE({error})")); } #region IsBlank Tests [Test] - public void IsBlank_MultipleAllEmpty_true() + public void IsBlank_EmptyCell_True() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - var actual = ws.Evaluate("=IsBlank(A1:A3)", "A2"); - Assert.AreEqual(true, actual); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var actual = ws.Evaluate("IsBlank(A1)"); + Assert.AreEqual(true, actual); } [Test] - public void IsBlank_MultipleAllFill_false() + public void IsBlank_NonEmptyCell_False() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = "1"; - ws.Cell("A2").Value = "1"; - ws.Cell("A3").Value = "1"; - var actual = ws.Evaluate("=IsBlank(A1:A3)", "A2"); - Assert.AreEqual(false, actual); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = "1"; + var actual = ws.Evaluate("IsBlank(A1)"); + Assert.AreEqual(false, actual); } - [Test] - public void IsBlank_MultipleMixedFill_false() + [TestCase("FALSE")] + [TestCase("0")] + [TestCase("5")] + [TestCase("\"\"")] + [TestCase("\"Hello\"")] + [TestCase("#DIV/0!")] + public void IsBlank_NonEmptyValue_False(string value) { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = "1"; - ws.Cell("A3").Value = "1"; - var actual = ws.Evaluate("=IsBlank(A1:A3)", "A3"); - Assert.AreEqual(false, actual); - } + var actual = XLWorkbook.EvaluateExpr($"IsBlank({value})"); + Assert.AreEqual(false, actual); } [Test] - public void IsBlank_Single_false() + public void IsBlank_InlineBlank_True() + { + var actual = XLWorkbook.EvaluateExpr("IsBlank(IF(TRUE,,))"); + Assert.AreEqual(true, actual); + } + + #endregion IsBlank Tests + + [TestCase("IF(TRUE,,)")] + [TestCase("FALSE")] + [TestCase("0")] + [TestCase("\"\"")] + [TestCase("\"text\"")] + public void IsErr_NonErrorValues_False(string valueFormula) + { + var actual = XLWorkbook.EvaluateExpr($"IsErr({valueFormula})"); + Assert.AreEqual(false, actual); + } + + [TestCase("#DIV/0!")] + [TestCase("#NAME?")] + [TestCase("#NULL!")] + [TestCase("#NUM!")] + [TestCase("#REF!")] + [TestCase("#VALUE!")] + public void IsErr_ErrorsExceptNA_True(string valueFormula) { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = " "; - var actual = ws.Evaluate("=IsBlank(A1)"); - Assert.AreEqual(false, actual); - } + var actual = XLWorkbook.EvaluateExpr($"IsErr({valueFormula})"); + Assert.AreEqual(true, actual); } [Test] - public void IsBlank_Single_true() + public void IsErr_NA_False() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - var actual = ws.Evaluate("=IsBlank(A1)"); - Assert.AreEqual(true, actual); - } + var actual = XLWorkbook.EvaluateExpr("IsErr(#N/A)"); + Assert.AreEqual(false, actual); } - #endregion IsBlank Tests + [TestCase("#DIV/0!")] + [TestCase("#N/A")] + [TestCase("#NAME?")] + [TestCase("#NULL!")] + [TestCase("#NUM!")] + [TestCase("#REF!")] + [TestCase("#VALUE!")] + public void IsError_Errors_True(string error) + { + var actual = XLWorkbook.EvaluateExpr($"IsError({error})"); + Assert.AreEqual(true, actual); + } + + [TestCase("IF(TRUE,,)")] + [TestCase("FALSE")] + [TestCase("0")] + [TestCase("\"\"")] + [TestCase("\"text\"")] + public void IsError_NonErrors_False(string valueFormula) + { + var actual = XLWorkbook.EvaluateExpr($"IsError({valueFormula})"); + Assert.AreEqual(false, actual); + } #region IsEven Tests + [TestCase("2")] + [TestCase("\"1 2/2\"")] + [TestCase("\"4 1/2\"")] + [TestCase("\"48:30:00\"")] + [TestCase("\"1900-01-02\"")] + public void IsEven_NumberLikeValue_ConvertedThroughValueSemantic(string valueFormula) + { + var actual = XLWorkbook.EvaluateExpr($"IsEven({valueFormula})"); + Assert.AreEqual(true, actual); + } + [Test] - public void IsEven_Single_False() + public void IsEven_NonIntegerValues_TruncatedForEvaluation() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = 1; - ws.Cell("A2").Value = 1.2; - ws.Cell("A3").Value = 3; + ws.Cell("A1").Value = 4; + ws.Cell("A2").Value = 0.9; + ws.Cell("A3").Value = -2.9; - var actual = ws.Evaluate("=IsEven(A1)"); - Assert.AreEqual(false, actual); + var actual = ws.Evaluate("=IsEven(A1)"); + Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsEven(A2)"); - Assert.AreEqual(false, actual); + actual = ws.Evaluate("=IsEven(A2)"); + Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsEven(A3)"); - Assert.AreEqual(false, actual); - } + actual = ws.Evaluate("=IsEven(A3)"); + Assert.AreEqual(true, actual); + + actual = ws.Evaluate("=IsEven(A4)"); + Assert.AreEqual(true, actual); } [Test] - public void IsEven_Single_True() + [Ignore("Arrays not yet implemented.")] + public void IsEven_Array_ReturnsArray() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - - ws.Cell("A1").Value = 4; - ws.Cell("A2").Value = 0.2; - ws.Cell("A3").Value = 12.2; - - var actual = ws.Evaluate("=IsEven(A1)"); - Assert.AreEqual(true, actual); + Assert.AreEqual(2.0, XLWorkbook.EvaluateExpr("SUM(N(IsEven({\"2.9\";2;1})))")); + } - actual = ws.Evaluate("=IsEven(A2)"); - Assert.AreEqual(true, actual); + [Test] + public void IsEven_ReferenceToMoreThanOneCell_Error() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell(1, 2).FormulaA1 = "IsEven(A1:A2)"; + Assert.AreEqual(XLError.IncompatibleValue, ws.Cell(1, 2).Value); + } - actual = ws.Evaluate("=IsEven(A3)"); - Assert.AreEqual(true, actual); - } + [TestCase("TRUE", XLError.IncompatibleValue)] + [TestCase("FALSE", XLError.IncompatibleValue)] + [TestCase("\"\"", XLError.IncompatibleValue)] + [TestCase("\"test\"", XLError.IncompatibleValue)] + [TestCase("#DIV/0!", XLError.DivisionByZero)] + [TestCase("IF(TRUE,,)", XLError.NoValueAvailable)] // Behaves differently from a reference to a blank cell + public void IsEven_NonNumberValues_Error(string valueFormula, XLError expectedError) + { + Assert.AreEqual(expectedError, XLWorkbook.EvaluateExpr($"IsEven({valueFormula})")); } #endregion IsEven Tests #region IsLogical Tests - [Test] - public void IsLogical_Simpe_False() + [TestCase("TRUE")] + [TestCase("FALSE")] + public void IsLogical_OnlyLogical_True(string valueFormula) { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - - ws.Cell("A1").Value = 123; + var actual = XLWorkbook.EvaluateExpr($"IsLogical({valueFormula})"); + Assert.AreEqual(true, actual); + } - var actual = ws.Evaluate("=IsLogical(A1)"); - Assert.AreEqual(false, actual); - } + [TestCase("IF(TRUE,,)")] + [TestCase("0")] + [TestCase("1")] + [TestCase("\"\"")] + [TestCase("\"text\"")] + [TestCase("#NAME?")] + [TestCase("#N/A")] + [TestCase("#VALUE!")] + [TestCase("#REF!")] + public void IsLogical_NonLogicalValue_False(string valueFormula) + { + var actual = XLWorkbook.EvaluateExpr($"IsLogical({valueFormula})"); + Assert.AreEqual(false, actual); } [Test] - public void IsLogical_Simple_True() + public void IsLogical_ReferenceToLogicalValue_True() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); - ws.Cell("A1").Value = true; + ws.Cell("A1").Value = true; - var actual = ws.Evaluate("=IsLogical(A1)"); - Assert.AreEqual(true, actual); - } + var actual = ws.Evaluate("IsLogical(A1)"); + Assert.AreEqual(true, actual); } #endregion IsLogical Tests [Test] - public void IsNA() + public void IsNA_NA_True() { - object actual; - actual = XLWorkbook.EvaluateExpr("ISNA(#N/A)"); + var actual = XLWorkbook.EvaluateExpr("ISNA(#N/A)"); Assert.AreEqual(true, actual); + } - actual = XLWorkbook.EvaluateExpr("ISNA(#REF!)"); + [TestCase("IF(TRUE,,)")] + [TestCase("TRUE")] + [TestCase("0")] + [TestCase("\"\"")] + [TestCase("#REF!")] + [TestCase("\"#N/A\"")] + public void IsNA_NonNotAvailableValue_False(string valueFormula) + { + var actual = XLWorkbook.EvaluateExpr($"ISNA({valueFormula})"); Assert.AreEqual(false, actual); } #region IsNotText Tests [Test] - public void IsNotText_Simple_false() + public void IsNotText_ReferenceToBlankCell_True() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var actual = ws.Evaluate("IsNonText(A1)"); + Assert.AreEqual(true, actual); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("text")] + public void IsNotText_ReferenceToStringCell_False(string text) { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = "asd"; - var actual = ws.Evaluate("=IsNonText(A1)"); - Assert.AreEqual(false, actual); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = text; + var actual = ws.Evaluate("IsNonText(A1)"); + Assert.AreEqual(false, actual); } [Test] - public void IsNotText_Simple_true() - { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = "123"; //Double Value - ws.Cell("A2").Value = DateTime.Now; //Date Value - ws.Cell("A3").Value = "12,235.5"; //Comma Formatting - ws.Cell("A4").Value = "$12,235.5"; //Currency Value - ws.Cell("A5").Value = true; //Bool Value - ws.Cell("A6").Value = "12%"; //Percentage Value - - var actual = ws.Evaluate("=IsNonText(A1)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsNonText(A2)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsNonText(A3)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsNonText(A4)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsNonText(A5)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsNonText(A6)"); - Assert.AreEqual(true, actual); - } + public void IsNotText_NonTextValues_True() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); + ws.Cell("A1").Value = 123; //Double Value + ws.Cell("A2").Value = DateTime.Now; //Date Value + ws.Cell("A3").Value = true; //Bool Value + ws.Cell("A4").Value = XLError.IncompatibleValue; //Error value + + var actual = ws.Evaluate("IsNonText(A1)"); + Assert.AreEqual(true, actual); + actual = ws.Evaluate("IsNonText(A2)"); + Assert.AreEqual(true, actual); + actual = ws.Evaluate("IsNonText(A3)"); + Assert.AreEqual(true, actual); + actual = ws.Evaluate("IsNonText(A4)"); + Assert.AreEqual(true, actual); } #endregion IsNotText Tests @@ -220,224 +301,333 @@ public void IsNotText_Simple_true() [Test] public void IsNumber_Simple_false() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = "asd"; //String Value - ws.Cell("A2").Value = true; //Bool Value + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); + ws.Cell("A1").Value = "asd"; //String Value + ws.Cell("A2").Value = true; //Bool Value - var actual = ws.Evaluate("=IsNumber(A1)"); - Assert.AreEqual(false, actual); - actual = ws.Evaluate("=IsNumber(A2)"); - Assert.AreEqual(false, actual); - } + var actual = ws.Evaluate("IsNumber(A1)"); + Assert.AreEqual(false, actual); + actual = ws.Evaluate("IsNumber(A2)"); + Assert.AreEqual(false, actual); } [Test] public void IsNumber_Simple_true() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = "123"; //Double Value - ws.Cell("A2").Value = DateTime.Now; //Date Value - ws.Cell("A3").Value = "12,235.5"; //Coma Formatting - ws.Cell("A4").Value = "$12,235.5"; //Currency Value - ws.Cell("A5").Value = "12%"; //Percentage Value - - var actual = ws.Evaluate("=IsNumber(A1)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsNumber(A2)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsNumber(A3)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsNumber(A4)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsNumber(A5)"); - Assert.AreEqual(true, actual); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); + ws.Cell("A1").Value = 123; //Double Value + ws.Cell("A2").Value = DateTime.Now; //Date Value + ws.Cell("A3").Value = new TimeSpan(2, 30, 50); //TimeSpan Value + + var actual = ws.Evaluate("=IsNumber(A1)"); + Assert.AreEqual(true, actual); + actual = ws.Evaluate("=IsNumber(A2)"); + Assert.AreEqual(true, actual); + actual = ws.Evaluate("=IsNumber(A3)"); + Assert.AreEqual(true, actual); + } + + [TestCase("TRUE")] + [TestCase("FALSE")] + [TestCase("\"\"")] + [TestCase("#DIV/0!")] + [TestCase("#NULL!")] + [TestCase("#VALUE!")] + [TestCase("#N/A")] + public void IsNumber_NonNumber_False(string nonNumberValue) + { + var actual = XLWorkbook.EvaluateExpr($"IsNumber({nonNumberValue})"); + Assert.AreEqual(false, actual); } #endregion IsNumber Tests #region IsOdd Test + [SetCulture("en-US")] + [TestCase("1")] + [TestCase("\"2 3/3\"")] + [TestCase("\"5 1/3\"")] + [TestCase("\"25:30:00\"")] + [TestCase("\"1900-01-03\"")] + public void IsOdd_SingleValue_ConvertedThroughValueSemantic(string valueFormula) + { + var actual = XLWorkbook.EvaluateExpr($"IsOdd({valueFormula})"); + Assert.AreEqual(true, actual); + } + [Test] - public void IsOdd_Simple_false() + public void IsOdd_NonIntegerValues_TruncatedForEvaluation() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = 4; - ws.Cell("A2").Value = 0.2; - ws.Cell("A3").Value = 12.2; + ws.Cell("A1").Value = 3; + ws.Cell("A2").Value = 1.9; + ws.Cell("A3").Value = -5.9; - var actual = ws.Evaluate("=IsOdd(A1)"); - Assert.AreEqual(false, actual); - actual = ws.Evaluate("=IsOdd(A2)"); - Assert.AreEqual(false, actual); - actual = ws.Evaluate("=IsOdd(A3)"); - Assert.AreEqual(false, actual); - } + var actual = ws.Evaluate("=IsOdd(A1)"); + Assert.AreEqual(true, actual); + + actual = ws.Evaluate("=IsOdd(A2)"); + Assert.AreEqual(true, actual); + + actual = ws.Evaluate("=IsOdd(A3)"); + Assert.AreEqual(true, actual); + + actual = ws.Evaluate("=IsOdd(A4)"); + Assert.AreEqual(false, actual); } + [SetCulture("en-US")] [Test] - public void IsOdd_Simple_true() + [Ignore("Arrays not yet implemented.")] + public void IsOdd_Array_ReturnsArray() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); + Assert.AreEqual(2.0, XLWorkbook.EvaluateExpr("SUM(N(IsOdd({\"3.2\",7,2})))")); + } - ws.Cell("A1").Value = 1; - ws.Cell("A2").Value = 1.2; - ws.Cell("A3").Value = 3; + [Test] + public void IsOdd_ReferenceToMoreThanOneCell_Error() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell(1, 2).FormulaA1 = "IsOdd(A1:A2)"; + Assert.AreEqual(XLError.IncompatibleValue, ws.Cell(1, 2).Value); + } - var actual = ws.Evaluate("=IsOdd(A1)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsOdd(A2)"); - Assert.AreEqual(true, actual); - actual = ws.Evaluate("=IsOdd(A3)"); - Assert.AreEqual(true, actual); - } + [TestCase("TRUE", XLError.IncompatibleValue)] + [TestCase("FALSE", XLError.IncompatibleValue)] + [TestCase("\"\"", XLError.IncompatibleValue)] + [TestCase("\"test\"", XLError.IncompatibleValue)] + [TestCase("#DIV/0!", XLError.DivisionByZero)] + [TestCase("IF(TRUE,,)", XLError.NoValueAvailable)] // Behaves differently from a reference to a blank cell + public void IsOdd_NonNumberValues_Error(string valueFormula, XLError expectedError) + { + Assert.AreEqual(expectedError, XLWorkbook.EvaluateExpr($"IsOdd({valueFormula})")); } #endregion IsOdd Test - [Test] - public void IsRef() + [TestCase("A1")] + [TestCase("(A1,A5)")] + public void IsRef_Reference_True(string reference) { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = "123"; + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); + ws.Cell("A1").Value = "123"; + + ws.Cell("B1").FormulaA1 = $"ISREF({reference})"; - ws.Cell("B1").FormulaA1 = "ISREF(A1)"; - ws.Cell("B2").FormulaA1 = "ISREF(5)"; - ws.Cell("B3").FormulaA1 = "ISREF(YEAR(TODAY()))"; + Assert.AreEqual(true, ws.Cell("B1").Value); + } - bool actual; - actual = ws.Cell("B1").GetValue(); - Assert.AreEqual(true, actual); + [TestCase("IF(TRUE,,)")] + [TestCase("TRUE")] + [TestCase("0")] + [TestCase("\"\"")] + // [TestCase("{1;2}")] Arrays not yet implemented + [TestCase("#N/A")] + [TestCase("#VALUE!")] + public void IsRef_NonReference_False(string nonReference) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); - actual = ws.Cell("B2").GetValue(); - Assert.AreEqual(false, actual); + ws.Cell("B1").FormulaA1 = $"ISREF({nonReference})"; - actual = ws.Cell("B3").GetValue(); - Assert.AreEqual(false, actual); - } + Assert.AreEqual(false, ws.Cell("B1").Value); } #region IsText Tests [Test] - public void IsText_Simple_false() - { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = "123"; //Double Value - ws.Cell("A2").Value = DateTime.Now; //Date Value - ws.Cell("A3").Value = "12,235.5"; //Comma Formatting - ws.Cell("A4").Value = "$12,235.5"; //Currency Value - ws.Cell("A5").Value = true; //Bool Value - ws.Cell("A6").Value = "12%"; //Percentage Value - - var actual = ws.Evaluate("=IsText(A1)"); - Assert.AreEqual(false, actual); - actual = ws.Evaluate("=IsText(A2)"); - Assert.AreEqual(false, actual); - actual = ws.Evaluate("=IsText(A3)"); - Assert.AreEqual(false, actual); - actual = ws.Evaluate("=IsText(A4)"); - Assert.AreEqual(false, actual); - actual = ws.Evaluate("=IsText(A5)"); - Assert.AreEqual(false, actual); - actual = ws.Evaluate("=IsText(A6)"); - Assert.AreEqual(false, actual); - } + public void IsText_BlankCell_False() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("B1").FormulaA1 = "ISTEXT(A1)"; + + Assert.AreEqual(false, ws.Cell("B1").Value); } - [Test] - public void IsText_Simple_true() + [TestCase("0")] + [TestCase("123")] + [TestCase("TRUE")] + [TestCase("#DIV/0!")] + [TestCase("IF(TRUE,,)")] + public void IsText_NonText_False(string nonText) + { + var actual = XLWorkbook.EvaluateExpr($"ISTEXT({nonText})"); + Assert.AreEqual(false, actual); + } + + [TestCase("")] + [TestCase("abc")] + public void IsText_CellWithText_True(string textValue) { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); - ws.Cell("A1").Value = "asd"; + ws.Cell("A1").Value = textValue; - var actual = ws.Evaluate("=IsText(A1)"); - Assert.AreEqual(true, actual); - } + var actual = ws.Evaluate("IsText(A1)"); + Assert.AreEqual(true, actual); } #endregion IsText Tests #region N Tests + [Test] + public void N_Blank_Zero() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var actual = ws.Evaluate("N(A1)"); + Assert.AreEqual(0.0, actual); + } + [Test] public void N_Date_SerialNumber() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - var testedDate = DateTime.Now; - ws.Cell("A1").Value = testedDate; - var actual = ws.Evaluate("=N(A1)"); - Assert.AreEqual(testedDate.ToOADate(), actual); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var testedDate = DateTime.Now; + ws.Cell("A1").Value = testedDate; + var actual = ws.Evaluate("N(A1)"); + Assert.AreEqual(testedDate.ToSerialDateTime(), actual); } [Test] public void N_False_Zero() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = false; - var actual = ws.Evaluate("=N(A1)"); - Assert.AreEqual(0, actual); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = false; + var actual = ws.Evaluate("N(A1)"); + Assert.AreEqual(0, actual); } + [Test] + public void N_True_One() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = true; + var actual = ws.Evaluate("N(A1)"); + Assert.AreEqual(1, actual); + } [Test] public void N_Number_Number() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - var testedValue = 123; - ws.Cell("A1").Value = testedValue; - var actual = ws.Evaluate("=N(A1)"); - Assert.AreEqual(testedValue, actual); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var testedValue = 123; + ws.Cell("A1").Value = testedValue; + var actual = ws.Evaluate("N(A1)"); + Assert.AreEqual(testedValue, actual); } - [Test] - public void N_String_Zero() + [TestCase("")] + [TestCase("abc")] + public void N_String_Zero(string text) { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = "asd"; - var actual = ws.Evaluate("=N(A1)"); - Assert.AreEqual(0, actual); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = text; + var actual = ws.Evaluate("N(A1)"); + Assert.AreEqual(0, actual); } [Test] - public void N_True_One() + [Ignore("Array not implemented")] + public void N_Array_ConvertsIndividualItems() + { + var actual = XLWorkbook.EvaluateExpr("SUM(N({2,TRUE}))"); + Assert.AreEqual(3, actual); + } + + [TestCase("A1")] + [TestCase("A1:B1")] + [TestCase("(A1, B1)")] + public void N_Reference_TakesFirstCellFromFirstArea(string reference) { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet"); - ws.Cell("A1").Value = true; - var actual = ws.Evaluate("=N(A1)"); - Assert.AreEqual(1, actual); - } + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 5; + ws.Cell("B1").Value = 10; + + var actual = ws.Evaluate($"SUM(N({reference}))"); + Assert.AreEqual(5, actual); } #endregion N Tests + + [TestCase("IF(TRUE,,)", 1)] + [TestCase("0", 1)] + [TestCase("1", 1)] + [TestCase("-5.2", 1)] + [TestCase("\"\"", 2)] + [TestCase("\"text\"", 2)] + [TestCase("\"1\"", 2)] + [TestCase("\"TRUE\"", 2)] + [TestCase("TRUE", 4)] + [TestCase("FALSE", 4)] + [TestCase("#DIV/0!", 16)] + [TestCase("1/0", 16)] + [TestCase("#N/A", 16)] + [TestCase("#VALUE!", 16)] + public void Type_NonReferenceScalarValues(string literalValues, double expectedNumber) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").FormulaA1 = $"TYPE({literalValues})"; + Assert.AreEqual(expectedNumber, ws.Cell("A1").Value); + } + + [Ignore("Arrays not implemented")] + [TestCase("{1}")] + [TestCase("{TRUE,#N/A}")] + [TestCase("{\"abc\";5}")] + public void Type_Array_HasValue64(string arrayLiteral) + { + var actual = XLWorkbook.EvaluateExpr($"TYPE({arrayLiteral})"); + Assert.AreEqual(64.0, actual); + } + + [TestCase("A1:A2")] + // [TestCase("(A1:A3 A2:B3)")] Not implemented // Intersection results in a 1x2 block + public void Type_ReferenceToNonSingleCell_BehavesLikeArray(string reference) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("C1").FormulaA1 = $"TYPE({reference})"; + Assert.AreEqual(64.0, ws.Cell("C1").Value); + } + + [Test] + public void Type_ReferenceToSingleCell_ReturnsTypeOfCell() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = "text"; + + ws.Cell("C1").FormulaA1 = "TYPE(A1)"; + Assert.AreEqual(2.0, ws.Cell("C1").Value); + } + + [Test] + public void Type_MultiAreaReference_ReturnsError() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = "text"; + + ws.Cell("C1").FormulaA1 = "TYPE((A1,A1))"; + Assert.AreEqual(16.0, ws.Cell("C1").Value); + } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/LogicalTests.cs b/ClosedXML.Tests/Excel/CalcEngine/LogicalTests.cs index 3ff365b7f..2a5506a53 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/LogicalTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/LogicalTests.cs @@ -7,6 +7,73 @@ namespace ClosedXML.Tests.Excel.CalcEngine [TestFixture] public class LogicalTests { + [Test] + public void And_IsLogicalConjunction() + { + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("AND(TRUE)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("AND(TRUE, TRUE)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("AND(TRUE, TRUE, TRUE)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("AND({TRUE, TRUE}, TRUE)")); + + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("AND(FALSE)")); + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("AND(TRUE, FALSE)")); + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("AND({TRUE, FALSE})")); + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("AND(TRUE, {TRUE, FALSE})")); + } + + [TestCase("A1")] + [TestCase("A1:A5")] + [TestCase("(A1:A5,B1:B5)")] + public void And_NoCollectionValues_Error(string range) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate($"AND({range})")); + } + + [Test] + public void And_ScalarArgumentsCoercedFromBlankOrTextOrNumber() + { + // Blank evaluated to false + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("AND(IF(TRUE,,))")); + + // Number coerced to logical + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("AND(0)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("AND(0.1)")); + + // Text coerced to logical + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("AND(\"FALSE\")")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("AND(\"TRUE\")")); + } + + [Test] + public void And_UnconvertableScalarArgumentsSkipped() + { + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("AND(TRUE,\"z\")")); + } + + [Test] + public void And_OnlyLogicalOrNumberElementsOfCollectionUsed() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // 0 is a number and is converted to logical + ws.Cell("A1").Value = 0; + Assert.AreEqual(false, ws.Evaluate("AND(TRUE,A1)")); + + // false is a logical + ws.Cell("A2").Value = false; + Assert.AreEqual(false, ws.Evaluate("AND(TRUE,A2)")); + + // Text is not converted and thus skipped for evaluation + ws.Cell("A3").Value = "FALSE"; + Assert.AreEqual(true, ws.Evaluate("AND(TRUE,A3)")); + + ws.Cell("A4").Value = "some text"; + Assert.AreEqual(true, ws.Evaluate("AND(TRUE,A4)")); + } + [Test] public void If_2_Params_true() { @@ -61,10 +128,172 @@ public void If_Case_Insensitivity() } [Test] - public void If_Missing_Second_Value_Then_False() + public void If_CanReturnReference() { - Object actual = XLWorkbook.EvaluateExpr(@"IF(FALSE, 1,)"); - Assert.AreEqual(false, actual); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(true, ws.Evaluate("ISREF(IF(TRUE, A1))")); + Assert.AreEqual(true, ws.Evaluate("ISREF(IF(FALSE,, A1))")); + } + + [Test] + public void If_has_scalar_condition_and_range_values() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new[] { 1, 2, 3 }); + ws.Cell("B1").InsertData(new[] { 4, 5, 6 }); + ws.Cell("C1").InsertData(new[] { true, false, true }); + for (var row = 1; row <= 4; ++row) + ws.Cell(row, 4).FormulaA1 = "SUM(IF(C1:C3, A1:A3, B1:B3))"; + + // Condition is implicitely intersected, because it's a scalar parameter + Assert.AreEqual(6, ws.Cell("D1").Value); + Assert.AreEqual(15, ws.Cell("D2").Value); + Assert.AreEqual(6, ws.Cell("D3").Value); + Assert.AreEqual(XLError.IncompatibleValue, ws.Cell("D4").Value); + } + + [Test] + public void If_ConditionError_ReturnError() + { + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr(@"IF(1/0, ""T"", ""F"")")); + } + + [Test] + public void If_ConditionCoercedToLogical() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual("F", ws.Evaluate(@"IF(A1, ""T"", ""F"")")); + + Assert.AreEqual("T", ws.Evaluate(@"IF(""TRUE"", ""T"", ""F"")")); + Assert.AreEqual("F", ws.Evaluate(@"IF(""FALSE"", ""T"", ""F"")")); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate(@"IF(""text"", ""T"", ""F"")")); + + Assert.AreEqual("T", ws.Evaluate(@"IF(1, ""T"", ""F"")")); + Assert.AreEqual("F", ws.Evaluate(@"IF(0, ""T"", ""F"")")); + } + + [Test] + public void If_MissingValues_ReturnBlank() + { + Assert.AreEqual(true, XLWorkbook.EvaluateExpr(@"ISBLANK(IF(TRUE,,))")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr(@"ISBLANK(IF(FALSE,,))")); + } + + [Test] + public void IfError_FirstArgumentNonError_ReturnFirstArgument() + { + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("ISBLANK(IFERROR(IF(TRUE,), 5))")); + + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("IFERROR(FALSE, 5)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("IFERROR(TRUE, 5)")); + + Assert.AreEqual(0.0, XLWorkbook.EvaluateExpr("IFERROR(0, 5)")); + Assert.AreEqual(-2.0, XLWorkbook.EvaluateExpr("IFERROR(-2, 5)")); + + Assert.AreEqual(string.Empty, XLWorkbook.EvaluateExpr("IFERROR(\"\", 5)")); + Assert.AreEqual("text", XLWorkbook.EvaluateExpr("IFERROR(\"text\", 5)")); + } + + [Test] + public void IfError_FirstArgumentError_ReturnSecondArgument() + { + Assert.AreEqual("text", XLWorkbook.EvaluateExpr("IFERROR(1/0, \"text\")")); + + Assert.AreEqual(XLError.NameNotRecognized, XLWorkbook.EvaluateExpr("IFERROR(#REF!, #NAME?)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("IFERROR(#NULL!, TRUE)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("ISBLANK(IFERROR(#VALUE!,IF(TRUE,)))")); + } + + [Test] + public void IfError_ReferenceNeverReturned() + { + // Unlike IF, IFERROR doesn't return reference + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(false, ws.Evaluate("ISREF(IFERROR(#VALUE!, A1))")); + } + + [TestCase("TRUE", false)] + [TestCase("FALSE", true)] + [TestCase("IF(TRUE,,)", true)] // Blank + [TestCase("0", true)] + [TestCase("0.1", false)] + [TestCase("\"true\"", false)] + [TestCase("\"false\"", true)] + [TestCase("1/0", XLError.DivisionByZero)] + public void Not(string valueFormula, object expectedResult) + { + Assert.AreEqual(expectedResult, XLWorkbook.EvaluateExpr($"NOT({valueFormula})")); + } + + [Test] + public void Or_IsLogicalDisjunction() + { + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("OR(TRUE)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("OR(TRUE, TRUE)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("OR(TRUE, FALSE, TRUE)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("OR({FALSE, TRUE}, FALSE)")); + + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("OR(FALSE)")); + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("OR(FALSE, FALSE)")); + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("OR({FALSE, FALSE})")); + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("OR(FALSE, {FALSE, FALSE})")); + } + + [TestCase("A1")] + [TestCase("A1:A5")] + [TestCase("(A1:A5,B1:B5)")] + public void Or_NoCollectionValues_Error(string range) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate($"OR({range})")); + } + + [Test] + public void Or_ScalarArgumentsCoercedFromBlankOrTextOrNumber() + { + // Blank evaluated to false + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("OR(IF(TRUE,,))")); + + // Number coerced to logical + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("OR(0)")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("OR(0.1)")); + + // Text coerced to logical + Assert.AreEqual(false, XLWorkbook.EvaluateExpr("OR(\"FALSE\")")); + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("OR(\"TRUE\")")); + } + + [Test] + public void Or_UnconvertableScalarArgumentsSkipped() + { + Assert.AreEqual(true, XLWorkbook.EvaluateExpr("OR(TRUE,\"z\")")); + } + + [Test] + public void Or_OnlyLogicalOrNumberElementsOfCollectionUsed() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // 1 is a number and is converted to logical + ws.Cell("A1").Value = 1; + Assert.AreEqual(true, ws.Evaluate("OR(FALSE,A1)")); + + // false is a logical + ws.Cell("A2").Value = true; + Assert.AreEqual(true, ws.Evaluate("OR(FALSE,A2)")); + + // Text is not converted and thus skipped for evaluation + ws.Cell("A3").Value = "TRUE"; + Assert.AreEqual(false, ws.Evaluate("OR(FALSE,A3)")); + + ws.Cell("A4").Value = "some text"; + Assert.AreEqual(false, ws.Evaluate("OR(FALSE,A4)")); } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/LookupTests.cs b/ClosedXML.Tests/Excel/CalcEngine/LookupTests.cs index 6f027e984..e5ea5b455 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/LookupTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/LookupTests.cs @@ -1,9 +1,8 @@ // Keep this file CodeMaid organised and cleaned using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine; -using ClosedXML.Excel.CalcEngine.Exceptions; using NUnit.Framework; using System; +using System.Linq; namespace ClosedXML.Tests.Excel.CalcEngine { @@ -124,118 +123,454 @@ public void Column() Assert.AreEqual(XLError.IncompatibleValue, ws.Cell("A10").SetFormulaA1("COLUMN(\"C5\")").Value); } + [Test] + public void Columns_Blank_ReturnsValueError() + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("COLUMNS(IF(TRUE,,))")); + } + + [TestCase("0")] + [TestCase("1")] + [TestCase("99")] + [TestCase("-10")] + [TestCase("TRUE")] + [TestCase("FALSE")] + [TestCase("\"\"")] + [TestCase("\"A\"")] + [TestCase("\"Hello World\"")] + public void Columns_ScalarValues_ReturnsOne(string value) + { + Assert.AreEqual(1, XLWorkbook.EvaluateExpr($"COLUMNS({value})")); + } + + [Test] + public void Columns_Error_ReturnsError() + { + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("COLUMNS(#DIV/0!)")); + } + + [TestCase("{1}", 1)] + [TestCase("{1;2;3}", 1)] + [TestCase("{1,2,3,4;5,6,7,8}", 4)] + [TestCase("{TRUE,\"Z\";#DIV/0!,4}", 2)] + public void Columns_Arrays_ReturnsNumberOfColumns(string array, int expectedColumnCount) + { + Assert.AreEqual(expectedColumnCount, XLWorkbook.EvaluateExpr($"COLUMNS({array})")); + } + + [TestCase("A1", 1)] + [TestCase("A1:A6", 1)] + [TestCase("B2:D6", 3)] + [TestCase("E7:AA14", 23)] + public void Columns_References_ReturnsNumberOfColumns(string range, int expectedColumnCount) + { + using var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + Assert.AreEqual(expectedColumnCount, sheet.Evaluate($"COLUMNS({range})")); + } + + [Test] + public void Columns_NonContiguousReferences_ReturnsReferenceError() + { + // Spec says #NULL!, but Excel says #REF! + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("COLUMNS((A1,C3))")); + } + [Test] public void Hlookup() { - // Range lookup false - var value = ws.Evaluate(@"=HLOOKUP(""Total"",Data!$B$2:$I$71,4,FALSE)"); - Assert.AreEqual(179.64, value); + // Since HLOOKUP requires values to be sorted, we can't use created data. + using var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + sheet.Cell("B2").InsertData(new[] + { + new object[] { 1, 3, 5, 10 }, + new object[] { "A", "B", "C", "D" }, + }); + + // Range lookup false = exact match + var value = sheet.Evaluate(@"HLOOKUP(3,B2:E3,2,FALSE)"); + Assert.AreEqual("B", value); + + // Text values are looked up case insensitive. + value = sheet.Evaluate(@"HLOOKUP(""c"",B3:E3,1,FALSE)"); + Assert.AreEqual("C", value); + + // Value not present in the range for exact search + // Empty string is not same as blank. + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate(@"HLOOKUP("""",A2:E2,1,FALSE)")); + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate(@"HLOOKUP(50,B2:E3,1,FALSE)")); + + // Value in approximate search that is lower than first element + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate(@"HLOOKUP(-10,B2:E3,2,TRUE)")); + } + + [Test] + public void Hlookup_UnexpectedArguments() + { + // Lookup value can't be an error + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr(@"HLOOKUP(#DIV/0!,{1,2},1)")); + + // Text value can't be over 255 chars + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"HLOOKUP(\"{new string('A', 256)}\",{{\"A\"}},1)")); + + // Range can only be array or a reference. If other type, it returns the error #N/A + Assert.AreEqual(XLError.NoValueAvailable, XLWorkbook.EvaluateExpr(@"HLOOKUP(""value"",1,1)")); + Assert.AreEqual(XLError.NoValueAvailable, XLWorkbook.EvaluateExpr(@"HLOOKUP(""value"",TRUE,1)")); + + // If range is a non-contiguous range, #N/A + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate(@"HLOOKUP(""Units"",(B2:I5,B6:I10),1)")); + + // The row index number must be at most the same as height of the range. It is 5 here, but range is 4 cell high. + Assert.AreEqual(XLError.CellReference, ws.Evaluate(@"HLOOKUP(""value"",B2:I5,5,FALSE)")); + + // The row index number must be at least 1. It is 0 here. + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"HLOOKUP(1,{1,2},0,FALSE)")); + } + + [Test] + public void Hlookup_truncates_row_index_number_parameter() + { + // If row index number is not a whole number, it is truncated, so here 1.9 is truncated to 1 + Assert.AreEqual(7, ws.Evaluate(@"HLOOKUP(7,{5,7,9},1.9)")); + } + + [Test] + public void Hlookup_converts_blank_lookup_value_to_number_zero() + { + using var wb = new XLWorkbook(); + var worksheet = wb.AddWorksheet(); + worksheet.Cell("A1").InsertData(new[] + { + new object[] { -1, 0, 1 }, + new object[] { "-one", "zero", "one"}, + }); + + var actual = worksheet.Evaluate("HLOOKUP(IF(TRUE,,),A1:C2,2)"); + + Assert.AreEqual("zero", actual); + } + + [Test] + public void Hlookup_approximate_search_omits_values_with_different_type() + { + using var wb = new XLWorkbook(); + var worksheet = wb.AddWorksheet(); + worksheet.Cell("A1").Value = "0"; + worksheet.Cell("B1").Value = "1"; + worksheet.Cell("C1").Value = 1; + worksheet.Cell("D1").Value = "0"; + worksheet.Cell("E1").Value = "text"; + worksheet.Cell("F1").Value = Blank.Value; + worksheet.Cell("G1").Value = 2; + worksheet.Cell("A2").InsertData(Enumerable.Range(1, 7).Select(x => $"Column {x}"), true); + + var actual = worksheet.Evaluate("HLOOKUP(1.9,A1:G2,2,TRUE)"); + Assert.AreEqual("Column 3", actual); + } + + [Test] + public void Hlookup_with_range_containing_only_cells_with_different_type_returns_NA_error() + { + using var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + sheet.Cell("A1").Value = "text"; + Assert.AreEqual(XLError.NoValueAvailable, sheet.Evaluate("HLOOKUP(1,A1,1,TRUE)")); + } + + [Test] + public void Hlookup_approximate_search_returns_last_column_for_multiple_equal_values() + { + var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + sheet.Cell("A1").InsertData(new object[] + { + new object[] { 1, 3, 3, 3, 3, 3, 3, 9 }, + new object[] { "A", "B", "C", "D", "E", "F", "G", "H" }, + }); + + // If there is a section of values with same value, return the value at the highest column + var actual = sheet.Evaluate("HLOOKUP(3, A1:H2, 2, TRUE)"); + Assert.AreEqual("G", actual); + + // If the last value is in the highest column, just return value outright + actual = sheet.Evaluate("HLOOKUP(3, B1:G2, 2, TRUE)"); + Assert.AreEqual("G", actual); } [Test] public void Hyperlink() { using var wb = new XLWorkbook(); - var ws = wb.AddWorksheet(); + var sheet = wb.AddWorksheet(); - var cell = ws.Cell("B3"); + var cell = sheet.Cell("B3"); cell.FormulaA1 = "HYPERLINK(\"http://github.com/ClosedXML/ClosedXML\")"; Assert.AreEqual("http://github.com/ClosedXML/ClosedXML", cell.Value); - Assert.True(cell.HasHyperlink); - Assert.AreEqual("http://github.com/ClosedXML/ClosedXML", cell.GetHyperlink().ExternalAddress.ToString()); + Assert.False(cell.HasHyperlink); - cell = ws.Cell("B4"); + cell = sheet.Cell("B4"); cell.FormulaA1 = "HYPERLINK(\"mailto:jsmith@github.com\", \"jsmith@github.com\")"; Assert.AreEqual("jsmith@github.com", cell.Value); - Assert.True(cell.HasHyperlink); - Assert.AreEqual("mailto:jsmith@github.com", cell.GetHyperlink().ExternalAddress.ToString()); + Assert.False(cell.HasHyperlink); + + cell = sheet.Cell("B5"); + cell.FormulaA1 = "HYPERLINK(\"[Test.xlsx]Sheet1!A5\", \"Cell A5\")"; + Assert.AreEqual("Cell A5", cell.Value); + Assert.False(cell.HasHyperlink); } [Test] - public void Index() + public void Index_reference() { - Assert.AreEqual("Kivell", ws.Evaluate(@"=INDEX(B2:J12, 3, 4)")); + using var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + sheet.Cell("B2").Value = "B2"; + sheet.Cell("B4").Value = "B4"; + sheet.Cell("B5").Value = "B5"; + sheet.Cell("E2").Value = "E2"; + sheet.Cell("E4").Value = "E4"; + + // A single cell + AssertIndex("INDEX(B2:J12, 3, 4)", 1, 1, "E4"); - // We don't support optional parameter fully here yet. - // Supposedly, if you omit e.g. the row number, then ROW() of the calling cell should be assumed - // Assert.AreEqual("Gill", ws.Evaluate(@"=INDEX(B2:J12, , 4)")); + // Row number is omitted, so take all rows from the range. The result is a column E2:E12 + AssertIndex("INDEX(B2:J12, 0, 4)", 11, 1, "E2"); + AssertIndex("INDEX(B2:J12, , 4)", 11, 1, "E2"); - Assert.AreEqual("Rep", ws.Evaluate(@"=INDEX(B2:I2, 4)")); + // Column number is omitted, so take all column from the range. The result is a column B4:J4 + AssertIndex("INDEX(B2:J12, 3, 0)", 1, 9, "B4"); + AssertIndex("INDEX(B2:J12, 3, )", 1, 9, "B4"); - Assert.AreEqual(3, ws.Evaluate(@"=INDEX(B2:B20, 4)")); - Assert.AreEqual(3, ws.Evaluate(@"=INDEX(B2:B20, 4, 1)")); - Assert.AreEqual(3, ws.Evaluate(@"=INDEX(B2:B20, 4, )")); + // The range is a row and there is only one parameter. Take the index from the row. + AssertIndex("INDEX(B2:I2, 4)", 1, 1, "E2"); - Assert.AreEqual("Rep", ws.Evaluate(@"=INDEX(B2:J2, 1, 4)")); - Assert.AreEqual("Rep", ws.Evaluate(@"=INDEX(B2:J2, , 4)")); + // The range is a column and there is only one parameter. Take the index from the column. + AssertIndex("INDEX(B2:B12, 4)", 1, 1, "B5"); + + // Take whole range. + AssertIndex("INDEX(B2:J12, 0, 0)", 11, 9, "B2"); + + // Select second area from multi-area reference + AssertIndex("INDEX((H4:J10, B2:J12, A1), 1, 1, 2)", 1, 1, "B2"); + return; + + void AssertIndex(string formula, int rows, int cols, XLCellValue value) + { + Assert.AreEqual(value, sheet.Evaluate($"INDEX({formula},1,1)")); + Assert.AreEqual(rows, sheet.Evaluate($"ROWS({formula})")); + Assert.AreEqual(cols, sheet.Evaluate($"COLUMNS({formula})")); + Assert.AreEqual(true, sheet.Evaluate($"ISREF({formula})")); + } } [Test] - public void Index_Exceptions() + public void Index_reference_errors() { - Assert.Throws(() => ws.Evaluate(@"INDEX(B2:I10, 20, 1)")); - Assert.Throws(() => ws.Evaluate(@"INDEX(B2:I10, 1, 10)")); - Assert.Throws(() => ws.Evaluate(@"INDEX(B2:I2, 10)")); - Assert.Throws(() => ws.Evaluate(@"INDEX(B2:I2, 4, 1)")); - Assert.Throws(() => ws.Evaluate(@"INDEX(B2:I2, 4, )")); - Assert.Throws(() => ws.Evaluate(@"INDEX(B2:B10, 20)")); - Assert.Throws(() => ws.Evaluate(@"INDEX(B2:B10, 20, )")); - Assert.Throws(() => ws.Evaluate(@"INDEX(B2:B10, , 4)")); + using var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + + // Row bounds + Assert.AreEqual(XLError.IncompatibleValue, sheet.Evaluate("INDEX(A1, -1, 1)")); + Assert.AreEqual(XLError.CellReference, sheet.Evaluate("INDEX(B3:C5, 4, 1)")); + + // Column bounds + Assert.AreEqual(XLError.IncompatibleValue, sheet.Evaluate("INDEX(A1, 1, -1)")); + Assert.AreEqual(XLError.CellReference, sheet.Evaluate("INDEX(B3:C5, 1, 3)")); + + // Area bounds + Assert.AreEqual(XLError.IncompatibleValue, sheet.Evaluate("INDEX((A1, B1, C1), 1, 1, 0)")); + Assert.AreEqual(XLError.CellReference, sheet.Evaluate("INDEX((A1, B1, C1),1, 1, 4)")); } [Test] - public void Match() + public void Index_array() { - Object value; - value = ws.Evaluate(@"=MATCH(""Rep"", B2:I2, 0)"); - Assert.AreEqual(4, value); + // A single element + AssertIndex("INDEX({1,2,3;4,5,6}, 2, 3)", 1, 1, 6); - value = ws.Evaluate(@"=MATCH(""Rep"", A2:Z2, 0)"); - Assert.AreEqual(5, value); + // Row number is omitted, so take all rows from the array at third column. The result is a column {3;6} + AssertIndex("INDEX({1,2,3;4,5,6}, 0, 3)", 2, 1, 3); + AssertIndex("INDEX({1,2,3;4,5,6}, , 3)", 2, 1, 3); - value = ws.Evaluate(@"=MATCH(""REP"", B2:I2, 0)"); - Assert.AreEqual(4, value); + // Column number is omitted, so take all columns from the array at second row. The result is a row {4,5,6} + AssertIndex("INDEX({1,2,3;4,5,6}, 2, 0)", 1, 3, 4); + AssertIndex("INDEX({1,2,3;4,5,6}, 2, )", 1, 3, 4); - value = ws.Evaluate(@"=MATCH(95, B3:I3, 0)"); - Assert.AreEqual(6, value); + // The array is a row and there is only one parameter. Take the index from the row. + AssertIndex("INDEX({1,2,3,4,5,6,7}, 5)", 1, 1, 5); - value = ws.Evaluate(@"=MATCH(DATE(2015,1,6), B3:I3, 0)"); - Assert.AreEqual(2, value); + // The array is a column and there is only one parameter. Take the index from the column. + AssertIndex("INDEX({1;2;3;4;5;6;7}, 6)", 1, 1, 6); - value = ws.Evaluate(@"=MATCH(1.99, 3:3, 0)"); - Assert.AreEqual(8, value); + // Take whole range. + AssertIndex("INDEX({1,2,3;4,5,6}, 0, 0)", 2, 3, 1); - value = ws.Evaluate(@"=MATCH(43, B:B, 0)"); - Assert.AreEqual(45, value); + return; - value = ws.Evaluate(@"=MATCH(""cENtraL"", D3:D45, 0)"); - Assert.AreEqual(2, value); + void AssertIndex(string formula, int rows, int cols, XLCellValue value) + { + Assert.AreEqual(value, XLWorkbook.EvaluateExpr(formula)); + Assert.AreEqual(rows, XLWorkbook.EvaluateExpr($"ROWS({formula})")); + Assert.AreEqual(cols, XLWorkbook.EvaluateExpr($"COLUMNS({formula})")); + Assert.AreEqual(false, XLWorkbook.EvaluateExpr($"ISREF({formula})")); + } + } - value = ws.Evaluate(@"=MATCH(4.99, H:H, 0)"); - Assert.AreEqual(5, value); + [Test] + public void Index_array_errors() + { + // Row bounds + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("INDEX({1}, -1, 1)")); + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("INDEX({1,2;3,4;5,6}, 4, 1)")); - value = ws.Evaluate(@"=MATCH(""Rapture"", B2:I2, 1)"); - Assert.AreEqual(2, value); + // Column bounds + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("INDEX({1}, 1, -1)")); + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("INDEX({1,2;3,4;5,6}, 1, 3)")); - value = ws.Evaluate(@"=MATCH(22.5, B3:B45, 1)"); - Assert.AreEqual(22, value); + // Area bounds + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("INDEX({1}, 1, 1, 0)")); + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("INDEX({1}, 1, 1, 2)")); + } - value = ws.Evaluate(@"=MATCH(""Rep"", B2:I2)"); - Assert.AreEqual(4, value); + [Test] + public void Index_scalar() + { + Assert.AreEqual("Text", XLWorkbook.EvaluateExpr("INDEX(\"Text\", 1, 1)")); + Assert.AreEqual("Text", XLWorkbook.EvaluateExpr("INDEX(\"Text\", 0, 0)")); + Assert.AreEqual(2, XLWorkbook.EvaluateExpr("TYPE(INDEX(\"Text\", 1, 1))")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("INDEX(IF(TRUE,), 1, 1)")); + + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("INDEX(\"Text\", -1, 1)")); + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("INDEX(\"Text\", 2, 1)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("INDEX(\"Text\", 1, -1)")); + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("INDEX(\"Text\", 1, 2)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("INDEX(\"Text\", 1, 1, 0)")); + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("INDEX(\"Text\", 1, 1, 2)")); + } - value = ws.Evaluate(@"=MATCH(""Rep"", B2:I2, 1)"); - Assert.AreEqual(4, value); + [TestCase(@"MATCH(""Rep"", B2:I2, 0)", 4)] + [TestCase(@"MATCH(""Rep"", A2:Z2, 0)", 5)] + [TestCase(@"MATCH(""REP"", B2:I2, 0)", 4)] + [TestCase(@"MATCH(95, B3:I3, 0)", 6)] + [TestCase(@"MATCH(DATE(2015,1,6), B3:I3, 0)", 2)] + [TestCase(@"MATCH(1.99, 3:3, 0)", 8)] + [TestCase(@"MATCH(43, B:B, 0)", 45)] + [TestCase(@"MATCH(""cENtraL"", D3:D45, 0)", 2)] + [TestCase(@"MATCH(4.99, H:H, 0)", 5)] + [TestCase(@"MATCH(""Rapture"", B2:I2, 1)", 2)] + [TestCase(@"MATCH(22.5, B3:B45, 1)", 22)] + [TestCase(@"MATCH(""Rep"", B2:I2)", 4)] + [TestCase(@"MATCH(""Rep"", B2:I2, 1)", 4)] + [TestCase(@"MATCH(E2, B2:I2, 0)", 4)] + [TestCase(@"MATCH(40, G3:G6, -1)", 2)] + [TestCase(@"MATCH(""Rep"", B2:I5)", XLError.NoValueAvailable)] + [TestCase(@"MATCH(""Dummy"", B2:I2, 0)", XLError.NoValueAvailable)] + [TestCase(@"MATCH(4.5,B3:B45,-1)", XLError.NoValueAvailable)] + public void Match_demo_sheet(string formula, object result) + { + var actual = ws.Evaluate(formula); + Assert.AreEqual(result, actual); + } - value = ws.Evaluate(@"=MATCH(40, G3:G6, -1)"); - Assert.AreEqual(2, value); + [Test] + public void Match_examples() + { + // Examples from specification + Assert.AreEqual(2, XLWorkbook.EvaluateExpr("MATCH(39,{25,38,40,41},1)")); + Assert.AreEqual(4, XLWorkbook.EvaluateExpr("MATCH(41,{25,38,40,41},0)")); + + // Example from office website + using var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + sheet.Cell("A1").InsertData(new object[] + { + ("Product", "Count"), + ("Bananas", 25), + ("Oranges", 38), + ("Apples", 40), + ("Pears", 41), + }); + + Assert.AreEqual(2, sheet.Evaluate("MATCH(39,B2:B5,1)")); + Assert.AreEqual(4, sheet.Evaluate("MATCH(41,B2:B5,0)")); + Assert.AreEqual(XLError.NoValueAvailable, sheet.Evaluate("MATCH(40,B2:B5,-1)")); + } + + [TestCase("MATCH(5, {10,5,4,5,5,5,5,5}, -1)", 2)] // Doesn't use bisection, otherwise it would pick later position + [TestCase("MATCH(5, {10,4,5}, -1)", 1)] // Because 4 is less than the target, search stops. Values should be descending. + [TestCase("MATCH(5, {\"5\",10,\"4\",FALSE,TRUE,#DIV/0!,5,3}, -1)", 7)] // Non-target values are ignored + [TestCase("MATCH(6, {\"4\",10,\"4\",FALSE,TRUE,#DIV/0!,5,3}, -1)", 2)] // Returned position is of the correct type, not just before less than target. + [TestCase("MATCH(5, {\"5\"}, -1)", XLError.NoValueAvailable)] // String values are not converted to numbers + [TestCase("MATCH(5, {4}, -1)", XLError.NoValueAvailable)] + [TestCase("MATCH(5, {10}, -1)", 1)] + [TestCase("MATCH(5, {TRUE}, -1)", XLError.NoValueAvailable)] + [TestCase("MATCH(\"c\", {\"E\",4,\"D\",\"B\"}, -1)", 3)] + [TestCase("MATCH(FALSE, {TRUE,TRUE,\"FALSE\",0,FALSE,FALSE}, -1)", 5)] + public void Match_from_descending(string formula, object result) + { + var actual = XLWorkbook.EvaluateExpr(formula); + Assert.AreEqual(result, actual); + } + + [TestCase("MATCH(35,{25,38,24,35,70},0)", 4)] // Finds value even in unsorted + [TestCase("MATCH(35,{\"35\",38,24,35,70},0)", 4)] // String values are not converted, must match type + [TestCase("MATCH(1,{5},0)", XLError.NoValueAvailable)] // Nothing found + [TestCase("MATCH(\"35\",{35,38,24,\"35\",70},0)", 4)] // String target is not converted, must match type + [TestCase("MATCH(\"c*\",{\"a\",\"cd\"},0)", 2)] // Consider string targets wildcards + [TestCase("MATCH(TRUE, {0,\"TRUE\",FALSE,TRUE,1},0)", 4)] + public void Match_from_unsorted(string formula, object result) + { + var actual = XLWorkbook.EvaluateExpr(formula); + Assert.AreEqual(result, actual); + } + + [TestCase("MATCH(39,{25,38,38,38,40,41},1)", 4)] // When there is a sequence of target values, return last one + [TestCase("MATCH(20,{25,38,40},1)", XLError.NoValueAvailable)] // Nothing found, even smallest value is greater than target + [TestCase("MATCH(25,{20,TRUE,FALSE,38,40},1)", 1)] // If found value is <= target, return position of value, not subsequent types that are ignored + [TestCase("MATCH(8, {FALSE;FALSE}, 1)", XLError.NoValueAvailable)] // Not even one value of target type + [TestCase("MATCH(5, {1,2,3}, 1)", 3)] // If target value is greater than the last element of same type, return the position of the last element + public void Match_from_ascending(string formula, object result) + { + var actual = XLWorkbook.EvaluateExpr(formula); + Assert.AreEqual(result, actual); + } + + [TestCase("MATCH(17, {14;5;3;5;11;12;11;13;13;4})", 10)] + [TestCase("MATCH(12, {5;15;18;18;11;1;15;17})", 1)] + [TestCase("MATCH(4, {10,3,FALSE, FALSE,FALSE})", XLError.NoValueAvailable)] + [TestCase("MATCH(8, {14;0;17;FALSE;8})", XLError.NoValueAvailable)] + public void Match_from_ascending_matches_excel(string formula, object result) + { + // The bisection algorithm should match Excel. That is checked by supplying + // non-ascending data and checking the result against Excel result. Use random + // generator to generate formulas + compare with Excel when modifying the algorithm. + var actual = XLWorkbook.EvaluateExpr(formula); + Assert.AreEqual(result, actual); + } + + [TestCase("MATCH(#DIV/0!,{1,2,3},1)", XLError.DivisionByZero)] // Scalar argument is error -> propagate + [TestCase("MATCH(IF(TRUE,),{1,2,3},1)", XLError.NoValueAvailable)] // Return not found for blank value + [TestCase("MATCH(1,{1,2;3,4},1)", XLError.NoValueAvailable)] // Must be either row or column, the array is 2x2 + [TestCase("MATCH(1,{3,2,1},-2)", 3)] // Match type can be negative for match type -1 + [TestCase("MATCH(1,{1,2,3}, 2)", 1)] // Match type can be positive for match type 1 + [TestCase("MATCH(2,{1;2;3}, 2)", 2)] // Match returns position from start both in row or column + [TestCase("MATCH(2,{1,2,3}, 2)", 2)] // Match returns position from start both in row or column + [TestCase("MATCH(3,{1,2,3,4,5})", 3)] // Default match type is 1 (ascending bisection) + [TestCase("MATCH(3,3)", XLError.NoValueAvailable)] // Scalar values are not converted to 1x1 array + public void Match_edge_conditions(string formula, object result) + { + var actual = XLWorkbook.EvaluateExpr(formula); + Assert.AreEqual(result, actual); } [Test] - public void Match_Exceptions() + public void Match_accepts_single_cell_as_values() { - Assert.Throws(() => ws.Evaluate(@"=MATCH(""Rep"", B2:I5)")); - Assert.Throws(() => ws.Evaluate(@"=MATCH(""Dummy"", B2:I2, 0)")); - Assert.Throws(() => ws.Evaluate(@"=MATCH(4.5,B3:B45,-1)")); + using var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + sheet.Cell("A1").Value = 5; + Assert.AreEqual(1, sheet.Evaluate("MATCH(5, A1)")); } [Test] @@ -272,12 +607,69 @@ public void Row() Assert.AreEqual(XLError.IncompatibleValue, ws.Cell("A9").SetFormulaA1("ROW(IF(TRUE,5))").Value); Assert.AreEqual(XLError.IncompatibleValue, ws.Cell("A10").SetFormulaA1("ROW(IF(TRUE,\"G15\"))").Value); Assert.AreEqual(XLError.DivisionByZero, ws.Cell("A11").SetFormulaA1("ROW(#DIV/0!)").Value); + + // Properly works even in array formulas, where border between references and arrays blurs. + ws.Range("A12:A13").FormulaArrayA1 = "ROW(2:3)"; + Assert.AreEqual(2, ws.Cell("A12").Value); + Assert.AreEqual(3, ws.Cell("A13").Value); + } + + [Test] + public void Rows_Blank_ReturnsValueError() + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("ROWS(IF(TRUE,,))")); + } + + [TestCase("0")] + [TestCase("1")] + [TestCase("99")] + [TestCase("-10")] + [TestCase("TRUE")] + [TestCase("FALSE")] + [TestCase("\"\"")] + [TestCase("\"A\"")] + [TestCase("\"Hello World\"")] + public void Rows_ScalarValues_ReturnsOne(string value) + { + Assert.AreEqual(1, XLWorkbook.EvaluateExpr($"ROWS({value})")); + } + + [Test] + public void Rows_Error_ReturnsError() + { + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("ROWS(#DIV/0!)")); + } + + [TestCase("{1}", 1)] + [TestCase("{1;2;3}", 3)] + [TestCase("{1,2,3,4;5,6,7,8;9,10,11,12}", 3)] + [TestCase("{TRUE;#DIV/0!}", 2)] + public void Rows_Arrays_ReturnsNumberOfRows(string array, int expectedColumnCount) + { + Assert.AreEqual(expectedColumnCount, XLWorkbook.EvaluateExpr($"ROWS({array})")); + } + + [TestCase("C3", 1)] + [TestCase("B3:E12", 10)] + [TestCase("AA21:AC400", 380)] + public void Rows_References_ReturnsNumberOfColumns(string range, int expectedColumnCount) + { + using var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + Assert.AreEqual(expectedColumnCount, sheet.Evaluate($"ROWS({range})")); + } + + [Test] + public void Rows_NonContiguousReferences_ReturnsReferenceError() + { + // Spec says #NULL!, but Excel says #REF! + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("ROWS((A1,C3))")); } [Test] public void Vlookup() { - // Range lookup false + // Range lookup false = exact match var value = ws.Evaluate("=VLOOKUP(3,Data!$B$2:$I$71,3,FALSE)"); Assert.AreEqual("Central", value); @@ -291,7 +683,7 @@ public void Vlookup() value = ws.Evaluate(@"=VLOOKUP(""central"",Data!D:E,2,FALSE)"); Assert.AreEqual("Kivell", value); - // Range lookup true + // Range lookup true = approximate match value = ws.Evaluate("=VLOOKUP(3,Data!$B$2:$I$71,8,TRUE)"); Assert.AreEqual(179.64, value); @@ -309,13 +701,145 @@ public void Vlookup() } [Test] - public void Vlookup_Exceptions() + public void Vlookup_ElementNotFound_ReturnsNotAvailableError() { - Assert.Throws(() => ws.Evaluate(@"=VLOOKUP("""",Data!$B$2:$I$71,3,FALSE)")); - Assert.Throws(() => ws.Evaluate(@"=VLOOKUP(50,Data!$B$2:$I$71,3,FALSE)")); - Assert.Throws(() => ws.Evaluate(@"=VLOOKUP(-1,Data!$B$2:$I$71,2,TRUE)")); + // Value not present in the range for exact search + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate(@"=VLOOKUP("""",Data!$B$2:$I$71,3,FALSE)")); + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate(@"=VLOOKUP(50,Data!$B$2:$I$71,3,FALSE)")); + + // Value in approximate search that is lower than first element + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate(@"=VLOOKUP(-1,Data!$B$2:$I$71,2,TRUE)")); + } + + [Test] + public void Vlookup_UnexpectedArguments() + { + // Lookup value can't be an error + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("=VLOOKUP(#DIV/0!,B2:I71,1)")); + + // Text value can't be over 255 chars + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate($"=VLOOKUP(\"{new string('A', 256)}\",B2:I71,1)")); + + // Range can only be array or a reference. If other type, it returns the error #N/A + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("=VLOOKUP(1,1,1)")); + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("=VLOOKUP(1,TRUE,1)")); + + // If range is a non-contiguous range, #N/A + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("=VLOOKUP(1,(B2:I5,B6:I10),1)")); - Assert.Throws(() => ws.Evaluate(@"=VLOOKUP(20,Data!$B$2:$I$71,9,FALSE)")); + // The column index must be at most the same as width of the range. It is 9 here, but range is 8 cell wide. + Assert.AreEqual(XLError.CellReference, ws.Evaluate("=VLOOKUP(20,B2:I71,9,FALSE)")); + // The column index must be at least 1. It is 0 here. + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("=VLOOKUP(20,B2:I71,0,FALSE)")); + } + + [Test] + public void Vlookup_ColumnIndexParameter_UsesValueSemantic() + { + // If column index is not a whole number, it is truncated, so here 1.9 is truncated to 1 + Assert.AreEqual(14.0, ws.Evaluate("=VLOOKUP(14,B2:I71,1.9)")); + + // Column index is evaluated using a VALUE semantic + Assert.AreEqual(@"Jardine", ws.Evaluate("=VLOOKUP(3,B2:I71,\"2 5/2\")")); + } + + [TestCase("\"TRUE\"")] + [TestCase("1")] + [TestCase("TRUE")] + public void Vlookup_FlagParameter_CoercedToBoolean(string flagValue) + { + Assert.AreEqual(5.0, ws.Evaluate($"VLOOKUP(5,B2:I71,1,{flagValue})")); + } + + [Test] + public void Vlookup_BlankLookupValue_BehavesAsZero() + { + using var wb = new XLWorkbook(); + var worksheet = wb.AddWorksheet(); + worksheet.Cell("A1").InsertData(Enumerable.Range(-5, 10).Select(x => new object[] { x, $"Row with value {x}" })); + + var actual = worksheet.Evaluate("VLOOKUP(IF(TRUE,,),A1:B10,2)"); + + Assert.AreEqual("Row with value 0", actual); + } + + [Test] + public void Vlookup_ApproximateSearch_OmitsValuesWithDifferentType() + { + using var wb = new XLWorkbook(); + var worksheet = wb.AddWorksheet(); + worksheet.Cell("A1").Value = "0"; + worksheet.Cell("A2").Value = "1"; + worksheet.Cell("A3").Value = 1; + worksheet.Cell("A4").Value = "0"; + worksheet.Cell("A5").Value = "text"; + worksheet.Cell("A6").Value = Blank.Value; + worksheet.Cell("A7").Value = 2; + worksheet.Cell("B1").InsertData(Enumerable.Range(1, 7).Select(x => $"Row {x}")); + + var actual = worksheet.Evaluate("VLOOKUP(1.9,A1:B7,2,TRUE)"); + Assert.AreEqual("Row 3", actual); + } + + [Test] + public void Vlookup_OnlyCellsWithDifferentType_ReturnsNotAvailable() + { + using var wb = new XLWorkbook(); + var worksheet = wb.AddWorksheet(); + Assert.AreEqual(XLError.NoValueAvailable, worksheet.Evaluate("VLOOKUP(1,A1,1,TRUE)")); + } + + [Test] + public void Vlookup_OnlyOneValueSurroundedByIgnoredTypes() + { + using var wb = new XLWorkbook(); + var worksheet = wb.AddWorksheet(); + worksheet.Cell("A3").Value = 5; + + Assert.AreEqual(5, worksheet.Evaluate("VLOOKUP(6,A1:A5,1,TRUE)")); + } + + [Test] + public void Vlookup_ResultAtTheHighestCellWithTrailingDifferentTypeAtTheEnd() + { + using var wb = new XLWorkbook(); + var worksheet = wb.AddWorksheet(); + worksheet.Cell("A1").Value = 1; + worksheet.Cell("A2").Value = 2; + worksheet.Cell("A3").Value = 3; + worksheet.Cell("A4").Value = Blank.Value; + + Assert.AreEqual(3, worksheet.Evaluate("VLOOKUP(3,A1:A4,1,TRUE)")); + } + + [Test] + public void Vlookup_ApproximateSearch_ReturnsLastRowForMultipleEqualValues() + { + var wb = new XLWorkbook(); + var sheet = wb.AddWorksheet(); + sheet.Cell("A1").Value = 1; + sheet.Cell("A2").Value = 3; + sheet.Cell("A3").Value = 3; + sheet.Cell("A4").Value = 3; + sheet.Cell("A5").Value = 3; + sheet.Cell("A6").Value = 3; + sheet.Cell("A7").Value = 3; + sheet.Cell("A8").Value = 9; + sheet.Cell("B1").InsertData(Enumerable.Range(1, 8)); + + // If there is a section of values with same value, return the value at the highest row + var actual = sheet.Evaluate("VLOOKUP(3, A1:B8, 2, TRUE)"); + Assert.AreEqual(7, actual); + + // If the last value is in the highest row, just return value outright + actual = sheet.Evaluate("VLOOKUP(3, A2:B7, 2, TRUE)"); + Assert.AreEqual(7, actual); + } + + [Test] + public void Vlookup_CanSearchArrays() + { + Assert.AreEqual(2, XLWorkbook.EvaluateExpr("VLOOKUP(4, {1,2; 3,2; 5,3; 7,4}, 2)")); } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/MathTrigTests.cs b/ClosedXML.Tests/Excel/CalcEngine/MathTrigTests.cs index cf457b9ed..c2f02ff66 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/MathTrigTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/MathTrigTests.cs @@ -1,17 +1,18 @@ // Keep this file CodeMaid organised and cleaned using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine.Exceptions; using NUnit.Framework; using System; using System.Globalization; using System.Linq; +using System.Text; namespace ClosedXML.Tests.Excel.CalcEngine { [TestFixture] + [SetCulture("en-US")] public class MathTrigTests { - private readonly double tolerance = 1e-10; + private const double tolerance = 1e-10; [Theory] public void Abs_ReturnsItselfOnPositiveNumbers([Range(0, 10, 0.1)] double input) @@ -50,22 +51,22 @@ public void Abs_ReturnsTheCorrectValueOnNegativeInput([Range(-10, -0.1, 0.1)] do [TestCase(1, 0)] public void Acos_ReturnsCorrectValue(double input, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ACOS({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ACOS({input})"); Assert.AreEqual(expectedResult, actual, tolerance * 10); } [Theory] - public void Acos_ThrowsNumberExceptionOutsideRange([Range(1.1, 3, 0.1)] double input) + public void Acos_returns_error_when_number_outside_range([Range(1.1, 3, 0.1)] double input) { // checking input and it's additive inverse as both are outside range. - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"ACOS({0})", input.ToString(CultureInfo.InvariantCulture)))); - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"ACOS({0})", (-input).ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"ACOS({input})")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"ACOS({-input})")); } [Theory] public void Acosh_NumbersBelow1ThrowNumberException([Range(-1, 0.9, 0.1)] double input) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"ACOSH({0})", input.ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"ACOSH({input})")); } [TestCase(1.2, 0.622362504)] @@ -86,9 +87,9 @@ public void Acosh_NumbersBelow1ThrowNumberException([Range(-1, 0.9, 0.1)] double [TestCase(5.7, 2.425828318)] [TestCase(6, 2.47788873)] [TestCase(1, 0)] - public void Acosh_ReturnsCorrectValue(double input, double expectedResult) + public void Acosh_returns_correct_number(double angle, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ACOSH({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ACOSH({angle})"); Assert.AreEqual(expectedResult, actual, tolerance * 10); } @@ -113,16 +114,16 @@ public void Acosh_ReturnsCorrectValue(double input, double expectedResult) [TestCase(8, 0.124354995)] [TestCase(9, 0.110657221)] [TestCase(10, 0.099668652)] - public void Acot_ReturnsCorrectValue(double input, double expectedResult) + public void Acot_returns_correct_number(double angle, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ACOT({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ACOT({angle})"); Assert.AreEqual(expectedResult, actual, tolerance * 10); } [Theory] - public void Acoth_ForPlusMinusXSmallerThan1_ThrowsNumberException([Range(-0.9, 0.9, 0.1)] double input) + public void Acoth_returns_error_for_absolute_angle_smaller_than_one([Range(-0.9, 0.9, 0.1)] double angle) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"ACOTH({0})", input.ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"ACOTH({angle})")); } [TestCase(-10, -0.100335348)] @@ -143,35 +144,47 @@ public void Acoth_ForPlusMinusXSmallerThan1_ThrowsNumberException([Range(-0.9, 0 [TestCase(8, 0.125657214)] [TestCase(9, 0.111571776)] [TestCase(10, 0.100335348)] - public void Acoth_ReturnsCorrectValue(double input, double expectedResult) + [TestCase(1E+100, 1E-100)] + public void Acoth_returns_correct_number(double angle, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ACOTH({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ACOTH({angle})"); Assert.AreEqual(expectedResult, actual, tolerance * 10); } [TestCase("LVII", 57)] - [TestCase("mcmxii", 1912)] + [TestCase(@"mcmxii", 1912)] [TestCase("", 0)] [TestCase("-IV", -4)] - [TestCase(" XIV", 14)] - [TestCase("MCMLXXXIII ", 1983)] - public void Arabic_ReturnsCorrectNumber(string roman, int arabic) - { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format($"ARABIC(\"{roman}\")")); + [TestCase(" XIV ", 14)] + [TestCase(@"MCMLXXXIII ", 1983)] + [TestCase(@"IIIIIIIIM", 992)] + [TestCase(@"CIVIIX", 102)] + [TestCase(@"IIX", 8)] + [TestCase(@"VIII", 8)] + public void Arabic_returns_correct_number(string roman, int arabic) + { + var actual = (double)XLWorkbook.EvaluateExpr($"ARABIC(\"{roman}\")"); Assert.AreEqual(arabic, actual); } [Test] - public void Arabic_ThrowsNumberExceptionOnMinus() + public void Arabic_solitary_minus_is_not_valid_roman_number() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("ARABIC(\"-\")")); + } + + [Test] + public void Arabic_can_have_at_most_255_chars() { - Assert.Throws(() => XLWorkbook.EvaluateExpr("ARABIC(\"-\")")); + Assert.AreEqual(255000, XLWorkbook.EvaluateExpr($"ARABIC(\"{new string('M', 255)}\")")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"ARABIC(\"{new string('M', 256)}\")")); } [TestCase("- I")] [TestCase("roman")] - public void Arabic_ThrowsValueExceptionOnInvalidNumber(string invalidRoman) + public void Arabic_returns_conversion_error_on_invalid_numbers(string invalidRoman) { - Assert.Throws(() => XLWorkbook.EvaluateExpr($"ARABIC(\"{invalidRoman}\")")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"ARABIC(\"{invalidRoman}\")")); } [TestCase(-1, -1.570796327)] @@ -197,15 +210,15 @@ public void Arabic_ThrowsValueExceptionOnInvalidNumber(string invalidRoman) [TestCase(1, 1.570796327)] public void Asin_ReturnsCorrectResult(double input, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ASIN({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ASIN({input})"); Assert.AreEqual(expectedResult, actual, tolerance * 10); } [Theory] public void Asin_ThrowsNumberExceptionWhenAbsOfInputGreaterThan1([Range(-3, -1.1, 0.1)] double input) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"ASIN({0})", input.ToString(CultureInfo.InvariantCulture)))); - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"ASIN({0})", (-input).ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"ASIN({input})")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"ASIN({-input})")); } [TestCase(0, 0)] @@ -225,9 +238,9 @@ public void Asin_ThrowsNumberExceptionWhenAbsOfInputGreaterThan1([Range(-3, -1.1 [TestCase(5, 2.31243834127275)] public void Asinh_ReturnsCorrectResult(double input, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ASINH({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ASINH({input})"); Assert.AreEqual(expectedResult, actual, tolerance); - var minusActual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ASINH({0})", (-input).ToString(CultureInfo.InvariantCulture))); + var minusActual = (double)XLWorkbook.EvaluateExpr($"ASINH({-input})"); Assert.AreEqual(-expectedResult, minusActual, tolerance); } @@ -248,16 +261,16 @@ public void Asinh_ReturnsCorrectResult(double input, double expectedResult) [TestCase(5, 1.37340076694502)] public void Atan_ReturnsCorrectResult(double input, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ATAN({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ATAN({input})"); Assert.AreEqual(expectedResult, actual, tolerance); - var minusActual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ATAN({0})", (-input).ToString(CultureInfo.InvariantCulture))); + var minusActual = (double)XLWorkbook.EvaluateExpr($"ATAN({-input})"); Assert.AreEqual(-expectedResult, minusActual, tolerance); } [Test] public void Atan2_Returns0OnSecond0AndFirstGreater0([Range(0.1, 5, 0.4)] double input) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ATAN2({0}, 0)", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ATAN2({input}, 0)"); Assert.AreEqual(0, actual, tolerance); } @@ -282,12 +295,7 @@ public void Atan2_ReturnsCorrectResults_EqualOnAllMultiplesOfFraction(double x, { for (int i = 1; i < 5; i++) { - var actual = (double)XLWorkbook.EvaluateExpr( - string.Format( - @"ATAN2({0}, {1})", - (x * i).ToString(CultureInfo.InvariantCulture), - (y * i).ToString(CultureInfo.InvariantCulture))); - + var actual = (double)XLWorkbook.EvaluateExpr($"ATAN2({x * i}, {y * i})"); Assert.AreEqual(expectedResult, actual, tolerance); } } @@ -295,42 +303,42 @@ public void Atan2_ReturnsCorrectResults_EqualOnAllMultiplesOfFraction(double x, [Test] public void Atan2_ReturnsHalfPiOn0AsFirstInputWhenSecondGreater0([Range(0.1, 5, 0.4)] double input) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ATAN2(0, {0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ATAN2(0, {input})"); Assert.AreEqual(0.5 * Math.PI, actual, tolerance); } [Test] public void Atan2_ReturnsMinus3QuartersOfPiWhenFirstSmaller0AndSecondItsNegative([Range(-5, -0.1, 0.3)] double input) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ATAN2({0}, {0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ATAN2({input}, {input})"); Assert.AreEqual(-0.75 * Math.PI, actual, tolerance); } [Test] public void Atan2_ReturnsMinusHalfPiOn0AsFirstInputWhenSecondSmaller0([Range(-5, -0.1, 0.4)] double input) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ATAN2(0, {0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ATAN2(0, {input})"); Assert.AreEqual(-0.5 * Math.PI, actual, tolerance); } [Test] public void Atan2_ReturnsPiOn0AsSecondInputWhenFirstSmaller0([Range(-5, -0.1, 0.4)] double input) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ATAN2({0}, 0)", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ATAN2({input}, 0)"); Assert.AreEqual(Math.PI, actual, tolerance); } [Test] public void Atan2_ReturnsQuarterOfPiWhenInputsAreEqualAndGreater0([Range(0.1, 5, 0.3)] double input) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"ATAN2({0}, {0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"ATAN2({input}, {input})"); Assert.AreEqual(0.25 * Math.PI, actual, tolerance); } [Test] public void Atan2_ThrowsDiv0ExceptionOn0And0() { - Assert.Throws(() => XLWorkbook.EvaluateExpr(@"ATAN2(0, 0)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("ATAN2(0, 0)")); } [TestCase(-0.99, -2.64665241236225)] @@ -349,19 +357,15 @@ public void Atan2_ThrowsDiv0ExceptionOn0And0() [TestCase(-0.999, -3.8002011672502)] public void Atanh_ReturnsCorrectResults(double input, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr( - string.Format( - @"ATANH({0})", - input.ToString(CultureInfo.InvariantCulture))); - + var actual = (double)XLWorkbook.EvaluateExpr($"ATANH({input})"); Assert.AreEqual(expectedResult, actual, tolerance * 10); } [Theory] public void Atanh_ThrowsNumberExceptionWhenAbsOfInput1OrGreater([Range(1, 5, 0.2)] double input) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"ATANH({0})", input.ToString(CultureInfo.InvariantCulture)))); - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"ATANH({0})", (-input).ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"ATANH({input})")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"ATANH({-input})")); } [TestCase(0, 36, "0")] @@ -403,9 +407,9 @@ public void Atanh_ThrowsNumberExceptionWhenAbsOfInput1OrGreater([Range(1, 5, 0.2 [TestCase(36, 36, "10")] [TestCase(255, 29, "8N")] [TestCase(255, 2, "11111111")] - public void Base_ReturnsCorrectResultOnInput(int input, int theBase, string expectedResult) + public void Base_returns_number_in_specified_base(int input, int radix, string expectedResult) { - var actual = (string)XLWorkbook.EvaluateExpr(string.Format(@"BASE({0}, {1})", input, theBase)); + var actual = (string)XLWorkbook.EvaluateExpr($"BASE({input},{radix})"); Assert.AreEqual(expectedResult, actual); } @@ -413,36 +417,44 @@ public void Base_ReturnsCorrectResultOnInput(int input, int theBase, string expe [TestCase(255, 2, 8, "11111111")] [TestCase(255, 2, 10, "0011111111")] [TestCase(10, 3, 4, "0101")] - public void Base_ReturnsCorrectResultOnInputWithMinimalLength(int input, int theBase, int minLength, string expectedResult) + [TestCase(0, 10, 0, "")] + public void Base_returns_text_of_at_least_minimal_length(int input, int radix, int minLength, string expectedResult) { - var actual = (string)XLWorkbook.EvaluateExpr(string.Format(@"BASE({0}, {1}, {2})", input, theBase, minLength)); + var actual = (string)XLWorkbook.EvaluateExpr($"BASE({input},{radix},{minLength})"); Assert.AreEqual(expectedResult, actual); } + [Test] + public void Base_min_length_must_be_at_most_255() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("BASE(0,2,256)")); + } + [TestCase(@"""x""", "2", "2")] [TestCase("0", @"""x""", "2")] [TestCase("0", "2", @"""x""")] - public void Base_ThrowsCellValueExceptionOnAnyInputNotANumber(string input, string theBase, string minLength) + public void Base_coercion(string input, string radix, string minLength) { - Assert.Throws(() => XLWorkbook.EvaluateExpr($"BASE({input}, {theBase}, {minLength})")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"BASE({input},{radix},{minLength})")); } [Theory] - public void Base_ThrowsNumberExceptionOnBaseSmallerThan2([Range(-2, 1)] int theBase) + public void Base_radix_must_be_between_2_and_36([Range(-2, 1), Range(37, 40)] int radix) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"BASE(0, {0})", theBase.ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"BASE(0,{radix})")); } [Theory] - public void Base_ThrowsNumberExceptionOnInputSmallerThan0([Range(-5, -1)] int input) + public void Base_number_must_be_zero_or_positive([Range(-5, -1)] int input) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"BASE({0}, 2)", input.ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"BASE({input},2)")); } [Theory] - public void Base_ThrowsNumberExceptionOnRadixGreaterThan36([Range(37, 40)] int radix) + public void Base_number_must_fit_in_double_without_precision_loss() { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"BASE(1, {0})", radix.ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(@"2GOPQOE5GCG", XLWorkbook.EvaluateExpr("BASE(9.007E+15,36)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("BASE(9.008E+15,36)")); } [TestCase(24.3, 5, 25)] @@ -453,21 +465,31 @@ public void Base_ThrowsNumberExceptionOnRadixGreaterThan36([Range(37, 40)] int r [TestCase(-5.5, 2.1, -4.2)] [TestCase(-5.5, -2.1, -6.3)] [TestCase(-5.5, 0, 0)] + [TestCase(0, 0, 0)] + [TestCase(0, 0.1, 0)] + [TestCase(0, -0.1, 0)] + [TestCase(0.1, 0, 0)] + [TestCase(-0.1, 0, 0)] public void Ceiling(double input, double significance, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr($"CEILING({input.ToInvariantString()}, {significance.ToInvariantString()})"); + var actual = (double)XLWorkbook.EvaluateExpr($"CEILING({input}, {significance})"); Assert.AreEqual(expectedResult, actual, tolerance); } [TestCase(6.7, -1)] - public void Ceiling_ThrowsNumberExceptionOnInvalidInput(double input, double significance) + [TestCase(0.1, -0.2)] + public void Ceiling_returns_error_on_different_number_and_significance(double input, double significance) { - Assert.Throws(() => XLWorkbook.EvaluateExpr($"CEILING({input.ToInvariantString()}, {significance.ToInvariantString()})")); + // Spec says "if x and significance have different signs, #NUM! is returned.", + // but in reality it only happens when number is positive and step negative. + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"CEILING({input}, {significance})")); } [TestCase(24.3, 5, null, 25)] [TestCase(6.7, null, null, 7)] [TestCase(-8.1, 2, null, -8)] + [TestCase(-5.5, 2, -1, -6)] + [TestCase(-5.5, 2, -0.1, -6)] [TestCase(5.5, 2.1, 0, 6.3)] [TestCase(5.5, -2.1, 0, 6.3)] [TestCase(5.5, 0, 0, 0)] @@ -486,14 +508,15 @@ public void Ceiling_ThrowsNumberExceptionOnInvalidInput(double input, double sig [TestCase(-5.5, 2.1, 10, -6.3)] [TestCase(-5.5, -2.1, 10, -6.3)] [TestCase(-5.5, 0, 10, 0)] - public void CeilingMath(double input, double? step, int? mode, double expectedResult) + public void CeilingMath(double input, double? significance, double? mode, double expectedResult) { - string parameters = input.ToString(CultureInfo.InvariantCulture); - if (step != null) + var parameters = new StringBuilder(); + parameters.Append(input); + if (significance != null) { - parameters = parameters + ", " + step?.ToString(CultureInfo.InvariantCulture); + parameters.Append(", ").Append(significance); if (mode != null) - parameters = parameters + ", " + mode?.ToString(CultureInfo.InvariantCulture); + parameters.Append(", ").Append(mode); } var actual = (double)XLWorkbook.EvaluateExpr($"CEILING.MATH({parameters})"); @@ -503,58 +526,82 @@ public void CeilingMath(double input, double? step, int? mode, double expectedRe [Test] public void Combin() { - object actual1 = XLWorkbook.EvaluateExpr("Combin(200, 2)"); + var actual1 = XLWorkbook.EvaluateExpr("COMBIN(200, 2)"); Assert.AreEqual(19900.0, actual1); - object actual2 = XLWorkbook.EvaluateExpr("Combin(20.1, 2.9)"); + var actual2 = XLWorkbook.EvaluateExpr("COMBIN(20.1, 2.9)"); Assert.AreEqual(190.0, actual2); } [Theory] - public void Combin_Returns1ForKis0OrKEqualsN([Range(0, 10)] int n) + public void Combin_returns_1_for_k_is_0_or_k_equals_n([Range(0, 10)] int n) { - var actual = XLWorkbook.EvaluateExpr(string.Format(@"COMBIN({0}, 0)", n)); + var actual = XLWorkbook.EvaluateExpr($"COMBIN({n}, 0)"); Assert.AreEqual(1, actual); - var actual2 = XLWorkbook.EvaluateExpr(string.Format(@"COMBIN({0}, {0})", n)); + var actual2 = XLWorkbook.EvaluateExpr($"COMBIN({n}, {n})"); Assert.AreEqual(1, actual2); } + [TestCase(0, 0, 1)] + [TestCase(1, 0, 1)] + [TestCase(1, 1, 1)] [TestCase(4, 2, 6)] [TestCase(5, 2, 10)] [TestCase(6, 2, 15)] [TestCase(6, 3, 20)] [TestCase(7, 2, 21)] [TestCase(7, 3, 35)] - public void Combin_ReturnsCorrectResults(int n, int k, int expectedResult) + public void Combin_calculates_combinations(int n, int k, int expectedResult) { - var actual = XLWorkbook.EvaluateExpr(string.Format(@"COMBIN({0}, {1})", n, k)); + var actual = XLWorkbook.EvaluateExpr($"COMBIN({n}, {k})"); Assert.AreEqual(expectedResult, actual); - var actual2 = XLWorkbook.EvaluateExpr(string.Format(@"COMBIN({0}, {1})", n, n - k)); + var actual2 = XLWorkbook.EvaluateExpr($"COMBIN({n}, {n - k})"); Assert.AreEqual(expectedResult, actual2); } [Theory] - public void Combin_ReturnsNforKis1OrKisNminus1([Range(1, 10)] int n) + public void Combin_returns_n_for_k_is_1_or_k_is_n_minus_1([Range(1, 10)] int n) { - var actual = XLWorkbook.EvaluateExpr(string.Format(@"COMBIN({0}, 1)", n)); + var actual = XLWorkbook.EvaluateExpr($"COMBIN({n}, 1)"); Assert.AreEqual(n, actual); - var actual2 = XLWorkbook.EvaluateExpr(string.Format(@"COMBIN({0}, {1})", n, n - 1)); + var actual2 = XLWorkbook.EvaluateExpr($"COMBIN({n}, {n - 1})"); Assert.AreEqual(n, actual2); } - [Theory] - public void Combin_ThrowsNumberExceptionForAnyArgumentSmaller0([Range(-4, -1)] int smaller0) + [Test] + public void Combin_returns_num_error_when_k_is_larger_than_n() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("COMBIN(5, 6)")); + + // Values are floored, so this is COMBIN(5, 5). + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("COMBIN(5, 5.5)")); + } + + [Test] + public void Combin_returns_num_error_when_value_is_too_large() + { + // Maximum int - 1 is maximum computable value in Excel. + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("COMBIN(2147483647, 2147483647)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("COMBIN(5E+301, 6)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("COMBIN(6, 5E+301)")); + } + + [TestCase(-4)] + [TestCase(-3)] + [TestCase(-1)] + [TestCase(-0.1)] + public void Combin_returns_num_error_for_any_argument_smaller_than_0(double smaller0) { - Assert.Throws(() => XLWorkbook.EvaluateExpr( + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr( string.Format( @"COMBIN({0}, {1})", smaller0.ToString(CultureInfo.InvariantCulture), (-smaller0).ToString(CultureInfo.InvariantCulture)))); - Assert.Throws(() => XLWorkbook.EvaluateExpr( + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr( string.Format( @"COMBIN({0}, {1})", (-smaller0).ToString(CultureInfo.InvariantCulture), @@ -563,57 +610,46 @@ public void Combin_ThrowsNumberExceptionForAnyArgumentSmaller0([Range(-4, -1)] i [TestCase("\"no number\"")] [TestCase("\"\"")] - public void Combin_ThrowsNumericExceptionForAnyArgumentNotNumeric(string input) + public void Combin_returns_value_error_for_any_non_numeric_argument(string input) { - Assert.Throws(() => XLWorkbook.EvaluateExpr( - string.Format( - @"COMBIN({0}, 1)", - input?.ToString(CultureInfo.InvariantCulture)))); - - Assert.Throws(() => XLWorkbook.EvaluateExpr( - string.Format( - @"COMBIN(1, {0})", - input?.ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"COMBIN({input}, 1)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"COMBIN(1, {input})")); } [TestCase(4, 3, 20)] [TestCase(10, 3, 220)] [TestCase(0, 0, 1)] - public void Combina_CalculatesCorrectValues(int number, int chosen, int expectedResult) + [TestCase(1, 0, 1)] + [TestCase(10, 15, 1307504)] + public void Combina_calculates_correct_values(int number, int chosen, int expectedResult) { var actualResult = XLWorkbook.EvaluateExpr($"COMBINA({number}, {chosen})"); - Assert.AreEqual(expectedResult, (double)actualResult); + Assert.AreEqual(expectedResult, actualResult); } [Theory] - public void Combina_Returns1WhenChosenIs0([Range(0, 10)] int number) + public void Combina_returns_one_when_chosen_is_zero([Range(0, 10)] int number) { - Combina_CalculatesCorrectValues(number, 0, 1); + var actualResult = XLWorkbook.EvaluateExpr($"COMBINA({number}, 0)"); + Assert.AreEqual(1, actualResult); } [TestCase(-1, 2)] [TestCase(-3, -2)] [TestCase(2, -2)] - public void Combina_ThrowsNumExceptionOnInvalidValues(int number, int chosen) + [TestCase(int.MaxValue + 1d, 1)] + public void Combina_returns_error_on_invalid_values(double number, int chosen) { - Assert.Throws(() => XLWorkbook.EvaluateExpr( - string.Format( - @"COMBINA({0}, {1})", - number.ToString(CultureInfo.InvariantCulture), - chosen.ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"COMBINA({number}, {chosen})")); } [TestCase(4.23, 3, 20)] [TestCase(10.4, 3.14, 220)] [TestCase(0, 0.4, 1)] - public void Combina_TruncatesNumbersCorrectly(double number, double chosen, int expectedResult) + public void Combina_truncates_numbers_to_zero(double number, double chosen, int expectedResult) { - var actualResult = XLWorkbook.EvaluateExpr(string.Format( - @"COMBINA({0}, {1})", - number.ToString(CultureInfo.InvariantCulture), - chosen.ToString(CultureInfo.InvariantCulture))); - - Assert.AreEqual(expectedResult, (double)actualResult); + var actualResult = XLWorkbook.EvaluateExpr($"COMBINA({number}, {chosen})"); + Assert.AreEqual(expectedResult, actualResult); } [TestCase(0, 1)] @@ -640,7 +676,7 @@ public void Combina_TruncatesNumbersCorrectly(double number, double chosen, int [TestCase(8.4, -0.519288654116686)] public void Cos_ReturnsCorrectResult(double input, double expectedResult) { - var actualResult = (double)XLWorkbook.EvaluateExpr(string.Format("COS({0})", input.ToString(CultureInfo.InvariantCulture))); + var actualResult = (double)XLWorkbook.EvaluateExpr($"COS({input})"); Assert.AreEqual(expectedResult, actualResult, tolerance); } @@ -668,12 +704,20 @@ public void Cos_ReturnsCorrectResult(double input, double expectedResult) [TestCase(8.4, 2223.53348628359)] public void Cosh_ReturnsCorrectResult(double input, double expectedResult) { - var actualResult = (double)XLWorkbook.EvaluateExpr(string.Format("COSH({0})", input.ToString(CultureInfo.InvariantCulture))); + var actualResult = (double)XLWorkbook.EvaluateExpr($"COSH({input})"); Assert.AreEqual(expectedResult, actualResult, tolerance); - var actualResult2 = (double)XLWorkbook.EvaluateExpr(string.Format("COSH({0})", (-input).ToString(CultureInfo.InvariantCulture))); + var actualResult2 = (double)XLWorkbook.EvaluateExpr($"COSH({-input})"); Assert.AreEqual(expectedResult, actualResult2, tolerance); } + [TestCase(711)] + [TestCase(-711)] + [TestCase(100000)] + public void Cosh_too_large_returns_error(double input) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"COSH({input})")); + } + [TestCase(1, 0.642092616)] [TestCase(2, -0.457657554)] [TestCase(3, -7.015252551)] @@ -689,22 +733,22 @@ public void Cosh_ReturnsCorrectResult(double input, double expectedResult) [TestCase(45, 0.617369624)] [TestCase(-2, 0.457657554)] [TestCase(-3, 7.015252551)] - public void Cot(double input, double expected) + public void Cot(double angle, double expected) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"COT({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"COT({angle})"); Assert.AreEqual(expected, actual, tolerance * 10.0); } [Test] - public void Cot_Input0() + public void Cot_returns_division_by_zero_error_on_angle_zero() { - Assert.Throws(() => XLWorkbook.EvaluateExpr("COT(0)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("COT(0)")); } [Test] - public void Cot_On0_ThrowsDivisionByZeroException() + public void Coth_returns_division_by_zero_error_on_angle_zero() { - Assert.Throws(() => XLWorkbook.EvaluateExpr(@"COTH(0)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("COTH(0)")); } [TestCase(-10, -1.000000004)] @@ -727,16 +771,16 @@ public void Cot_On0_ThrowsDivisionByZeroException() [TestCase(8, 1.000000225)] [TestCase(9, 1.00000003)] [TestCase(10, 1.000000004)] - public void Coth_Examples(double input, double expected) + public void Coth_returns_correct_number(double angle, double expected) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"COTH({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"COTH({angle})"); Assert.AreEqual(expected, actual, tolerance * 10.0); } [Test] - public void Csc_On0_ThrowsDivisionByZeroException() + public void Csc_returns_division_by_zero_on_angle_zero() { - Assert.Throws(() => XLWorkbook.EvaluateExpr(@"CSC(0)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("CSC(0)")); } [TestCase(-10, 1.838163961)] @@ -759,9 +803,9 @@ public void Csc_On0_ThrowsDivisionByZeroException() [TestCase(8, 1.010756218)] [TestCase(9, 2.426486644)] [TestCase(10, -1.838163961)] - public void Csc_ReturnsCorrectValues(double input, double expected) + public void Csc_returns_correct_number(double angle, double expected) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"CSC({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"CSC({angle})"); Assert.AreEqual(expected, actual, tolerance * 10); } @@ -776,49 +820,67 @@ public void Csc_ReturnsCorrectValues(double input, double expected) [TestCase(9, 0.00024682)] [TestCase(10, 0.000090799859712122200000)] [TestCase(11, 0.0000334034)] - public void CSch_CalculatesCorrectValues(double input, double expectedOutput) + public void Csch_calculates_correct_values(double input, double expectedOutput) { - Assert.AreEqual(expectedOutput, (double)XLWorkbook.EvaluateExpr($@"CSCH({input})"), 0.000000001); + Assert.AreEqual(expectedOutput, (double)XLWorkbook.EvaluateExpr($"CSCH({input})"), 0.000000001); } [Test] - public void Csch_ReturnsDivisionByZeroErrorOnInput0() + public void Csch_returns_division_error_on_angle_zero() { - Assert.Throws(() => XLWorkbook.EvaluateExpr("CSCH(0)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("CSCH(0)")); } [TestCase("FF", 16, 255)] [TestCase("111", 2, 7)] - [TestCase("zap", 36, 45745)] - public void Decimal(string inputString, int radix, int expectedResult) + [TestCase("zap", 36, 45745)] // Case insensitive + [TestCase(" 1234", 10, 1234)] // Trims start + [TestCase("123", 10.9, 123)] // Radix truncated + [TestCase("1F", 10, XLError.NumberInvalid)] + [TestCase("", 10, 0)] + public void Decimal(string inputString, double radix, object expectedResult) { var actualResult = XLWorkbook.EvaluateExpr($"DECIMAL(\"{inputString}\", {radix})"); Assert.AreEqual(expectedResult, actualResult); } [Theory] - public void Decimal_ReturnsErrorForRadiansGreater36([Range(37, 255)] int radix) + public void Decimal_radix_must_be_between_2_and_36([Range(37, 255), Range(-5, 1)] int radix) { - Assert.Throws(() => XLWorkbook.EvaluateExpr($"DECIMAL(\"0\", {radix})")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"DECIMAL(\"0\", {radix})")); } - [Theory] - public void Decimal_ReturnsErrorForRadiansSmaller2([Range(-5, 1)] int radix) + [Test] + public void Decimal_zero_is_zero_in_any_radix([Range(2, 36)] int radix) { - Assert.Throws(() => XLWorkbook.EvaluateExpr($"DECIMAL(\"0\", {radix})")); + Assert.AreEqual(0, XLWorkbook.EvaluateExpr($"DECIMAL(\"0\", {radix})")); } [Test] - public void Decimal_ZeroIsZeroInAnyRadix([Range(2, 36)] int radix) + public void Decimal_text_must_be_less_than_256_chars_long() { - Assert.AreEqual(0, XLWorkbook.EvaluateExpr($"DECIMAL(\"0\", {radix})")); + var text = new string('0', 256); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"DECIMAL(\"{text}\", 10)")); + } + + [Test] + public void Decimal_returns_number_invalid_when_result_out_of_bounds() + { + Assert.AreEqual(1.4057081148316923E+308d, (double)XLWorkbook.EvaluateExpr($"DECIMAL(\"{new string('Z', 198)}\", 36)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"DECIMAL(\"{new string('Z', 199)}\", 36)")); + } + + [TestCase("101", "\"1 2/2\"", 5)] // 101 in binary is 5 + public void Decimal_coercion(string input, string radix, object expectedResult) + { + Assert.AreEqual(expectedResult, XLWorkbook.EvaluateExpr($"DECIMAL({input}, {radix})")); } [Test] public void Degrees() { - object actual1 = XLWorkbook.EvaluateExpr("Degrees(180)"); - Assert.IsTrue(Math.PI - (double)actual1 < XLHelper.Epsilon); + var actual = (double)XLWorkbook.EvaluateExpr("DEGREES(PI())"); + Assert.AreEqual(180, actual, XLHelper.Epsilon); } [TestCase(0, 0)] @@ -840,44 +902,22 @@ public void Degrees() [TestCase(-1, -57.2957795130823)] public void Degrees_ReturnsCorrectResult(double input, double expected) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"DEGREES({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"DEGREES({input})"); Assert.AreEqual(expected, actual, tolerance); } - [Test] - public void Even() - { - object actual = XLWorkbook.EvaluateExpr("Even(3)"); - Assert.AreEqual(4, actual); - - actual = XLWorkbook.EvaluateExpr("Even(2)"); - Assert.AreEqual(2, actual); - - actual = XLWorkbook.EvaluateExpr("Even(-1)"); - Assert.AreEqual(-2, actual); - - actual = XLWorkbook.EvaluateExpr("Even(-2)"); - Assert.AreEqual(-2, actual); - - actual = XLWorkbook.EvaluateExpr("Even(0)"); - Assert.AreEqual(0, actual); - - actual = XLWorkbook.EvaluateExpr("Even(1.5)"); - Assert.AreEqual(2, actual); - - actual = XLWorkbook.EvaluateExpr("Even(2.01)"); - Assert.AreEqual(4, actual); - } - - [TestCase(1.5, 2)] [TestCase(3, 4)] [TestCase(2, 2)] [TestCase(-1, -2)] + [TestCase(-2, -2)] [TestCase(0, 0)] + [TestCase(1.5, 2)] + [TestCase(2.01, 4)] + [TestCase(1e+100, 1e+100)] [TestCase(Math.PI, 4)] - public void Even_ReturnsCorrectResults(double input, double expectedResult) + public void Even(double number, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"EVEN({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = XLWorkbook.EvaluateExpr($"EVEN({number})"); Assert.AreEqual(expectedResult, actual); } @@ -894,12 +934,19 @@ public void Even_ReturnsCorrectResults(double input, double expectedResult) [TestCase(10, 22026.4657948067)] [TestCase(11, 59874.1417151978)] [TestCase(12, 162754.791419004)] - public void Exp_ReturnsCorrectResults(double input, double expectedResult) + [TestCase(-1E+100, 0)] + public void Exp_returns_correct_results(double input, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"EXP({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"EXP({input})"); Assert.AreEqual(expectedResult, actual, tolerance); } + [TestCase(710)] + public void Exp_with_too_large_result_return_error(double input) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"EXP({input})")); + } + [Test] public void Fact() { @@ -907,51 +954,55 @@ public void Fact() Assert.AreEqual(120.0, actual); } - [TestCase(0, 1L)] - [TestCase(1, 1L)] - [TestCase(2, 2L)] - [TestCase(3, 6L)] - [TestCase(4, 24L)] - [TestCase(5, 120L)] - [TestCase(6, 720L)] - [TestCase(7, 5040L)] - [TestCase(8, 40320L)] - [TestCase(9, 362880L)] - [TestCase(10, 3628800L)] - [TestCase(11, 39916800L)] - [TestCase(12, 479001600L)] - [TestCase(13, 6227020800L)] - [TestCase(14, 87178291200L)] - [TestCase(15, 1307674368000L)] - [TestCase(16, 20922789888000L)] + [TestCase(0, 1d)] + [TestCase(1, 1d)] + [TestCase(2, 2d)] + [TestCase(3, 6d)] + [TestCase(4, 24d)] + [TestCase(5, 120d)] + [TestCase(6, 720d)] + [TestCase(7, 5040d)] + [TestCase(8, 40320d)] + [TestCase(9, 362880d)] + [TestCase(10, 3628800d)] + [TestCase(11, 39916800d)] + [TestCase(12, 479001600d)] + [TestCase(13, 6227020800d)] + [TestCase(14, 87178291200d)] + [TestCase(15, 1307674368000d)] + [TestCase(16, 20922789888000d)] + [TestCase(170.9, 7.257415615308004E+306)] [TestCase(0.1, 1L)] [TestCase(2.3, 2L)] [TestCase(2.8, 2L)] - public void Fact_ReturnsCorrectResult(double input, long expectedResult) + public void Fact_calculates_factorial(double input, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"FACT({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = XLWorkbook.EvaluateExpr($@"FACT({input.ToString(CultureInfo.InvariantCulture)})"); Assert.AreEqual(expectedResult, actual); } - [Theory] - public void Fact_ThrowsNumberExceptionForNegativeInput([Range(-10, -1)] int input) + [TestCase(-10)] + [TestCase(-5)] + [TestCase(-1)] + [TestCase(-0.1)] + public void Fact_returns_error_for_negative_input(double input) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"FACT({0})", input.ToString(CultureInfo.InvariantCulture)))); + var actual = XLWorkbook.EvaluateExpr($@"FACT({input.ToString(CultureInfo.InvariantCulture)})"); + Assert.AreEqual(XLError.NumberInvalid, actual); } - [Test] - public void Fact_ThrowsValueExceptionForNonNumericInput() + [TestCase(171)] + [TestCase(5000)] + public void Fact_returns_error_for_too_large_result(int input) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"FACT(""x"")"))); + var actual = XLWorkbook.EvaluateExpr($@"FACT({input})"); + Assert.AreEqual(XLError.NumberInvalid, actual); } [Test] - public void FactDouble() + public void Fact_coercion_fails_for_non_numeric_input() { - object actual1 = XLWorkbook.EvaluateExpr("FactDouble(6)"); - Assert.AreEqual(48.0, actual1); - object actual2 = XLWorkbook.EvaluateExpr("FactDouble(7)"); - Assert.AreEqual(105.0, actual2); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"FACT(""x"")")); } [TestCase(0, 1L)] @@ -979,22 +1030,31 @@ public void FactDouble() [TestCase(2.8, 2L)] public void FactDouble_ReturnsCorrectResult(double input, long expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"FACTDOUBLE({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = (double)XLWorkbook.EvaluateExpr($"FACTDOUBLE({input})"); Assert.AreEqual(expectedResult, actual); } + [TestCase(301)] + [TestCase(1e+100)] + public void FactDouble_returns_error_on_too_large_value(double n) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"FACTDOUBLE({n})")); + } + [Theory] public void FactDouble_ThrowsNumberExceptionForInputSmallerThanMinus1([Range(-10, -2)] int input) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"FACTDOUBLE({0})", input.ToString(CultureInfo.InvariantCulture)))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"FACTDOUBLE({input})")); } [Test] public void FactDouble_ThrowsValueExceptionForNonNumericInput() { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(@"FACTDOUBLE(""x"")"))); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"FACTDOUBLE(""x"")")); } + [TestCase(0, 0, 0)] + [TestCase(0, 1, 0)] [TestCase(24.3, 5, 20)] [TestCase(6.7, 1, 6)] [TestCase(-8.1, 2, -10)] @@ -1003,7 +1063,7 @@ public void FactDouble_ThrowsValueExceptionForNonNumericInput() [TestCase(-5.5, -2.1, -4.2)] public void Floor(double input, double significance, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr($"FLOOR({input.ToInvariantString()}, {significance.ToInvariantString()})"); + var actual = (double)XLWorkbook.EvaluateExpr($"FLOOR({input}, {significance})"); Assert.AreEqual(expectedResult, actual, tolerance); } @@ -1011,13 +1071,13 @@ public void Floor(double input, double significance, double expectedResult) [TestCase(-6.7, 0)] public void Floor_ThrowsDivisionByZeroOnZeroSignificance(double input, double significance) { - Assert.Throws(() => XLWorkbook.EvaluateExpr($"FLOOR({input.ToInvariantString()}, {significance.ToInvariantString()})")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr($"FLOOR({input}, {significance})")); } [TestCase(6.7, -1)] public void Floor_ThrowsNumberExceptionOnInvalidInput(double input, double significance) { - Assert.Throws(() => XLWorkbook.EvaluateExpr($"FLOOR({input.ToInvariantString()}, {significance.ToInvariantString()})")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"FLOOR({input}, {significance})")); } [Test] @@ -1043,189 +1103,416 @@ public void Floor_ThrowsNumberExceptionOnInvalidInput(double input, double signi [TestCase(-5.5, 2.1, 10, -4.2)] [TestCase(-5.5, -2.1, 10, -4.2)] [TestCase(-5.5, 0, 0, 0)] - public void FloorMath(double input, double? step, int? mode, double expectedResult) + [DefaultFloatingPointTolerance(tolerance)] + public void FloorMath(double input, double? significance, int? mode, double expectedResult) { - string parameters = input.ToString(CultureInfo.InvariantCulture); - if (step != null) + var parameters = new StringBuilder(); + parameters.Append(input); + if (significance != null) { - parameters = parameters + ", " + step?.ToString(CultureInfo.InvariantCulture); + parameters.Append(", ").Append(significance); if (mode != null) - parameters = parameters + ", " + mode?.ToString(CultureInfo.InvariantCulture); + parameters.Append(", ").Append(mode); } - var actual = (double)XLWorkbook.EvaluateExpr(string.Format(@"FLOOR.MATH({0})", parameters)); - Assert.AreEqual(expectedResult, actual, tolerance); + var actual = (double)XLWorkbook.EvaluateExpr($"FLOOR.MATH({parameters})"); + Assert.AreEqual(expectedResult, actual); + } + + [TestCase("24,36", ExpectedResult = 12)] + [TestCase("240,360,30", ExpectedResult = 30)] + [TestCase("24.9,36.9", ExpectedResult = 12)] + [TestCase("{24,36}", ExpectedResult = 12)] + [TestCase("{\"24\",\"36\"}", ExpectedResult = 12)] + [TestCase("5,0", ExpectedResult = 5)] + [TestCase("0,5", ExpectedResult = 5)] + public double Gcd(string args) + { + return (double)XLWorkbook.EvaluateExpr($"GCD({args})"); } [Test] - public void Gcd() + public void Gcd_accepts_references() { - object actual = XLWorkbook.EvaluateExpr("Gcd(24, 36)"); - Assert.AreEqual(12, actual); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new object[] + { + (120, 240), + ("60", "150"), + }); + Assert.AreEqual(30, ws.Evaluate("GCD(A1:A2,B1:B2)")); - object actual1 = XLWorkbook.EvaluateExpr("Gcd(5, 0)"); - Assert.AreEqual(5, actual1); + // Blank is considered 0 + Assert.AreEqual(60, ws.Evaluate("GCD(A1:A3)")); + + // Logical are not converted + ws.Cell("A3").Value = true; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("GCD(A1:A3)")); + + // Unconvertable text causes error + ws.Cell("A3").Value = "one"; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("GCD(A1:A3)")); + } - object actual2 = XLWorkbook.EvaluateExpr("Gcd(0, 5)"); - Assert.AreEqual(5, actual2); + [TestCase] + public void Gcd_numbers_must_fit_in_double_without_precision_loss() + { + Assert.AreEqual(9.007E+15, XLWorkbook.EvaluateExpr("GCD(9.007E+15)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("GCD(9.008E+15)")); + } - object actual3 = XLWorkbook.EvaluateExpr("Gcd(240, 360, 30)"); - Assert.AreEqual(30, actual3); + [TestCase] + public void Gcd_numbers_must_be_zero_or_positive() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("GCD(-1)")); } [TestCase(8.9, 8)] [TestCase(-8.9, -9)] public void Int(double input, double expected) { - var actual = XLWorkbook.EvaluateExpr(string.Format(@"INT({0})", input.ToString(CultureInfo.InvariantCulture))); + var actual = XLWorkbook.EvaluateExpr($"INT({input})"); Assert.AreEqual(expected, actual); } + [TestCase("24, 36", ExpectedResult = 72)] + [TestCase("24.9, 36.9", ExpectedResult = 72)] + [TestCase("{24, 36}", ExpectedResult = 72)] + [TestCase("{1,2,3;4,5,6}", ExpectedResult = 60)] + [TestCase("{\"1\",\"2\",\"3\"}", ExpectedResult = 6)] + [TestCase("240, 360, 30", ExpectedResult = 720)] + [TestCase("5, 0", ExpectedResult = 0)] + [TestCase("0, 5", ExpectedResult = 0)] + public double Lcm(string args) + { + return (double)XLWorkbook.EvaluateExpr($"LCM({args})"); + } + [Test] - public void Lcm() + public void Lcm_accepts_references() { - object actual = XLWorkbook.EvaluateExpr("Lcm(24, 36)"); - Assert.AreEqual(72, actual); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new object[] + { + (1, 2, 3), + ("4", "5", "6"), + }); + Assert.AreEqual(60, ws.Evaluate("LCM(A1:B2,C1:C2)")); + + // Blank is considered 0 + Assert.AreEqual(0, ws.Evaluate("LCM(A1:A3)")); + + // Logical are not converted + ws.Cell("A3").Value = true; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("LCM(A1:A3)")); + + // Unconvertable text causes error + ws.Cell("A3").Value = "one"; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("LCM(A1:A3)")); + } + + [TestCase] + public void Lcm_numbers_must_fit_in_double_without_precision_loss() + { + Assert.AreEqual(9.007E+15, XLWorkbook.EvaluateExpr("LCM(9.007E+15)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("LCM(9.008E+15)")); + } - object actual1 = XLWorkbook.EvaluateExpr("Lcm(5, 0)"); - Assert.AreEqual(0, actual1); + [TestCase] + public void Lcm_numbers_must_be_zero_or_positive() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("LCM(-1)")); + } + + [TestCase(86, 4.4543472962)] + [TestCase(2.7182818, 0.9999999895)] + [TestCase(20.085536923, 3)] + public void Ln_calculates_logarithm(double x, double ln) + { + Assert.AreEqual(ln, (double)XLWorkbook.EvaluateExpr($"LN({x})"), tolerance); + } - object actual2 = XLWorkbook.EvaluateExpr("Lcm(0, 5)"); - Assert.AreEqual(0, actual2); + [TestCase(0)] + [TestCase(-0.7)] + [TestCase(-10)] + public void Ln_non_positive_returns_error(double x) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"LN({x})")); + } - object actual3 = XLWorkbook.EvaluateExpr("Lcm(240, 360, 30)"); - Assert.AreEqual(720, actual3); + [TestCase(10, 10, 1)] + [TestCase(8, 2, 3)] + [TestCase(86, 2.7182818, 4.4543473428883)] + public void Log_calculates_logarithm(double x, double @base, double result) + { + Assert.AreEqual(result, (double)XLWorkbook.EvaluateExpr($"LOG({x}, {@base})"), tolerance); } [Test] - public void MDeterm() + public void Log_default_base_is_10() { - IXLWorksheet ws = new XLWorkbook().AddWorksheet("Sheet1"); - ws.Cell("A1").SetValue(2).CellRight().SetValue(4); - ws.Cell("A2").SetValue(3).CellRight().SetValue(5); + Assert.AreEqual(2, XLWorkbook.EvaluateExpr("LOG(100)")); + } - Object actual; + [Test] + public void Log_error_conditions() + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("LOG(0)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("LOG(1,0)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("LOG(0,0)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("LOG(10,1)")); + } - ws.Cell("A5").FormulaA1 = "MDeterm(A1:B2)"; - actual = ws.Cell("A5").Value; + [TestCase(86, 1.93449845124)] + [TestCase(10, 1)] + [TestCase(1E5, 5)] + public void Log10_calculates_logarithm(double x, double expectedResult) + { + Assert.AreEqual(expectedResult, (double)XLWorkbook.EvaluateExpr($"LOG10({x})"), tolerance); + } - Assert.IsTrue(XLHelper.AreEqual(-2.0, (double)actual)); + [TestCase(0)] + [TestCase(-5)] + [TestCase(-0.5)] + public void Log10_error_conditions(double x) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"LOG10({x})")); + } - ws.Cell("A6").FormulaA1 = "Sum(A5)"; - actual = ws.Cell("A6").Value; + [Test] + public void Log10_is_detected_inside_expression() + { + // Because LOG10 is extracted from CellFunction, make sure it is properly read even in the middle of expression. + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("0 + LOG10(10)")); + } - Assert.IsTrue(XLHelper.AreEqual(-2.0, (double)actual)); + [Test] + [DefaultFloatingPointTolerance(tolerance)] + public void MDeterm() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new object[] + { + (2, 4), + (3, 5), + }); + + ws.Cell("A5").FormulaA1 = "MDETERM(A1:B2)"; + var actual = ws.Cell("A5").Value; + Assert.AreEqual(-2, (double)actual); + + ws.Cell("A6").FormulaA1 = "SUM(A5)"; + actual = ws.Cell("A6").Value; + Assert.AreEqual(-2, (double)actual); - ws.Cell("A7").FormulaA1 = "Sum(MDeterm(A1:B2))"; + ws.Cell("A7").FormulaA1 = "SUM(MDETERM(A1:B2))"; actual = ws.Cell("A7").Value; + Assert.AreEqual(-2, (double)actual); + } - Assert.IsTrue(XLHelper.AreEqual(-2.0, (double)actual)); + [Test] + [DefaultFloatingPointTolerance(tolerance)] + public void MDeterm_examples() + { + // Examples from spec + Assert.AreEqual(1, (double)XLWorkbook.EvaluateExpr("MDETERM({3,6,1;1,1,0;3,10,2})")); + Assert.AreEqual(-3, XLWorkbook.EvaluateExpr("MDETERM({3,6;1,1})")); + + // Example from office website + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new object[] + { + ("Data", "Data", "Data", "Data"), + (1, 3, 8, 5), + (1, 3, 6, 1), + (1, 1, 1, 0), + (7, 3, 10, 2), + }); + Assert.AreEqual(88, (double)ws.Evaluate("MDETERM(A2:D5)")); } [Test] - public void MInverse() + public void MDeterm_requires_equal_number_of_rows_and_columns() { - IXLWorksheet ws = new XLWorkbook().AddWorksheet("Sheet1"); - ws.Cell("A1").SetValue(1).CellRight().SetValue(2).CellRight().SetValue(1); - ws.Cell("A2").SetValue(3).CellRight().SetValue(4).CellRight().SetValue(-1); - ws.Cell("A3").SetValue(0).CellRight().SetValue(2).CellRight().SetValue(0); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("MDETERM({1,2})")); + } + + [Test] + public void MDeterm_singular_matrix_returns_zero() + { + Assert.AreEqual(0, XLWorkbook.EvaluateExpr("MDETERM({1,2;1,2})")); + } + + [Test] + public void MDeterm_requires_all_array_elements_are_numbers() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new object[] + { + (2, 4), + (3, 5), + }); - Object actual; + ws.Cell("B2").Value = Blank.Value; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("MDETERM(A1:B2)")); - ws.Cell("A5").FormulaA1 = "MInverse(A1:C3)"; - actual = ws.Cell("A5").Value; + ws.Cell("B2").Value = "1"; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("MDETERM(A1:B2)")); - Assert.IsTrue(XLHelper.AreEqual(0.25, (double)actual)); + ws.Cell("B2").Value = true; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("MDETERM(A1:B2)")); - ws.Cell("A6").FormulaA1 = "Sum(A5)"; - actual = ws.Cell("A6").Value; + ws.Cell("B2").Value = XLError.NameNotRecognized; + Assert.AreEqual(XLError.NameNotRecognized, ws.Evaluate("MDETERM(A1:B2)")); + } - Assert.IsTrue(XLHelper.AreEqual(0.25, (double)actual)); + [Test] + [DefaultFloatingPointTolerance(tolerance)] + public void MInverse() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new[] + { + (1, 2, 1), + (3, 4, -1), + (0, 2, 0), + }); + + ws.Cell("A5").FormulaA1 = "MINVERSE(A1:C3)"; + var actual = ws.Cell("A5").Value; + Assert.AreEqual(0.25, (double)actual); - ws.Cell("A7").FormulaA1 = "Sum(MInverse(A1:C3))"; + ws.Cell("A6").FormulaA1 = "SUM(A5)"; + actual = ws.Cell("A6").Value; + Assert.AreEqual(0.25, (double)actual); + + ws.Cell("A7").FormulaA1 = "SUM(MINVERSE(A1:C3))"; actual = ws.Cell("A7").Value; + Assert.AreEqual(0.5, (double)actual); + } - Assert.IsTrue(XLHelper.AreEqual(0.5, (double)actual)); + [Test] + public void MInverse_returns_error_on_singular_matrix() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new[] + { + (1, 2), + (1, 2), + }); + Assert.AreEqual(XLError.NumberInvalid, ws.Evaluate("MINVERSE(A1:B2)")); } [Test] - public void MMult() + public void MInverse_requires_square_matrix() { - IXLWorksheet ws = new XLWorkbook().AddWorksheet("Sheet1"); - ws.Cell("A1").SetValue(2).CellRight().SetValue(4); - ws.Cell("A2").SetValue(3).CellRight().SetValue(5); - ws.Cell("A3").SetValue(2).CellRight().SetValue(4); - ws.Cell("A4").SetValue(3).CellRight().SetValue(5); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("MINVERSE({1,2,3;7,5,5})")); + } + + [Test] + public void MInverse_all_array_elements_must_be_numbers() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new[] + { + (1, 2), + (8, 4), + }); - Object actual; + ws.Cell("B2").Value = Blank.Value; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("MINVERSE(A1:B2)")); - ws.Cell("A5").FormulaA1 = "MMult(A1:B2, A3:B4)"; - actual = ws.Cell("A5").Value; + ws.Cell("B2").Value = true; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("MINVERSE(A1:B2)")); + ws.Cell("B2").Value = "1"; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("MINVERSE(A1:B2)")); + + ws.Cell("B2").Value = XLError.DivisionByZero; + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("MINVERSE(A1:B2)")); + } + + [Test] + public void MMult() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new[] + { + (2, 4), + (3, 5), + (2, 4), + (3, 5), + }); + + ws.Cell("A5").FormulaA1 = "MMULT(A1:B2, A3:B4)"; + var actual = ws.Cell("A5").Value; Assert.AreEqual(16.0, actual); - ws.Cell("A6").FormulaA1 = "Sum(A5)"; + ws.Cell("A6").FormulaA1 = "SUM(A5)"; actual = ws.Cell("A6").Value; - Assert.AreEqual(16.0, actual); - ws.Cell("A7").FormulaA1 = "Sum(MMult(A1:B2, A3:B4))"; + ws.Cell("A7").FormulaA1 = "SUM(MMULT(A1:B2, A3:B4))"; actual = ws.Cell("A7").Value; - Assert.AreEqual(102.0, actual); } [Test] - public void MMult_HandlesNonSquareMatrices() + public void MMult_handles_non_square_matrices() { - IXLWorksheet ws = new XLWorkbook().AddWorksheet("Sheet1"); - - // 2x3 - ws.Cell("A1").SetValue(1).CellRight().SetValue(3).CellRight().SetValue(5); - ws.Cell("A2").SetValue(2).CellRight().SetValue(4).CellRight().SetValue(6); - - // 3x4 - ws.Cell("A3").SetValue(10).CellRight().SetValue(13).CellRight().SetValue(16).CellRight().SetValue(19); - ws.Cell("A4").SetValue(11).CellRight().SetValue(14).CellRight().SetValue(17).CellRight().SetValue(20); - ws.Cell("A5").SetValue(12).CellRight().SetValue(15).CellRight().SetValue(18).CellRight().SetValue(21); - - Object actual; + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new object[] + { + // 2x3 + (1, 3, 5), + (2, 4, 6), + // 3x4 + (10, 13, 16, 19), + (11, 14, 17, 20), + (12, 15, 18, 21), + }); // 2x4 output expected: // 103, 130, 157, 184 // 136, 172, 208, 244 ws.Cell("A6").FormulaA1 = "MMult(A1:C2, A3:D5)"; - - actual = ws.Cell("A6").Value; + var actual = ws.Cell("A6").Value; Assert.AreEqual(103.0, actual); ws.Cell("A7").FormulaA1 = "Sum(MMult(A1:C2, A3:D5))"; actual = ws.Cell("A7").Value; - Assert.AreEqual(1334, actual); } [TestCase("A2:C2", "A3:C3")] // 1x3 and 1x3 [TestCase("A2:C4", "A5:C5")] // 3x3 and 1x3 [TestCase("A2:C5", "A6:D6")] // 3x4 and 1x4 - public void MMult_ThrowsWhenArray1RowsNotEqualToArray2Cols(string array1Range, string array2Range) + public void MMult_array1_rows_must_match_array2_column(string array1Range, string array2Range) { - IXLWorksheet ws = new XLWorkbook().AddWorksheet("Sheet1"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); ws.Cells($"{array1Range}").Value = 1.0; ws.Cells($"{array2Range}").Value = 1.0; ws.Cell("A1").FormulaA1 = $"MMULT({array1Range},{array2Range})"; - var error = Assert.Throws(() => { var _ = ws.Cell("A1").Value; }); - - Assert.AreEqual("The number of columns in array1 is different from the number of rows in array2.", error.Message); + Assert.AreEqual(XLError.IncompatibleValue, ws.Cell("A1").Value); } - [TestCase(null)] [TestCase("")] [TestCase("Text")] - public void MMult_ThrowsWhenCellsContainInvalidInput(object invalidInput) + public void MMult_ThrowsWhenCellsContainInvalidInput(string invalidInput) { IXLWorksheet ws = new XLWorkbook().AddWorksheet("Sheet1"); @@ -1238,46 +1525,31 @@ public void MMult_ThrowsWhenCellsContainInvalidInput(object invalidInput) ws.Cell("A4").SetValue(11).CellRight().SetValue(14).CellRight().SetValue(17).CellRight().SetValue(20); ws.Cell("A5").SetValue(12).CellRight().SetValue(15).CellRight().SetValue(18).CellRight().SetValue(21); - ws.Cell("A6").FormulaA1 = $"MMULT(A1:C2,A3:D4)"; + ws.Cell("A6").FormulaA1 = "MMULT(A1:C2,A3:D4)"; - var error = Assert.Throws(() => { var _ = ws.Cell("A6").Value; }); + Assert.AreEqual(XLError.IncompatibleValue, ws.Cell("A6").Value); + } - Assert.AreEqual("Cells are empty or contain text.", error.Message); + [TestCase(1.5, 1, 0.5)] + [TestCase(3, 2, 1)] + [TestCase(-3, 2, 1)] + [TestCase(-3, -2, -1)] + [TestCase(-4.3, -0.5, -0.3)] + [TestCase(6.9, -0.2, -0.1)] + [TestCase(0.7, 0.6, 0.1)] + [TestCase(6.2, 1.1, 0.7)] + public void Mod(double x, double y, double result) + { + var actual = (double)XLWorkbook.EvaluateExpr($"MOD({x}, {y})"); + Assert.AreEqual(result, actual, tolerance); } [Test] - public void Mod() + public void Mod_divisor_zero_returns_error() { - double actual; - - actual = (double)XLWorkbook.EvaluateExpr(@"MOD(1.5, 1)"); - Assert.AreEqual(0.5, actual, tolerance); - - actual = (double)XLWorkbook.EvaluateExpr(@"MOD(3, 2)"); - Assert.AreEqual(1, actual, tolerance); - - actual = (double)XLWorkbook.EvaluateExpr(@"MOD(-3, 2)"); - Assert.AreEqual(1, actual, tolerance); - - actual = (double)XLWorkbook.EvaluateExpr(@"MOD(3, -2)"); - Assert.AreEqual(-1, actual, tolerance); - - actual = (double)XLWorkbook.EvaluateExpr(@"MOD(-3, -2)"); - Assert.AreEqual(-1, actual, tolerance); - - ////// - - actual = (double)XLWorkbook.EvaluateExpr(@"MOD(-4.3, -0.5)"); - Assert.AreEqual(-0.3, actual, tolerance); - - actual = (double)XLWorkbook.EvaluateExpr(@"MOD(6.9, -0.2)"); - Assert.AreEqual(-0.1, actual, tolerance); - - actual = (double)XLWorkbook.EvaluateExpr(@"MOD(0.7, 0.6)"); - Assert.AreEqual(0.1, actual, tolerance); - - actual = (double)XLWorkbook.EvaluateExpr(@"MOD(6.2, 1.1)"); - Assert.AreEqual(0.7, actual, tolerance); + // Spec says that "If y is 0, the return value is unspecified", but Excel says #DIV/0!, so let's go with that. + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("MOD(1, 0)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("MOD(0, 0)")); } [TestCase(10, 3, ExpectedResult = 9.0)] @@ -1299,161 +1571,334 @@ public void Mod() [TestCase(0.89999, 0.2, ExpectedResult = 0.8)] [TestCase(15.5, 3, ExpectedResult = 15.0)] [TestCase(1.4, 0.5, ExpectedResult = 1.5)] + [TestCase(3, 7, ExpectedResult = 0)] + [TestCase(3, 0, ExpectedResult = 0)] [DefaultFloatingPointTolerance(1e-12)] public double MRound(double number, double multiple) { - return (double)XLWorkbook.EvaluateExpr(string.Format(CultureInfo.InvariantCulture, "MROUND({0}, {1})", number, multiple)); + return (double)XLWorkbook.EvaluateExpr($"MROUND({number}, {multiple})"); } [TestCase(123456.123, -10)] [TestCase(-123456.123, 5)] public void MRoundExceptions(double number, double multiple) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(string.Format(CultureInfo.InvariantCulture, "MROUND({0}, {1})", number, multiple))); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"MROUND({number}, {multiple})")); } [Test] public void Multinomial() { - object actual = XLWorkbook.EvaluateExpr("Multinomial(2,3,4)"); - Assert.AreEqual(1260.0, actual); + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("MULTINOMIAL(2)")); + Assert.AreEqual(10, XLWorkbook.EvaluateExpr("MULTINOMIAL(2,3)")); + Assert.AreEqual(1260, XLWorkbook.EvaluateExpr("MULTINOMIAL(2,3,4)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("MULTINOMIAL(1E+100)")); } [Test] - public void Odd() + public void Multinomial_accepts_ranges() { - object actual = XLWorkbook.EvaluateExpr("Odd(1.5)"); - Assert.AreEqual(3, actual); - - object actual1 = XLWorkbook.EvaluateExpr("Odd(3)"); - Assert.AreEqual(3, actual1); - - object actual2 = XLWorkbook.EvaluateExpr("Odd(2)"); - Assert.AreEqual(3, actual2); - - object actual3 = XLWorkbook.EvaluateExpr("Odd(-1)"); - Assert.AreEqual(-1, actual3); - - object actual4 = XLWorkbook.EvaluateExpr("Odd(-2)"); - Assert.AreEqual(-3, actual4); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("B2").InsertData(new[] { 2, 0, 5 }); + ws.Cell("A5").InsertData(new[] { 3, 6 }); - actual = XLWorkbook.EvaluateExpr("Odd(0)"); - Assert.AreEqual(1, actual); + Assert.AreEqual(3087564480d, ws.Evaluate("MULTINOMIAL(B:XFD, 2, A5:A6)")); } [Test] - public void Product() + public void Multinomial_doesnt_accept_negative_values() { - object actual = XLWorkbook.EvaluateExpr("Product(2,3,4)"); - Assert.AreEqual(24.0, actual); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("MULTINOMIAL(5, -1)")); } [Test] - public void Quotient() + public void Multinomial_coercion() { - object actual = XLWorkbook.EvaluateExpr("Quotient(5,2)"); - Assert.AreEqual(2, actual); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = true; + ws.Cell("A2").Value = 5; + ws.Cell("A3").Value = "1 2/2"; + ws.Cell("A4").Value = "one"; - actual = XLWorkbook.EvaluateExpr("Quotient(4.5,3.1)"); - Assert.AreEqual(1, actual); + // True is not converted + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("MULTINOMIAL(A1:A2)")); + + // Text is coerced + Assert.AreEqual(21, ws.Evaluate("MULTINOMIAL(A2:A3)")); + + // Text is coerced, errors are propagates + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("MULTINOMIAL(A2:A4)")); + + // Errors are propagates + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("MULTINOMIAL(5, #DIV/0!)")); + } - actual = XLWorkbook.EvaluateExpr("Quotient(-10,3)"); - Assert.AreEqual(-3, actual); + [TestCase(1.5, ExpectedResult = 3)] + [TestCase(3, ExpectedResult = 3)] + [TestCase(2, ExpectedResult = 3)] + [TestCase(-1, ExpectedResult = -1)] + [TestCase(-2, ExpectedResult = -3)] + [TestCase(0, ExpectedResult = 1)] + [TestCase(1E+100, ExpectedResult = 1E+100)] + [DefaultFloatingPointTolerance(1e-12)] + public double Odd(double number) + { + return (double)XLWorkbook.EvaluateExpr($"ODD({number})"); } [Test] - public void Radians() + public void Pi() + { + Assert.AreEqual(Math.PI, XLWorkbook.EvaluateExpr("PI()")); + } + + [TestCase(2, 3, ExpectedResult = 8)] + [TestCase(2, 0.5, ExpectedResult = 1.414213562373)] + [TestCase(-1.234, 5.0, ExpectedResult = -2.861381721051)] + [TestCase(1.234, 5.1, ExpectedResult = 2.9221823578798)] + [DefaultFloatingPointTolerance(1e-12)] + public double Power(double x, double y) { - object actual = XLWorkbook.EvaluateExpr("Radians(270)"); - Assert.IsTrue(Math.Abs(4.71238898038469 - (double)actual) < XLHelper.Epsilon); + return (double)XLWorkbook.EvaluateExpr($"POWER({x}, {y})"); } [Test] - public void Roman() + public void Power_errors() { - object actual = XLWorkbook.EvaluateExpr("Roman(3046, 1)"); - Assert.AreEqual("MMMXLVI", actual); + // Negative base and fractional exponent + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("POWER(-5, 0.5)")); + + // Spec says this should be #DIV/0!, but Excel says #NUM! + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("POWER(0, 0)")); - actual = XLWorkbook.EvaluateExpr("Roman(270)"); - Assert.AreEqual("CCLXX", actual); + // base is zero and exponent is negative -> #NUM! + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("POWER(0, -5)")); - actual = XLWorkbook.EvaluateExpr("Roman(3999, true)"); - Assert.AreEqual("MMMCMXCIX", actual); + // Result is not representable (e.g. out fo range) + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("POWER(1e+100, 1e+100)")); } [Test] - public void Round() + public void Product() { - object actual = XLWorkbook.EvaluateExpr("Round(2.15, 1)"); - Assert.AreEqual(2.2, actual); + Assert.AreEqual(24d, XLWorkbook.EvaluateExpr("PRODUCT(2,3,4)")); + + // Examples from specification + Assert.AreEqual(1d, XLWorkbook.EvaluateExpr("PRODUCT(1)")); + Assert.AreEqual(120d, XLWorkbook.EvaluateExpr("PRODUCT(1,2,3,4,5)")); + Assert.AreEqual(24d, XLWorkbook.EvaluateExpr("PRODUCT({1,2;3,4})")); + Assert.AreEqual(120d, XLWorkbook.EvaluateExpr("PRODUCT({2,3},4,\"5\")")); - actual = XLWorkbook.EvaluateExpr("Round(2.149, 1)"); - Assert.AreEqual(2.1, actual); + // If no arguments are passed, return 0 + Assert.AreEqual(0, XLWorkbook.EvaluateExpr("PRODUCT({\"hello\"})")); - actual = XLWorkbook.EvaluateExpr("Round(-1.475, 2)"); - Assert.AreEqual(-1.48, actual); + // Scalar blank is skipped + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("PRODUCT(IF(TRUE,), 1)")); - actual = XLWorkbook.EvaluateExpr("Round(21.5, -1)"); - Assert.AreEqual(20.0, actual); + // Scalar logical is converted to number + Assert.AreEqual(0, XLWorkbook.EvaluateExpr("PRODUCT(FALSE, 1)")); + Assert.AreEqual(2, XLWorkbook.EvaluateExpr("PRODUCT(2, TRUE)")); - actual = XLWorkbook.EvaluateExpr("Round(626.3, -3)"); - Assert.AreEqual(1000.0, actual); + // Scalar text is converted to number + Assert.AreEqual(5, XLWorkbook.EvaluateExpr("PRODUCT(\"5\")")); - actual = XLWorkbook.EvaluateExpr("Round(1.98, -1)"); - Assert.AreEqual(0.0, actual); + // Scalar text that is not convertible return error + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("PRODUCT(1, \"Hello\")")); - actual = XLWorkbook.EvaluateExpr("Round(-50.55, -2)"); - Assert.AreEqual(-100.0, actual); + // Array non-number arguments are ignored + Assert.AreEqual(5, XLWorkbook.EvaluateExpr("PRODUCT({5, \"Hello\", FALSE, TRUE})")); - actual = XLWorkbook.EvaluateExpr("ROUND(59 * 0.535, 2)"); // (59 * 0.535) = 31.565 - Assert.AreEqual(31.57, actual); + // Reference argument only uses number, ignores blanks, logical and text + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = Blank.Value; + ws.Cell("A2").Value = true; + ws.Cell("A3").Value = "100"; + ws.Cell("A4").Value = "hello"; + ws.Cell("A5").Value = 2; + ws.Cell("A6").Value = 3; + Assert.AreEqual(6, ws.Evaluate("PRODUCT(A1:A6)")); - actual = XLWorkbook.EvaluateExpr("ROUND(59 * -0.535, 2)"); // (59 * -0.535) = -31.565 - Assert.AreEqual(-31.57, actual); + // Scalar error is propagated + Assert.AreEqual(XLError.NullValue, XLWorkbook.EvaluateExpr("PRODUCT(1, #NULL!)")); + + // Array error is propagated + Assert.AreEqual(XLError.NullValue, XLWorkbook.EvaluateExpr("PRODUCT({1, #NULL!})")); + + // Reference error is propagated + ws.Cell("A1").Value = XLError.NoValueAvailable; + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("PRODUCT(A1)")); + } + + [TestCase(5, 2, ExpectedResult = 2)] + [TestCase(4.5, 3.1, ExpectedResult = 1)] + [TestCase(-10, 3, ExpectedResult = -3)] + [TestCase(-10, -4, ExpectedResult = 2)] + [TestCase(1E+100, 1E+40, ExpectedResult = 1E+60)] + public double Quotient(double x, double y) + { + return (double)XLWorkbook.EvaluateExpr($"QUOTIENT({x}, {y})"); + } + + [Test] + public void Quotient_errors() + { + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("QUOTIENT(1, 0)")); + } + + [TestCase(270, ExpectedResult = 4.71238898038469)] + [TestCase(-180, ExpectedResult = -Math.PI)] + [DefaultFloatingPointTolerance(XLHelper.Epsilon)] + public double Radians(double angle) + { + return (double)XLWorkbook.EvaluateExpr($"RADIANS({angle})"); + } + + [Test] + public void Rand() + { + for (var i = 0; i < 100; ++i) + { + var randomNumber = (double)XLWorkbook.EvaluateExpr("RAND()"); + Assert.That(randomNumber, Is.GreaterThanOrEqualTo(0).And.LessThan(1)); + } } [Test] - public void RoundDown() + public void RandBetween() { - object actual = XLWorkbook.EvaluateExpr("RoundDown(3.2, 0)"); - Assert.AreEqual(3.0, actual); + for (var i = 0; i < 100; ++i) + { + var randomNumber = (double)XLWorkbook.EvaluateExpr("RANDBETWEEN(10, 20)"); + Assert.That(randomNumber, Is.GreaterThanOrEqualTo(10).And.LessThanOrEqualTo(20)); + } - actual = XLWorkbook.EvaluateExpr("RoundDown(76.9, 0)"); - Assert.AreEqual(76.0, actual); + Assert.AreEqual(101, (double)XLWorkbook.EvaluateExpr("RANDBETWEEN(100.5, 100.9)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("RANDBETWEEN(100.9, 100.5)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("RANDBETWEEN(20, 5)")); + Assert.That((double)XLWorkbook.EvaluateExpr("RANDBETWEEN(1E+100, 1E+110)"), Is.GreaterThanOrEqualTo(1E+100).And.LessThanOrEqualTo(1E+110)); + } - actual = XLWorkbook.EvaluateExpr("RoundDown(3.14159, 3)"); - Assert.AreEqual(3.141, actual); + [TestCase(1, 0, ExpectedResult = "I")] + [TestCase(3046, 1, ExpectedResult = @"MMMVLI")] + [TestCase(3999, 1, ExpectedResult = @"MMMLMVLIV")] + [TestCase(999, 0, ExpectedResult = @"CMXCIX")] + [TestCase(999.99, 0.9, ExpectedResult = @"CMXCIX")] + [TestCase(999, 1, ExpectedResult = @"LMVLIV")] + [TestCase(999, 2, ExpectedResult = @"XMIX")] + [TestCase(999, 3, ExpectedResult = @"VMIV")] + [TestCase(999, 4, ExpectedResult = @"IM")] + public string Roman(double value, double form) + { + return (string)XLWorkbook.EvaluateExpr($"ROMAN({value}, {form})"); + } - actual = XLWorkbook.EvaluateExpr("RoundDown(-3.14159, 1)"); - Assert.AreEqual(-3.1, actual); + [Test] + public void Roman_value_0_is_empty_string() + { + Assert.AreEqual(string.Empty, XLWorkbook.EvaluateExpr("ROMAN(0, 0)")); + } - actual = XLWorkbook.EvaluateExpr("RoundDown(31415.92654, -2)"); - Assert.AreEqual(31400.0, actual); + [Test] + public void Roman_has_optional_second_argument_with_default_value_0() + { + Assert.AreEqual(@"CMXCIX", XLWorkbook.EvaluateExpr("ROMAN(999)")); + } - actual = XLWorkbook.EvaluateExpr("RoundDown(0, 3)"); - Assert.AreEqual(0.0, actual); + [Test] + public void Roman_form_must_be_between_0_and_4() + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("ROMAN(1, -1)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("ROMAN(1, 5)")); } [Test] - public void RoundUp() + public void Roman_value_must_be_between_0_and_3999() { - object actual = XLWorkbook.EvaluateExpr("RoundUp(3.2, 0)"); - Assert.AreEqual(4.0, actual); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("ROMAN(-1, 0)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("ROMAN(4000, 0)")); + } - actual = XLWorkbook.EvaluateExpr("RoundUp(76.9, 0)"); - Assert.AreEqual(77.0, actual); + [TestCase(2.15, 1, ExpectedResult = 2.2)] + [TestCase(2.149, 1, ExpectedResult = 2.1)] + [TestCase(-1.475, 2, ExpectedResult = -1.48)] + [TestCase(21.5, -1, ExpectedResult = 20.0)] + [TestCase(626.3, -3, ExpectedResult = 1000.0)] + [TestCase(1.98, -1, ExpectedResult = 0.0)] + [TestCase(-50.55, -2, ExpectedResult = -100.0)] + [TestCase(31.565, 2, ExpectedResult = 31.57)] + [TestCase(-31.565, 2, ExpectedResult = -31.57)] + [TestCase(1E+100, 2, ExpectedResult = 1E+100)] + [TestCase(1.25, 0, ExpectedResult = 1)] + [TestCase(1, -1E+100, ExpectedResult = 0)] + [TestCase(1.123456, 1E+100, ExpectedResult = 1.123456)] // Excel says 0 for anything over 2147483646 + public double Round(double number, double digits) + { + return (double)XLWorkbook.EvaluateExpr($"ROUND({number}, {digits})"); + } - actual = XLWorkbook.EvaluateExpr("RoundUp(3.14159, 3)"); - Assert.AreEqual(3.142, actual); + [TestCase(3.2, 0, ExpectedResult = 3.0)] + [TestCase(76.9, 0, ExpectedResult = 76.0)] + [TestCase(3.14159, 3, ExpectedResult = 3.141)] + [TestCase(-3.14159, 1, ExpectedResult = -3.1)] + [TestCase(31415.92654, -2, ExpectedResult = 31400.0)] + [TestCase(0, 3, ExpectedResult = 0)] + public double RoundDown(double number, double digits) + { + return (double)XLWorkbook.EvaluateExpr($"ROUNDDOWN({number}, {digits})"); + } + + [TestCase(3.2, 0, ExpectedResult = 4)] + [TestCase(76.9, 0, ExpectedResult = 77.0)] + [TestCase(3.14159, 3, ExpectedResult = 3.142)] + [TestCase(-3.14159, 1, ExpectedResult = -3.2)] + [TestCase(31415.92654, -2, ExpectedResult = 31500.0)] + [TestCase(0, 3, ExpectedResult = 0)] + [TestCase(11, 0, ExpectedResult = 11)] + public double RoundUp(double number, double digits) + { + return (double)XLWorkbook.EvaluateExpr($"ROUNDUP({number}, {digits})"); + } - actual = XLWorkbook.EvaluateExpr("RoundUp(-3.14159, 1)"); - Assert.AreEqual(-3.2, actual); + [TestCase("0", ExpectedResult = 0)] + [TestCase("10.5", ExpectedResult = 1)] + [TestCase("-5.4", ExpectedResult = -1)] + [TestCase("-0.00001", ExpectedResult = -1)] + [TestCase("-1E+300", ExpectedResult = -1)] + [TestCase("\"0 1/2\"", ExpectedResult = 1)] + [TestCase("FALSE", ExpectedResult = 0)] + [TestCase("TRUE", ExpectedResult = 1)] + public double Sign(string arg) + { + return (double)XLWorkbook.EvaluateExpr($"SIGN({arg})"); + } - actual = XLWorkbook.EvaluateExpr("RoundUp(31415.92654, -2)"); - Assert.AreEqual(31500.0, actual); + [TestCase("0", ExpectedResult = 0)] + [TestCase("1", ExpectedResult = 0.8414709848078965)] + [TestCase("-1", ExpectedResult = -0.8414709848078965)] + [TestCase("PI()", ExpectedResult = 0)] + [TestCase("PI()/2", ExpectedResult = 1)] + [TestCase("30*PI()/180", ExpectedResult = 0.5)] + [TestCase("RADIANS(30)", ExpectedResult = 0.5)] + [DefaultFloatingPointTolerance(tolerance)] + public double Sin(string arg) + { + return (double)XLWorkbook.EvaluateExpr($"SIN({arg})"); + } - actual = XLWorkbook.EvaluateExpr("RoundUp(0, 3)"); - Assert.AreEqual(0.0, actual); + [TestCase("0", 0)] + [TestCase("1", 1.1752011936438014)] + [TestCase("10", 11013.232874703393)] + [TestCase("100", 1.3440585709080678E+43)] + [TestCase("100", 1.3440585709080678E+43)] + [TestCase("711", XLError.NumberInvalid)] + [TestCase("-711", XLError.NumberInvalid)] + public void Sinh(string arg, object result) + { + var actual = XLWorkbook.EvaluateExpr($"SINH({arg})"); + Assert.AreEqual(result, actual); } [TestCase(0, 1)] @@ -1497,28 +1942,16 @@ public void RoundUp() [TestCase(11.4, 2.541355049)] [TestCase(45, 1.90359)] [TestCase(30, 6.48292)] - public void Sec_ReturnsCorrectNumber(double input, double expectedOutput) + public void Sec_returns_correct_number(double angle, double expectedOutput) { - double result = (double)XLWorkbook.EvaluateExpr( - string.Format( - @"SEC({0})", - input.ToString(CultureInfo.InvariantCulture))); + var result = (double)XLWorkbook.EvaluateExpr($"SEC({angle})"); Assert.AreEqual(expectedOutput, result, 0.00001); // as the secant is symmetric for positive and negative numbers, let's assert twice: - double resultForNegative = (double)XLWorkbook.EvaluateExpr( - string.Format( - @"SEC({0})", - (-input).ToString(CultureInfo.InvariantCulture))); + var resultForNegative = (double)XLWorkbook.EvaluateExpr($"SEC({-angle})"); Assert.AreEqual(expectedOutput, resultForNegative, 0.00001); } - [Test] - public void Sec_ThrowsCellValueExceptionOnNonNumericValue() - { - Assert.Throws(() => XLWorkbook.EvaluateExpr(@"SEC(""number"")")); - } - [TestCase(-9, 0.00024682)] [TestCase(-8, 0.000670925)] [TestCase(-7, 0.001823762)] @@ -1529,48 +1962,127 @@ public void Sec_ThrowsCellValueExceptionOnNonNumericValue() [TestCase(-2, 0.265802229)] [TestCase(-1, 0.648054274)] [TestCase(0, 1)] - public void Sech_ReturnsCorrectNumber(double input, double expectedOutput) + [TestCase(1E+100, 0)] + [TestCase(1E-100, 1)] + public void Sech_returns_correct_number(double angle, double expectedOutput) { - double result = (double)XLWorkbook.EvaluateExpr( - string.Format( - @"SECH({0})", - input.ToString(CultureInfo.InvariantCulture))); + var result = (double)XLWorkbook.EvaluateExpr($"SECH({angle})"); Assert.AreEqual(expectedOutput, result, 0.00001); // as the secant is symmetric for positive and negative numbers, let's assert twice: - double resultForNegative = (double)XLWorkbook.EvaluateExpr( - string.Format( - @"SECH({0})", - (-input).ToString(CultureInfo.InvariantCulture))); + var resultForNegative = (double)XLWorkbook.EvaluateExpr($"SECH({-angle})"); Assert.AreEqual(expectedOutput, resultForNegative, 0.00001); } [Test] + [DefaultFloatingPointTolerance(tolerance)] public void SeriesSum() { - object actual = XLWorkbook.EvaluateExpr("SERIESSUM(2,3,4,5)"); - Assert.AreEqual(40.0, actual); + Assert.AreEqual(40.0, XLWorkbook.EvaluateExpr("SERIESSUM(2,3,4,5)")); - var wb = new XLWorkbook(); - IXLWorksheet ws = wb.AddWorksheet("Sheet1"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet1"); ws.Cell("A2").FormulaA1 = "PI()/4"; ws.Cell("A3").Value = 1; ws.Cell("A4").FormulaA1 = "-1/FACT(2)"; ws.Cell("A5").FormulaA1 = "1/FACT(4)"; ws.Cell("A6").FormulaA1 = "-1/FACT(6)"; - actual = ws.Evaluate("SERIESSUM(A2,0,2,A3:A6)"); - Assert.IsTrue(Math.Abs(0.70710321482284566 - (double)actual) < XLHelper.Epsilon); + var actual = ws.Evaluate("SERIESSUM(A2,0,2,A3:A6)"); + Assert.AreEqual(0.70710321482284566, actual); + } + + [TestCase("{1,2,3;4,5,6}")] + [TestCase("{1,2,3,4,5,6}")] + [TestCase("{1,2;3,4;5,6}")] + public void SeriesSum_takes_coefficients_row_by_row_left_to_right(string array) + { + Assert.AreEqual(1284, XLWorkbook.EvaluateExpr($"SERIESSUM(2,2,1,{array})")); + } + + [Test] + public void SeriesSum_returns_invalid_number_error_when_result_is_too_large() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").InsertData(new object[] { 1, 2, 3, 4, 5 }); + Assert.AreEqual(3E+300, ws.Evaluate("SERIESSUM(10,100,100,A1:A3)")); + Assert.AreEqual(XLError.NumberInvalid, ws.Evaluate("SERIESSUM(10,100,100,A1:A4)")); + } + + [Test] + public void SeriesSum_coercion() + { + // For some weird reason, SERIESSUM doesn't convert logical + foreach (var invalidValue in new[] { "\"\"", "TRUE" }) + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"SERIESSUM({invalidValue},1,1,1)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"SERIESSUM(1,{invalidValue},1,1)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"SERIESSUM(1,1,{invalidValue},1)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"SERIESSUM(1,1,1,{invalidValue})")); + } + + // Blank and text values are coerced to a number + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + foreach (var validArg in new[] { "A1", "\"0 0/2\"" }) + { + Assert.AreEqual(0, ws.Evaluate($"SERIESSUM({validArg},1,1,1)")); + Assert.AreEqual(1, ws.Evaluate($"SERIESSUM(1,{validArg},1,1)")); + Assert.AreEqual(1, ws.Evaluate($"SERIESSUM(1,1,{validArg},1)")); + } + + // Text is not converted in an area and causes conversion error + ws.Cell("B2").Value = "0"; + ws.Cell("B3").Value = 5; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SERIESSUM(1,1,1,B2:B3)")); + + // Blank is interpreted as 0 + ws.Cell("C1").Value = Blank.Value; + ws.Cell("C2").Value = 2; + Assert.AreEqual(2, ws.Evaluate("SERIESSUM(1,1,1,C1:C2)")); + } + + [TestCase(0, 0)] + [TestCase(1, 1)] + [TestCase(2, 1.4142135624)] + [TestCase(1E+300, 1E+150)] + public void Sqrt(double x, double result) + { + Assert.AreEqual(result, (double)XLWorkbook.EvaluateExpr($"SQRT({x})"), tolerance); + } + + [TestCase(-1)] + [TestCase(-0.0001)] + public void Sqrt_returns_invalid_number_for_negative_numbers(double x) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"SQRT({x})")); } [Test] public void SqrtPi() { - object actual = XLWorkbook.EvaluateExpr("SqrtPi(1)"); - Assert.IsTrue(Math.Abs(1.7724538509055159 - (double)actual) < XLHelper.Epsilon); + var actual = (double)XLWorkbook.EvaluateExpr("SQRTPI(1)"); + Assert.AreEqual(1.7724538509055159, actual, tolerance); + + actual = (double)XLWorkbook.EvaluateExpr("SQRTPI(2)"); + Assert.AreEqual(2.5066282746310002, actual, tolerance); + + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("SQRTPI(-1)")); + } + + [Test] + public void Subtotal() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); - actual = XLWorkbook.EvaluateExpr("SqrtPi(2)"); - Assert.IsTrue(Math.Abs(2.5066282746310002 - (double)actual) < XLHelper.Epsilon); + // Non-existent functions return error + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SUBTOTAL(0, A1)")); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SUBTOTAL(0.9, A1)")); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SUBTOTAL(12, A1)")); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SUBTOTAL(100.9, A1)")); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SUBTOTAL(112, A1)")); } [Test] @@ -1580,21 +2092,22 @@ public void SubtotalAverage() var ws = wb.AddWorksheet(); ws.Cell("A1").Value = 2; ws.Cell("A2").Value = 3; - ws.Cell("A3").Value = "A"; + ws.Cell("A3").FormulaA1 = "SUBTOTAL(1,A1,A2)"; + ws.Cell("A4").Value = "A"; - object actual = ws.Evaluate("SUBTOTAL(1,A1,A2)"); - Assert.AreEqual(2.5, actual); + Assert.AreEqual(2.5, ws.Cell("A3").Value); + Assert.AreEqual(2.5, ws.Evaluate("SUBTOTAL(1, A1:A4)")); - actual = ws.Evaluate(@"SUBTOTAL(1,A1,A2,A3)"); - Assert.AreEqual(2.5, actual); + ws.Row(2).Hide(); + Assert.AreEqual(2, ws.Evaluate("SUBTOTAL(101, A1:A4)")); } [Test] - public void SubtotalCalc() + public void Subtotal10Calc() { - var wb = new XLWorkbook(); + using var wb = new XLWorkbook(); var ws = wb.AddWorksheet(); - ws.NamedRanges.Add("subtotalrange", "A37:A38"); + ws.DefinedNames.Add("subtotalrange", "$A$37:$A$38"); ws.Cell("A1").Value = 2; ws.Cell("A2").Value = 4; @@ -1657,110 +2170,221 @@ public void SubtotalCalc() Assert.AreEqual(47185920, ws.Cell("A42").Value); } + [Test] + public void Subtotal100Calc() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + ws.Cell("A1").Value = 1; + ws.Cell("B1").Value = 2; + ws.Cell("C1").Value = Blank.Value; + ws.Cell("A2").Value = "A"; + ws.Cell("B2").Value = 4; + ws.Cell("C2").Value = 8; + ws.Cell("A3").FormulaA1 = "SUBTOTAL(109, A1:A2)"; + ws.Cell("B3").FormulaA1 = "SUBTOTAL(109, B1:B2)"; + ws.Cell("C3").FormulaA1 = "SUBTOTAL(109, C1:C2)"; + ws.Cell("A4").Value = 16; + ws.Cell("B4").Value = 32; + ws.Cell("C4").Value = 64; + ws.Cell("A5").Value = 128; + ws.Cell("B5").Value = 256; + ws.Cell("C5").Value = 512; + ws.Cell("A6").FormulaA1 = "SUBTOTAL(109, A1:A5)"; + ws.Cell("B6").FormulaA1 = "SUBTOTAL(109, B1:B5)"; + ws.Cell("C6").FormulaA1 = "SUBTOTAL(109, C1:C5)"; + + ws.Row(2).Hide(); + ws.Row(5).Hide(); + + Assert.AreEqual(1, ws.Cell("A3").Value); + Assert.AreEqual(2, ws.Cell("B3").Value); + Assert.AreEqual(0, ws.Cell("C3").Value); + Assert.AreEqual(17, ws.Cell("A6").Value); + Assert.AreEqual(34, ws.Cell("B6").Value); + Assert.AreEqual(64, ws.Cell("C6").Value); + } + [Test] public void SubtotalCount() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 2, 3, "A"); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 2; + ws.Cell("A2").Value = 3; + ws.Cell("A3").Value = "A"; + ws.Cell("A4").FormulaA1 = "SUBTOTAL(2,A1:A3)"; - var actual = ws.Evaluate("SUBTOTAL(2,A1:A2)"); - Assert.AreEqual(2, actual); + Assert.AreEqual(2, ws.Cell("A4").Value); + Assert.AreEqual(1, ws.Evaluate("SUBTOTAL(2,A2:A4)")); - actual = ws.Evaluate(@"SUBTOTAL(2,A2:A3)"); - Assert.AreEqual(1, actual); + ws.Row(2).Hide(); + Assert.AreEqual(1, ws.Evaluate("SUBTOTAL(102,A1:A4)")); } [Test] public void SubtotalCountA() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 2, 3, ""); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 2; + ws.Cell("A2").Value = 3; + ws.Cell("A3").Value = string.Empty; + ws.Cell("A4").FormulaA1 = "SUBTOTAL(3,A1,A2,A3)"; - var actual = ws.Evaluate("SUBTOTAL(3,A1,A2)"); - Assert.AreEqual(2.0, actual); + Assert.AreEqual(3, ws.Cell("A4").Value); + Assert.AreEqual(3, ws.Evaluate("SUBTOTAL(3,A1:A4)")); - actual = ws.Evaluate(@"SUBTOTAL(3,A3,A2)"); - Assert.AreEqual(1.0, actual); + ws.Row(1).Hide(); + Assert.AreEqual(2, ws.Evaluate("SUBTOTAL(103,A1:A4)")); } [Test] public void SubtotalMax() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 2, 3, "A"); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 2; + ws.Cell("A2").Value = 3; + ws.Cell("A3").Value = "A"; + ws.Cell("A4").FormulaA1 = "SUBTOTAL(4,A1,A2,A3) + 10"; - var actual = ws.Evaluate(@"SUBTOTAL(4,A1:A3)"); - Assert.AreEqual(3.0, actual); + Assert.AreEqual(13, ws.Cell("A4").Value); + Assert.AreEqual(3, ws.Evaluate("SUBTOTAL(4,A1:A4)")); + + ws.Cell("A5").Value = 2.5; + ws.Row(2).Hide(); + Assert.AreEqual(2.5, ws.Evaluate("SUBTOTAL(104,A1:A5)")); } [Test] public void SubtotalMin() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 2, 3, "A"); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 2; + ws.Cell("A2").Value = 3; + ws.Cell("A3").Value = "A"; + ws.Cell("A4").FormulaA1 = "SUBTOTAL(5,A1,A2,A3) - 10"; + + Assert.AreEqual(-8, ws.Cell("A4").Value); + Assert.AreEqual(2, ws.Evaluate("SUBTOTAL(5,A1:A4)")); - var actual = ws.Evaluate(@"SUBTOTAL(5,A1:A3)"); - Assert.AreEqual(2.0, actual); + ws.Cell("A5").Value = 2.5; + ws.Row(1).Hide(); + Assert.AreEqual(2.5, ws.Evaluate("SUBTOTAL(105,A1:A5)")); } [Test] public void SubtotalProduct() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 2, 3, "A"); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 2; + ws.Cell("A2").Value = 3; + ws.Cell("A3").Value = "A"; + ws.Cell("A4").FormulaA1 = "SUBTOTAL(6,A1,A2,A3)"; + + Assert.AreEqual(6, ws.Cell("A4").Value); + Assert.AreEqual(6, ws.Evaluate("SUBTOTAL(6,A1:A4)")); - var actual = ws.Evaluate(@"Subtotal(6,A1,A2,A3)"); - Assert.AreEqual(6.0, actual); + ws.Row(2).Hide(); + ws.Cell("A5").Value = 4; + Assert.AreEqual(8, ws.Evaluate("SUBTOTAL(106,A1:A5)")); } [Test] + [DefaultFloatingPointTolerance(XLHelper.Epsilon)] public void SubtotalStDev() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 2, 3, "A"); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 2; + ws.Cell("A2").Value = 3; + ws.Cell("A3").Value = "A"; + ws.Cell("A4").FormulaA1 = "SUBTOTAL(7,A1:A3,A5)"; + ws.Cell("A5").Value = 5; + + Assert.AreEqual(1.5275252316, (double)ws.Cell("A4").Value); + Assert.AreEqual(1.5275252316, (double)ws.Evaluate("SUBTOTAL(7,A1:A5)")); - var actual = ws.Evaluate(@"SUBTOTAL(7,A1:A3)"); - Assert.IsTrue(Math.Abs(0.70710678118654757 - (double)actual) < XLHelper.Epsilon); + ws.Row(2).Hide(); + Assert.AreEqual(2.1213203435, (double)ws.Evaluate("SUBTOTAL(107,A1:A5)")); } [Test] public void SubtotalStDevP() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 2, 3, "A"); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 2; + ws.Cell("A2").Value = 3; + ws.Cell("A3").Value = "A"; + ws.Cell("A4").FormulaA1 = "SUBTOTAL(8,A1,A2,A3)"; + + Assert.AreEqual(0.5, ws.Cell("A4").Value); + Assert.AreEqual(0.5, ws.Evaluate("SUBTOTAL(8,A1:A4)")); - var actual = ws.Evaluate(@"SUBTOTAL(8,A1:A3)"); - Assert.AreEqual(0.5, actual); + ws.Row(2).Hide(); + ws.Cell("A5").Value = 3; + Assert.AreEqual(0.5, ws.Evaluate("SUBTOTAL(108,A1:A5)")); } [Test] public void SubtotalSum() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 2, 3, "A"); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 2; + ws.Cell("A2").Value = 3; + ws.Cell("A3").Value = "A"; + ws.Cell("A4").FormulaA1 = "SUBTOTAL(9,A1,A2,A3)"; - var actual = ws.Evaluate(@"SUBTOTAL(9,A1:A3)"); - Assert.AreEqual(5.0, actual); + Assert.AreEqual(5, ws.Cell("A4").Value); + Assert.AreEqual(5, ws.Evaluate("SUBTOTAL(9,A1:A4)")); + + ws.Row(2).Hide(); + + Assert.AreEqual(2, ws.Evaluate("SUBTOTAL(109, A1:A4)")); } [Test] public void SubtotalVar() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 5, 4, "A", 8, 5); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 5; + ws.Cell("A2").Value = 4; + ws.Cell("A3").Value = "A"; + ws.Cell("A4").Value = 8; + ws.Cell("A5").Value = 5; + ws.Cell("A6").FormulaA1 = "SUBTOTAL(10,A1:A5)"; - var actual = ws.Evaluate(@"SUBTOTAL(10,A1:A5)"); - Assert.AreEqual(3, actual); + Assert.AreEqual(3, ws.Cell("A6").Value); + Assert.AreEqual(3, ws.Evaluate("SUBTOTAL(10,A1:A6)")); + + ws.Row(1).Hide(); + ws.Row(5).Hide(); + Assert.AreEqual(8, ws.Evaluate("SUBTOTAL(110,A1:A6)")); } [Test] public void SubtotalVarP() { using var wb = new XLWorkbook(); - var ws = AddWorksheetWithCellValues(wb, 2, 3, "A"); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 2; + ws.Cell("A2").Value = 3; + ws.Cell("A3").Value = "A"; + ws.Cell("A4").FormulaA1 = "SUBTOTAL(11,A1,A2,A3)"; - var actual = ws.Evaluate(@"SUBTOTAL(11,A1:A3)"); - Assert.AreEqual(0.25, actual); + Assert.AreEqual(0.25, ws.Cell("A4").Value); + Assert.AreEqual(0.25, ws.Evaluate("SUBTOTAL(11,A1:A4)")); + + ws.Row(2).Hide(); + ws.Cell("A5").Value = 4; + Assert.AreEqual(1, ws.Evaluate("SUBTOTAL(111,A1:A5)")); } [Test] @@ -1789,6 +2413,25 @@ public void SumDateTimeAndNumber() } } + [TestCase(9, "SUMIF(A:B, \"A*\", C:C)")] + [TestCase(9, "SUMIF(A1:B6, \"A*\", C1:C6)")] + public void SumIf_InputRangeHasMultipleColumns(int expectedOutcome, string formula) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Data"); + var data = new object[] + { + new { Id = "AA", Id2 = "BA", Value = 2}, + new { Id = "AB", Id2 = "BB", Value = 3}, + new { Id = "BA", Id2 = "AA", Value = 2}, + new { Id = "BB", Id2 = "AB", Value = 1}, + new { Id = "AC", Id2 = "AC", Value = 4}, + }; + ws.Cell("A1").InsertTable(data); + + Assert.AreEqual(expectedOutcome, ws.Evaluate(formula)); + } + /// /// refers to Example 1 from the Excel documentation, /// @@ -1910,6 +2553,22 @@ public void SumIf_ReturnsCorrectValues_WhenFormulaBelongToSameRange() } } + [Test] + public void SumIfs_MultidimensionalRanges() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.FirstCell().InsertData(new object[] + { + (10, 10, 1, 2), + (20, 15, 2, 4), + (30, 20, 3, 6), + (40, 25, 4, 8), + (50, 30, 5, 10), + }); + Assert.AreEqual(30, ws.Evaluate("SUMIFS(C1:D5,A1:B5,\">20\")")); + } + /// /// refers to Example 2 to SumIf from the Excel documentation. /// As SumIfs should behave the same if called with three parameters, we can take that example here again. @@ -1925,8 +2584,6 @@ public void SumIfs_ReturnsCorrectValues_ReferenceExample2FromMicrosoft(int expec { using (var wb = new XLWorkbook()) { - wb.ReferenceStyle = XLReferenceStyle.A1; - var ws = wb.AddWorksheet("Sheet1"); ws.Cell(2, 1).Value = "Vegetables"; ws.Cell(3, 1).Value = "Vegetables"; @@ -1951,7 +2608,7 @@ public void SumIfs_ReturnsCorrectValues_ReferenceExample2FromMicrosoft(int expec ws.Cell(1, 3).Value = 300000; - var actualResult = ws.Evaluate(formula).CastTo(); + var actualResult = (double)ws.Evaluate(formula); Assert.AreEqual(expectedResult, actualResult); } } @@ -1970,8 +2627,6 @@ public void SumIfs_ReturnsCorrectValues_ReferenceExampleForSumIf1FromMicrosoft(i { using (var wb = new XLWorkbook()) { - wb.ReferenceStyle = XLReferenceStyle.A1; - var ws = wb.AddWorksheet("Sheet1"); ws.Cell(1, 1).Value = 100000; ws.Cell(1, 2).Value = 7000; @@ -2000,7 +2655,6 @@ public void SumIfs_ReturnsCorrectValues_ReferenceExampleFromMicrosoft( { using (var wb = new XLWorkbook()) { - wb.ReferenceStyle = XLReferenceStyle.A1; var ws = wb.AddWorksheet("Sheet1"); var row = 2; @@ -2044,53 +2698,180 @@ public void SumIfs_ReturnsCorrectValues_ReferenceExampleFromMicrosoft( ws.Cell(row, 2).Value = "Carrots"; ws.Cell(row, 3).Value = "Sarah"; - var actualResult = ws.Evaluate(formula).CastTo(); + var actualResult = ws.Evaluate(formula); - Assert.AreEqual(expectedResult, actualResult, tolerance); + Assert.AreEqual(expectedResult, (double)actualResult, tolerance); } } + [TestCase("SUMIFS(D1:E5,A1:B5,\"A*\",C1:C5,\">2\")")] + [TestCase("SUMIFS(H1:I3,A1:B3,1,D1:F2,2)")] + [TestCase("SUMIFS(D:E,A:B,\"A*\",C:C,\">2\")")] + public void SumIfs_ReturnsErrorWhenRangeDimensionsAreNotSame(string formula) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate(formula)); + } + + [TestCase("SUMIFS(A1:A3, B1:B3,\"<>B\")", 11)] + [TestCase("SUMIFS(A1:A3, B1:B3,\"<>\")", 110)] + public void SumIfs_matches_blank_cells_when_criteria_is_not_equal(string formula, double expectedSum) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 1; + ws.Cell("A2").Value = 10; + ws.Cell("A3").Value = 100; + ws.Cell("B1").Value = Blank.Value; + ws.Cell("B2").Value = string.Empty; + ws.Cell("B3").Value = "B"; + + Assert.AreEqual(expectedSum, ws.Evaluate(formula)); + } + [Test] public void SumProduct() { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet1"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet1"); - ws.FirstCell().Value = Enumerable.Range(1, 10); - ws.FirstCell().CellRight().Value = Enumerable.Range(1, 10).Reverse(); + ws.FirstCell().InsertData(Enumerable.Range(1, 10)); + ws.FirstCell().CellRight().InsertData(Enumerable.Range(1, 10).Reverse()); - Assert.AreEqual(2, ws.Evaluate("SUMPRODUCT(A2)")); - Assert.AreEqual(55, ws.Evaluate("SUMPRODUCT(A1:A10)")); - Assert.AreEqual(220, ws.Evaluate("SUMPRODUCT(A1:A10, B1:B10)")); + Assert.AreEqual(2, ws.Evaluate("SUMPRODUCT(A2)")); + Assert.AreEqual(55, ws.Evaluate("SUMPRODUCT(A1:A10)")); + Assert.AreEqual(220, ws.Evaluate("SUMPRODUCT(A1:A10, B1:B10)")); - Assert.Throws(() => ws.Evaluate("SUMPRODUCT(A1:A10, B1:B5)")); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SUMPRODUCT(A1:A10, B1:B5)")); - // Blank cells and cells with text should be treated as zeros - ws.Range("A1:A5").Clear(); - Assert.AreEqual(110, ws.Evaluate("SUMPRODUCT(A1:A10, B1:B10)")); + // Scalar, one element array and single cell area are compatible + Assert.AreEqual(60, ws.Evaluate("SUMPRODUCT(A5, 4, {3})")); - ws.Range("A1:A5").SetValue("asdf"); - Assert.AreEqual(110, ws.Evaluate("SUMPRODUCT(A1:A10, B1:B10)")); - } + // An array can be an argument + Assert.AreEqual(10, ws.Evaluate("SUMPRODUCT(A1:A3, {3;2;1})")); + + // An array must have correct orientation, otherwise dimensions don't match + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SUMPRODUCT(A1:A3, {3,2,1})")); + + // Anything but number is counted as zero. The second array is zero for all values = result is 0. + Assert.AreEqual(0, ws.Evaluate("SUMPRODUCT({1,2,3,4}, {TRUE,FALSE,\"1\",\"\"})")); + + // Any error returns error + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("SUMPRODUCT({1,2}, {1,#N/A})")); + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("SUMPRODUCT(A1, #N/A)")); + ws.Cell("A2").Value = XLError.NoValueAvailable; + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("SUMPRODUCT(A2, 5)")); + + // Blank cells and cells with text should be treated as zeros + ws.Range("A1:A5").Clear(); + Assert.AreEqual(110, ws.Evaluate("SUMPRODUCT(A1:A10, B1:B10)")); + + // Non-number values are treated as zero + ws.Range("A1:A5").SetValue("asdf"); + Assert.AreEqual(110, ws.Evaluate("SUMPRODUCT(A1:A10, B1:B10)")); + + // Blank cell is considered as a blank and cause #VALUE! error + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SUMPRODUCT(Z1, 5)")); + + // Blank value will cause #VALUE! error + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("SUMPRODUCT(IF(TRUE,,), 5)")); } [Test] public void SumSq() { - Object actual; + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // Examples from specification + Assert.AreEqual(4.0, XLWorkbook.EvaluateExpr("SUMSQ(2)")); + Assert.AreEqual(19.21, XLWorkbook.EvaluateExpr("SUMSQ(2.5, -3.6)")); + Assert.AreEqual(24.97, XLWorkbook.EvaluateExpr("SUMSQ({ 2.5, -3.6}, 2.4)")); + + // Scalar blank is converted to 0 + Assert.AreEqual(16, XLWorkbook.EvaluateExpr("SUMSQ(IF(TRUE,), 4)")); + + // Scalar logical is converted to number + Assert.AreEqual(10, XLWorkbook.EvaluateExpr("SUMSQ(3, TRUE)")); + + // Scalar text is converted to number + Assert.AreEqual(25, XLWorkbook.EvaluateExpr("SUMSQ(\"4\", \"3\")")); + + // Scalar text that is not convertible return error + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("SUMSQ(1, \"Hello\")")); + + // Array logical arguments are ignored + Assert.AreEqual(4, XLWorkbook.EvaluateExpr("SUMSQ({2,TRUE,TRUE,FALSE,FALSE})")); - actual = XLWorkbook.EvaluateExpr(@"SumSq(3,4)"); - Assert.AreEqual(25.0, actual); + // Array text arguments are ignored + Assert.AreEqual(20, XLWorkbook.EvaluateExpr("SUMSQ({4, 2, \"hello\", \"10\" })")); + + // Blank, logical and text from reference are ignored + ws.Cell("A1").Value = Blank.Value; + ws.Cell("A2").Value = true; + ws.Cell("A3").Value = "100"; + ws.Cell("A4").Value = "hello"; + ws.Cell("A5").Value = 1; + ws.Cell("A6").Value = 4; + Assert.AreEqual(17, ws.Evaluate("SUMSQ(A1:A6)")); + + // Scalar error is propagated + Assert.AreEqual(XLError.NullValue, XLWorkbook.EvaluateExpr("SUMSQ(1, #NULL!)")); + + // Array error is propagated + Assert.AreEqual(XLError.NullValue, XLWorkbook.EvaluateExpr("SUMSQ({1, #NULL!})")); + + // Reference error is propagated + ws.Cell("A1").Value = XLError.NoValueAvailable; + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("SUMSQ(A1)")); } - [Test] - public void Trunc() + [TestCase(-1, ExpectedResult = -1.5574077247)] + [TestCase(0, ExpectedResult = 0)] + [TestCase(1, ExpectedResult = 1.5574077247)] + [TestCase(134217727, ExpectedResult = 3.2584564256)] + [TestCase(-134217727, ExpectedResult = -3.2584564256)] + [DefaultFloatingPointTolerance(tolerance)] + public double Tan(double radians) { - var input = 27.64799257; - var expectedResult = 27; - var actual = (double)XLWorkbook.EvaluateExpr($"TRUNC({input.ToString(CultureInfo.InvariantCulture)})"); - Assert.AreEqual(expectedResult, actual); + return (double)XLWorkbook.EvaluateExpr($"TAN({radians})"); + } + + [TestCase(134217728)] + [TestCase(-134217728)] + [TestCase(1E+100)] + public void Tan_returns_invalid_number_for_radians_outside_limit(double radians) + { + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr($"TAN({radians})")); + } + + [TestCase(-1, -0.761594156)] + [TestCase(0, 0)] + [TestCase(1, 0.761594156)] + [TestCase(1E+300, 1)] + [TestCase(-1E+300, -1)] + [DefaultFloatingPointTolerance(tolerance)] + public void Tanh(double number, double result) + { + Assert.AreEqual(result, (double)XLWorkbook.EvaluateExpr($"TANH({number})")); + } + + [TestCase(27.64799257, null, 27)] + [TestCase(0, null, 0)] + [TestCase(0, 0, 0)] + [TestCase(3.1415926, 0, 3)] + [TestCase(3.1415926, 1, 3.1)] + [TestCase(3.1415926, 3, 3.141)] + [TestCase(3.1415926, 5, 3.14159)] + [TestCase(-4.3, 0, -4)] + [TestCase(8.9, null, 8)] + [TestCase(-8.9, null, -8)] + [TestCase(0.45, null, 0)] + public void Trunc(double number, double? digits, object expectedResult) + { + var formula = digits is null ? $"TRUNC({number})" : $"TRUNC({number}, {digits})"; + Assert.AreEqual(expectedResult, (double)XLWorkbook.EvaluateExpr(formula)); } [TestCase(27.64799257, -1, 20)] @@ -2102,14 +2883,5 @@ public void Trunc_Specify_Digits(double input, int digits, double expectedResult var actual = (double)XLWorkbook.EvaluateExpr($"TRUNC({input.ToString(CultureInfo.InvariantCulture)}, {digits})"); Assert.AreEqual(expectedResult, actual); } - - private static IXLWorksheet AddWorksheetWithCellValues(XLWorkbook wb, params object[] values) - { - var ws = wb.AddWorksheet(); - for (var row = 1; row <= values.Length; ++row) - ws.Cell(row, 1).Value = values[row - 1]; - - return ws; - } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/PrecedentCellsTests.cs b/ClosedXML.Tests/Excel/CalcEngine/PrecedentCellsTests.cs deleted file mode 100644 index 44f9ce242..000000000 --- a/ClosedXML.Tests/Excel/CalcEngine/PrecedentCellsTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using ClosedXML.Excel; -using NUnit.Framework; -using System.IO; -using System.Linq; - -namespace ClosedXML.Tests.Excel.CalcEngine -{ - [TestFixture] - public class PrecedentCellsTests - { - [Test] - public void GetPrecedentCellsDealsWithNamedRanges() - { - using (XLWorkbook wb = new XLWorkbook()) - { - var sheet1 = wb.AddWorksheet("Sheet1") as XLWorksheet; - sheet1.NamedRanges.Add("NAMED_RANGE", sheet1.Range("A2:B3")); - var formula = "=SUM(NAMED_RANGE)"; - - var reliable = sheet1.CalcEngine.TryGetPrecedentCells(formula, sheet1, out var cells); - - Assert.AreEqual(4, cells.Count); - Assert.AreEqual(new[] { "A2", "B2", "A3", "B3" }, cells.Select(x => x.Address.ToString())); - } - } - - [TestCase("=A1", new[] { "A1" }, new string[] { })] - [TestCase( - "=MAX(A2:E2)/COUNTBLANK(A2:E2)*MAX(B1:C3)+SUM(Sheet2!B1:C3)+SUM($A$2:$E$2)+A2+B$2+$C$2", - new[] { "A2", "B2", "C2", "D2", "E2", "B1", "C1", "B3", "C3" }, - new[] { "B1", "C1", "B2", "C2", "B3", "C3" })] - public void GetPrecedentCellsPreventsDuplication(string formula, string[] expectedAtSheet1, string[] expectedAtSheet2) - { - using (XLWorkbook wb = new XLWorkbook()) - { - var sheet1 = wb.AddWorksheet("Sheet1") as XLWorksheet; - var sheet2 = wb.AddWorksheet("Sheet2"); - - var remotelyReliable = sheet1.CalcEngine.TryGetPrecedentCells(formula, sheet1, out var cells); - - Assert.True(remotelyReliable); - Assert.AreEqual(expectedAtSheet1.Length + expectedAtSheet2.Length, cells.Count()); - foreach (var address in expectedAtSheet1) - { - Assert.IsTrue(cells.Any(cell => cell.Address.Worksheet.Name == sheet1.Name && cell.Address.ToString() == address), - string.Format("Address {0}!{1} is not presented", sheet1.Name, address)); - } - foreach (var address in expectedAtSheet2) - { - Assert.IsTrue(cells.Any(cell => cell.Address.Worksheet.Name == sheet2.Name && cell.Address.ToString() == address), - string.Format("Address {0}!{1} is not presented", sheet2.Name, address)); - } - } - } - - [Test] - public void CanParseWorksheetNamesWithExclamationMark() - { - using (var wb = new XLWorkbook()) - { - var ws1 = wb.AddWorksheet() as XLWorksheet; - var ws2 = wb.AddWorksheet("Worksheet!"); - var expectedCell = ws2.Cell("B2"); - - var remotelyReliable = ws1.CalcEngine.TryGetPrecedentCells("='Worksheet!'!B2*2", ws1, out var cells); - Assert.True(remotelyReliable); - Assert.AreSame(expectedCell, cells.Single()); - } - } - - [Test] - public void NonexistentSheetsMeanUnreliablePrecednetCells() - { - using var wb = new XLWorkbook(); - var ws = (XLWorksheet)wb.AddWorksheet(); - var remotelyReliable = ws.CalcEngine.TryGetPrecedentCells("=Sheet2!A1", ws, out var cells); - Assert.False(remotelyReliable); - } - } -} diff --git a/ClosedXML.Tests/Excel/CalcEngine/ReferenceOperatorsTests.cs b/ClosedXML.Tests/Excel/CalcEngine/ReferenceOperatorsTests.cs index d72750d7e..b661550a3 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/ReferenceOperatorsTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/ReferenceOperatorsTests.cs @@ -1,5 +1,4 @@ -using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine; +using ClosedXML.Excel; using NUnit.Framework; namespace ClosedXML.Tests.Excel.CalcEngine diff --git a/ClosedXML.Tests/Excel/CalcEngine/StatisticalTests.cs b/ClosedXML.Tests/Excel/CalcEngine/StatisticalTests.cs index e1cbedba9..ec71d6d63 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/StatisticalTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/StatisticalTests.cs @@ -1,7 +1,5 @@ // Keep this file CodeMaid organised and cleaned using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine; -using ClosedXML.Excel.CalcEngine.Exceptions; using NUnit.Framework; using System; using System.Linq; @@ -11,93 +9,292 @@ namespace ClosedXML.Tests.Excel.CalcEngine [TestFixture] public class StatisticalTests { - private double tolerance = 1e-6; + private const double tolerance = 1e-6; private XLWorkbook workbook; [Test] public void Average() { double value; - value = workbook.Evaluate("AVERAGE(-27.5,93.93,64.51,-70.56)").CastTo(); + value = (double)workbook.Evaluate("AVERAGE(-27.5,93.93,64.51,-70.56)"); Assert.AreEqual(15.095, value, tolerance); var ws = workbook.Worksheets.First(); - value = ws.Evaluate("AVERAGE(G3:G45)").CastTo(); + value = (double)ws.Evaluate("AVERAGE(G3:G45)"); Assert.AreEqual(49.3255814, value, tolerance); - Assert.That(() => ws.Evaluate("AVERAGE(D3:D45)"), Throws.TypeOf()); + // Column D contains only strings - no average, because non-number types are skipped + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("AVERAGE(D3:D45)")); + + // Non-numbers in array are skipped instead of being converted + Assert.AreEqual(-1, ws.Evaluate("AVERAGE({FALSE, TRUE, \"1\", \"0 0/2\", -1})")); + + // Blank value in references are skipped + ws.Cell("Z1").Value = Blank.Value; + Assert.AreEqual(1, ws.Evaluate("AVERAGE(Z1,1)")); + + AssertScalarToNumberConversion("AVERAGE", 0.5); + AssertAnyErrorIsPropagated("AVERAGE"); + } + + [Test] + public void AverageA() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // Examples from specification + ws.Cell("E1").Value = Blank.Value; + Assert.AreEqual(10, ws.Evaluate("AVERAGEA(10, E1)")); + ws.Cell("E2").Value = true; + Assert.AreEqual(5.5, ws.Evaluate("AVERAGEA(10, E2)")); + ws.Cell("E3").Value = false; + Assert.AreEqual(5, ws.Evaluate("AVERAGEA(10, E3)")); + + // Make sure multiple values not in an array work as intended + Assert.AreEqual(15.095, (double)workbook.Evaluate("AVERAGEA(-27.5,93.93,64.51,-70.56)"), tolerance); + + // Array logical arguments are ignored + Assert.AreEqual(2, workbook.Evaluate("AVERAGEA({2,TRUE,TRUE,FALSE,FALSE})")); + + // Array text arguments are counted as zero (4+2+0+0)/4 + Assert.AreEqual(1.5, workbook.Evaluate("AVERAGEA({4, 2, \"hello\", \"10\" })")); + + // Reference argument only counts logical as 0/1, text as 0 and ignores blanks. + ws.Cell("Z1").Value = Blank.Value; // Not counted + ws.Cell("Z2").Value = true; // 1 + ws.Cell("Z3").Value = "100"; // 0 + ws.Cell("Z4").Value = "hello"; // 0 + ws.Cell("Z5").Value = 0; // 0 + ws.Cell("Z6").Value = 4; // 4 + Assert.AreEqual(1, (double)ws.Evaluate("AVERAGEA(Z1:Z6)")); + + AssertScalarToNumberConversion("AVERAGEA", 0.5); + AssertAnyErrorIsPropagated("AVERAGEA"); + } + + [TestCase(6, 10, 0.5, 0.205078125)] + [TestCase(4, 20, 0.2, 0.2181994)] // p different than 0.5 + [TestCase(0, 5, 0.2, 0.32768)] // 0 out of 5 successes + [TestCase(0, 0, 0.2, 1)] // 0 out of 0 successes + [TestCase(1, 1, 0, 0)] + [TestCase(1, 1, 1, 1)] + [TestCase(2, 4, 0.5, 0.375)] + [TestCase(2.9, 4.9, 0.5, 0.375)] // Attempts are floored + public void BinomDist_calculates_non_cumulative_binomial_distribution(double k, double n, double p, double expected) + { + var kString = k.ToInvariantString(); + var nString = n.ToInvariantString(); + var pString = p.ToInvariantString(); + var result = (double)XLWorkbook.EvaluateExpr($"BINOMDIST({kString}, {nString}, {pString}, FALSE)"); + Assert.AreEqual(expected, result, tolerance); + } + + [TestCase(6, 10, 0.5, 0.828125)] + [TestCase(2, 7, 0.3, 0.6470695)] + [TestCase(0, 7, 0.3, 0.0823543)] + [TestCase(0, 0, 0.3, 1)] + [TestCase(0, 0, 1, 1)] + [TestCase(2, 4, 0.5, 0.6875)] + [TestCase(2.9, 4.9, 0.5, 0.6875)] // Values are floored + public void BinomDist_calculates_cumulative_binomial_distribution(double k, double n, double p, double expected) + { + var kString = k.ToInvariantString(); + var nString = n.ToInvariantString(); + var pString = p.ToInvariantString(); + var result = (double)XLWorkbook.EvaluateExpr($"BINOMDIST({kString}, {nString}, {pString}, TRUE)"); + Assert.AreEqual(expected, result, tolerance); + } + + [TestCase(5, 4, 0.5)] // Five successes out of 4 attempts + [TestCase(-1, 4, 0.5)] // Negative successes + [TestCase(0, -1, 0.5)] // Negative attempts + [TestCase(2, 4, -0.1)] // p < 0 + [TestCase(2, 4, 1.1)] // p > 1 + [TestCase(1E+300, 2E+300, 0.5)] // Too large values + public void BinomDist_returns_num_error_on_invalid_calculations(double k, double n, double p) + { + var kString = k.ToInvariantString(); + var nString = n.ToInvariantString(); + var pString = p.ToInvariantString(); + var result = XLWorkbook.EvaluateExpr($"BINOMDIST({kString}, {nString}, {pString}, FALSE)"); + Assert.AreEqual(XLError.NumberInvalid, result); } [Test] public void Count() { var ws = workbook.Worksheets.First(); - int value; - value = ws.Evaluate(@"=COUNT(D3:D45)").CastTo(); + XLCellValue value; + value = ws.Evaluate("COUNT(D3:D45)"); Assert.AreEqual(0, value); - value = ws.Evaluate(@"=COUNT(G3:G45)").CastTo(); + value = ws.Evaluate("COUNT(G3:G45)"); Assert.AreEqual(43, value); - value = ws.Evaluate(@"=COUNT(G:G)").CastTo(); + value = ws.Evaluate("COUNT(G:G)"); Assert.AreEqual(43, value); - value = workbook.Evaluate(@"=COUNT(Data!G:G)").CastTo(); + value = workbook.Evaluate("COUNT(Data!G:G)"); Assert.AreEqual(43, value); + + // Scalar blank, logical and text is counted as numbers + Assert.AreEqual(4, ws.Evaluate("COUNT(IF(TRUE,,),TRUE, FALSE, \"1\")")); + + // Non-number values in arrays are not counted as numbers. + Assert.AreEqual(0, ws.Evaluate("COUNT({TRUE,FALSE,\"1\"})")); + + // Text is not counted as number. + Assert.AreEqual(0, ws.Evaluate("COUNT(\"Hello\")")); + + // Blank cells are not counted as numbers + ws.Cell("Z1").Value = Blank.Value; + Assert.AreEqual(0, ws.Evaluate("COUNT(Z1)")); + + // Scalar errors are not propagated + Assert.AreEqual(1, ws.Evaluate("COUNT(1, #NULL!)")); + + // Array errors are not propagated + Assert.AreEqual(1, ws.Evaluate("COUNT({1, #NULL!})")); + + // Reference errors are not propagated + ws.Cell("Z1").Value = XLError.NullValue; + Assert.AreEqual(0, ws.Evaluate("COUNT(Z1)")); } [Test] public void CountA() { var ws = workbook.Worksheets.First(); - int value; - value = ws.Evaluate(@"=COUNTA(D3:D45)").CastTo(); + var value = ws.Evaluate("COUNTA(D3:D45)"); Assert.AreEqual(43, value); - value = ws.Evaluate(@"=COUNTA(G3:G45)").CastTo(); + value = ws.Evaluate("COUNTA(G3:G45)"); Assert.AreEqual(43, value); - value = ws.Evaluate(@"=COUNTA(G:G)").CastTo(); + value = ws.Evaluate("COUNTA(G:G)"); Assert.AreEqual(44, value); - value = workbook.Evaluate(@"=COUNTA(Data!G:G)").CastTo(); + value = workbook.Evaluate("COUNTA(Data!G:G)"); Assert.AreEqual(44, value); } [Test] - public void CountBlank() + public void CountA_counts_non_blank_values() { - var ws = workbook.Worksheets.First(); - int value; - value = ws.Evaluate(@"=COUNTBLANK(B:B)").CastTo(); - Assert.AreEqual(1048532, value); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = Blank.Value; + ws.Cell("A2").Value = 39790; + ws.Cell("A3").Value = 0; + ws.Cell("A4").Value = 22.24; + ws.Cell("A5").Value = "Text"; + ws.Cell("A6").Value = false; + ws.Cell("A7").Value = true; + ws.Cell("A8").Value = XLError.DivisionByZero; + ws.Cell("A9").FormulaA1 = "COUNTA(A1:B8)"; + Assert.AreEqual(7, ws.Cell("A9").Value); + } - value = ws.Evaluate(@"=COUNTBLANK(D43:D49)").CastTo(); - Assert.AreEqual(4, value); + [Test] + public void CountA_on_examples_from_spec() + { + Assert.AreEqual(5, XLWorkbook.EvaluateExpr("COUNTA(1,2,3,4,5)")); + Assert.AreEqual(5, XLWorkbook.EvaluateExpr("COUNTA(1,2,3,4,5)")); + Assert.AreEqual(7, XLWorkbook.EvaluateExpr("COUNTA({1,2,3,4,5},6,\"7\")")); + + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("E2").Value = true; + Assert.AreEqual(1, ws.Evaluate("COUNTA(10, E1)")); + Assert.AreEqual(2, ws.Evaluate("COUNTA(10, E2)")); + } - value = ws.Evaluate(@"=COUNTBLANK(E3:E45)").CastTo(); - Assert.AreEqual(0, value); + [Test] + public void CountA_accepts_union_references() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A2").Value = 7; + ws.Cell("B5").Value = false; + Assert.AreEqual(2, ws.Evaluate("COUNTA((A1:A4,B4:B7))")); + } - value = ws.Evaluate(@"=COUNTBLANK(A1)").CastTo(); - Assert.AreEqual(1, value); + [Test] + public void CountA_doesnt_count_single_blank_cell_reference() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(0, ws.Evaluate("COUNTA(A1)")); + } + + [Test] + public void CountA_counts_blank_argument() + { + Assert.AreEqual(1, XLWorkbook.EvaluateExpr("COUNTA(IF(TRUE,,))")); + } - Assert.Throws(() => workbook.Evaluate(@"=COUNTBLANK(E3:E45)")); - Assert.Throws(() => ws.Evaluate(@"=COUNTBLANK()")); - Assert.Throws(() => ws.Evaluate(@"=COUNTBLANK(A3:A45,E3:E45)")); + [Test] + public void CountA_counts_error_arguments() + { + Assert.AreEqual(7, XLWorkbook.EvaluateExpr("COUNTA(#NULL!, #DIV/0!, #VALUE!, #REF!, #NAME?, #NUM!, #N/A)")); + } + + [Test] + public void CountA_counts_empty_string() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = string.Empty; + Assert.AreEqual(2, ws.Evaluate("COUNTA(A1, \"\")")); + } + + [Test] + public void CountBlank() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = Blank.Value; + ws.Cell("A2").Value = 0; + ws.Cell("A3").Value = 1; + ws.Cell("A4").Value = false; + ws.Cell("A5").Value = true; + ws.Cell("A6").Value = ""; + ws.Cell("A7").Value = "Text"; + ws.Cell("A8").Value = XLError.DivisionByZero; + + // Blank and empty text value is counted as blank + Assert.AreEqual(1, ws.Evaluate("COUNTBLANK(A1)")); + Assert.AreEqual(string.Empty, ws.Cell("A6").Value); + Assert.AreEqual(1, ws.Evaluate("COUNTBLANK(A6)")); + + // Anything else isn't counted as blank + Assert.AreEqual(2, ws.Evaluate("COUNTBLANK(A1:A8)")); + + Assert.AreEqual(17179869178d, ws.Evaluate("COUNTBLANK(A:XFD)")); + + // Check that all others argument types. The Excel grammar doesn't allow that, + // so use IF workaround for that. + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("COUNTBLANK(IF(TRUE,))")); // Blank + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("COUNTBLANK(IF(TRUE,FALSE))")); // Logical + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("COUNTBLANK(IF(TRUE,1))")); // Number + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("COUNTBLANK(IF(TRUE,\"\"))")); // Text + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("COUNTBLANK(IF(TRUE,#DIV/0!))")); // Error + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("COUNTBLANK(IF(TRUE,{1}))")); // Array } [Test] public void CountIf() { var ws = workbook.Worksheets.First(); - int value; - value = ws.Evaluate(@"=COUNTIF(D3:D45,""Central"")").CastTo(); + XLCellValue value; + value = ws.Evaluate(@"=COUNTIF(D3:D45,""Central"")"); Assert.AreEqual(24, value); - value = ws.Evaluate(@"=COUNTIF(D:D,""Central"")").CastTo(); + value = ws.Evaluate(@"=COUNTIF(D:D,""Central"")"); Assert.AreEqual(24, value); - value = workbook.Evaluate(@"=COUNTIF(Data!D:D,""Central"")").CastTo(); + value = workbook.Evaluate(@"=COUNTIF(Data!D:D,""Central"")"); Assert.AreEqual(24, value); } @@ -113,7 +310,7 @@ public void CountIf_ConditionWithWildcards(string formula, int expectedResult) { var ws = workbook.Worksheets.First(); - int value = ws.Evaluate(formula).CastTo(); + var value = ws.Evaluate(formula); Assert.AreEqual(expectedResult, value); } @@ -162,15 +359,13 @@ public void CountIf_MoreWildcards(string cellContent, string formula, int expect [TestCase("=COUNTIFS(B1:D1, \"=Yes\")", 1)] [TestCase("=COUNTIFS(B1:B4, \"=Yes\", C1:C4, \"=Yes\")", 2)] - [TestCase("= COUNTIFS(B4:D4, \"=Yes\", B2:D2, \"=Yes\")", 1)] + [TestCase("=COUNTIFS(B4:D4, \"=Yes\", B2:D2, \"=Yes\")", 1)] public void CountIfs_ReferenceExample1FromExcelDocumentations( string formula, int expectedOutcome) { using (var wb = new XLWorkbook()) { - wb.ReferenceStyle = XLReferenceStyle.A1; - var ws = wb.AddWorksheet("Sheet1"); ws.Cell(1, 1).Value = "Davidoski"; @@ -201,14 +396,14 @@ public void CountIfs_ReferenceExample1FromExcelDocumentations( public void CountIfs_SingleCondition() { var ws = workbook.Worksheets.First(); - int value; - value = ws.Evaluate(@"=COUNTIFS(D3:D45,""Central"")").CastTo(); + XLCellValue value; + value = ws.Evaluate(@"=COUNTIFS(D3:D45,""Central"")"); Assert.AreEqual(24, value); - value = ws.Evaluate(@"=COUNTIFS(D:D,""Central"")").CastTo(); + value = ws.Evaluate(@"=COUNTIFS(D:D,""Central"")"); Assert.AreEqual(24, value); - value = workbook.Evaluate(@"=COUNTIFS(Data!D:D,""Central"")").CastTo(); + value = workbook.Evaluate(@"=COUNTIFS(Data!D:D,""Central"")"); Assert.AreEqual(24, value); } @@ -224,41 +419,85 @@ public void CountIfs_SingleConditionWithWildcards(string formula, int expectedRe { var ws = workbook.Worksheets.First(); - int value = ws.Evaluate(formula).CastTo(); + var value = ws.Evaluate(formula); Assert.AreEqual(expectedResult, value); } + [TestCase("COUNTIFS(H1:I3, 1, D1:F2, 2)")] + [TestCase("COUNTIFS(A:B, \"A*\", C:C, \">2\")")] + public void CountIfs_returns_error_when_areas_dimensions_are_different(string formula) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate(formula)); + } + [OneTimeTearDown] public void Dispose() { workbook.Dispose(); } - [TestCase(@"H3:H45", ExpectedResult = 7.51126069234216)] - [TestCase(@"H:H", ExpectedResult = 7.51126069234216)] - [TestCase(@"Data!H:H", ExpectedResult = 7.51126069234216)] - [TestCase(@"H3:H10", ExpectedResult = 5.26214814727941)] - [TestCase(@"H3:H20", ExpectedResult = 7.01281435054797)] - [TestCase(@"H3:H30", ExpectedResult = 7.00137389296182)] - [TestCase(@"H3:H3", ExpectedResult = 1.99)] - [TestCase(@"H10:H20", ExpectedResult = 8.37855107505682)] - [TestCase(@"H15:H20", ExpectedResult = 15.8927310267677)] - [TestCase(@"H20:H30", ExpectedResult = 7.14321227391814)] + [TestCase("H3:H45", ExpectedResult = 7.51126069234216)] + [TestCase("H:H", ExpectedResult = 7.51126069234216)] + [TestCase("Data!H:H", ExpectedResult = 7.51126069234216)] + [TestCase("H3:H10", ExpectedResult = 5.26214814727941)] + [TestCase("H3:H20", ExpectedResult = 7.01281435054797)] + [TestCase("H3:H30", ExpectedResult = 7.00137389296182)] + [TestCase("H3:H3", ExpectedResult = 1.99)] + [TestCase("H10:H20", ExpectedResult = 8.37855107505682)] + [TestCase("H15:H20", ExpectedResult = 15.8927310267677)] + [TestCase("H20:H30", ExpectedResult = 7.14321227391814)] [DefaultFloatingPointTolerance(1e-12)] - public double Geomean(string sourceValue) + public double Geomean_calculation(string sourceValue) { - return workbook.Worksheets.First().Evaluate($"=GEOMEAN({sourceValue})").CastTo(); + return (double)workbook.Worksheets.First().Evaluate($"GEOMEAN({sourceValue})"); } - [TestCase("D3:D45", typeof(NumberException), "No numeric parameters.")] - [TestCase("-1, 0, 3", typeof(NumberException), "Incorrect parameters. Use only positive numbers in your data.")] - public void Geomean_IncorrectCases(string sourceValue, Type exceptionType, string exceptionMessage) + [TestCase("D3:D45", ExpectedResult = XLError.NumberInvalid)] + [TestCase("-1, 0, 3", ExpectedResult = XLError.NumberInvalid)] + [TestCase("0", ExpectedResult = XLError.NumberInvalid)] + public XLError Geomean_IncorrectCases(string sourceValue) { var ws = workbook.Worksheets.First(); - Assert.Throws( - Is.TypeOf(exceptionType).And.Message.EqualTo(exceptionMessage), - () => ws.Evaluate($"=GEOMEAN({sourceValue})")); + return (XLError)ws.Evaluate($"GEOMEAN({sourceValue})"); + } + + [Test] + [DefaultFloatingPointTolerance(1e-8)] + public void Geomean() + { + // Example from the specification + Assert.AreEqual(5.4444547024966, (double)XLWorkbook.EvaluateExpr("GEOMEAN(10.5,5.3,2.9)")); + Assert.AreEqual(6.6337805880630, (double)XLWorkbook.EvaluateExpr("GEOMEAN(10.5,{5.3,2.9},\"12\")")); + + // GEOMEAN isn't limited by double scale, i.e. it doesn't use naive algorithm for large number. + Assert.AreEqual(1.0000000000000231E+307d, (double)XLWorkbook.EvaluateExpr("GEOMEAN(1E+307, 1E+307)")); + + // Scalar blank is counted as a 0 + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("GEOMEAN(IF(TRUE,), 1)")); + + // Scalar logical and text is converted to numbers + Assert.AreEqual(2.236067977, (double)XLWorkbook.EvaluateExpr("GEOMEAN(TRUE, \"5\")")); + + // Non-number values in arrays are ignored. + Assert.AreEqual(5.916079783, (double)XLWorkbook.EvaluateExpr("GEOMEAN({TRUE, FALSE, \"1\", 7}, 5)")); + + // Scalar non-number text causes an error due to conversion. + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("GEOMEAN(\"Hello\", 5)")); + + // Reference non-number arguments are ignored + var ws = workbook.Worksheets.First(); + ws.Cell("Z1").Value = Blank.Value; + ws.Cell("Z2").Value = "1"; + ws.Cell("Z3").Value = "hello"; + ws.Cell("Z4").Value = false; + ws.Cell("Z5").Value = true; + ws.Cell("Z6").Value = 5; + Assert.AreEqual(5, (double)ws.Evaluate("GEOMEAN(Z1:Z6)")); + + AssertAnyErrorIsPropagated("GEOMEAN"); } [SetUp] @@ -282,17 +521,44 @@ public void Init() [DefaultFloatingPointTolerance(1e-10)] public double DevSq(string sourceValue) { - return workbook.Worksheets.First().Evaluate($"=DEVSQ({sourceValue})").CastTo(); + return (double)workbook.Worksheets.First().Evaluate($"DEVSQ({sourceValue})"); } - [TestCase("D3:D45", typeof(CellValueException), "No numeric parameters.")] - public void Devsq_IncorrectCases(string sourceValue, Type exceptionType, string exceptionMessage) + [TestCase("D3:D45", ExpectedResult = XLError.NumberInvalid)] + public XLError Devsq_IncorrectCases(string sourceValue) { var ws = workbook.Worksheets.First(); - Assert.Throws( - Is.TypeOf(exceptionType).And.Message.EqualTo(exceptionMessage), - () => ws.Evaluate($"=DEVSQ({sourceValue})")); + return (XLError)ws.Evaluate($"DEVSQ({sourceValue})"); + } + + [Test] + [DefaultFloatingPointTolerance(1e-10)] + public void Devsq_is_calculated_from_numbers() + { + Assert.AreEqual(6.90666666666666, (double)XLWorkbook.EvaluateExpr("DEVSQ(5.6, 8.2, 9.2)")); + Assert.AreEqual(6.90666666666666, (double)XLWorkbook.EvaluateExpr("DEVSQ({ 5.6, 8.2, 9.2})")); + + // Array logical arguments are ignored + Assert.AreEqual(0, workbook.Evaluate("DEVSQ({2,TRUE,TRUE,FALSE,FALSE})")); + Assert.AreEqual(2.8, (double)workbook.Evaluate("DEVSQ({2, 1, 1, 0, 0})")); + + // Array text arguments are ignored + Assert.AreEqual(2, workbook.Evaluate("DEVSQ({4, 2, \"hello\", \"10\" })")); + + // Non-numerical reference values are ignored. + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = Blank.Value; // Ignored + ws.Cell("A2").Value = true; // Ignored + ws.Cell("A3").Value = "100"; // Ignored + ws.Cell("A4").Value = "hello"; // Ignored + ws.Cell("A5").Value = 2; // Included + ws.Cell("A6").Value = 4; // Included + Assert.AreEqual(2, ws.Evaluate("DEVSQ(A1:A6)")); + + AssertScalarToNumberConversion("DEVSQ", 0.5); + AssertAnyErrorIsPropagated("DEVSQ"); } [TestCase(0, ExpectedResult = 0)] @@ -308,50 +574,100 @@ public void Devsq_IncorrectCases(string sourceValue, Type exceptionType, string [DefaultFloatingPointTolerance(1e-12)] public double Fisher(double sourceValue) { - return XLWorkbook.EvaluateExpr($"=FISHER({sourceValue})").CastTo(); + return (double)XLWorkbook.EvaluateExpr($"FISHER({sourceValue})"); } - // TODO : the string case will be treated correctly when Coercion is implemented better - //[TestCase("asdf", typeof(CellValueException), "Parameter non numeric.")] - [TestCase("5", typeof(NumberException), "Incorrect value. Should be: -1 > x < 1.")] - [TestCase("-1", typeof(NumberException), "Incorrect value. Should be: -1 > x < 1.")] - [TestCase("1", typeof(NumberException), "Incorrect value. Should be: -1 > x < 1.")] - public void Fisher_IncorrectCases(string sourceValue, Type exceptionType, string exceptionMessage) + [TestCase("\"asdf\"", ExpectedResult = XLError.IncompatibleValue)] + [TestCase("5", ExpectedResult = XLError.NumberInvalid)] + [TestCase("-1", ExpectedResult = XLError.NumberInvalid)] + [TestCase("1", ExpectedResult = XLError.NumberInvalid)] + public XLError Fisher_IncorrectCases(string sourceValue) { - Assert.Throws( - Is.TypeOf(exceptionType).And.Message.EqualTo(exceptionMessage), - () => XLWorkbook.EvaluateExpr($"=FISHER({sourceValue})")); + return (XLError)XLWorkbook.EvaluateExpr($"FISHER({sourceValue})"); } [Test] public void Max() { var ws = workbook.Worksheets.First(); - int value; - value = ws.Evaluate(@"=MAX(D3:D45)").CastTo(); + XLCellValue value; + value = ws.Evaluate(@"=MAX(D3:D45)"); Assert.AreEqual(0, value); - value = ws.Evaluate(@"=MAX(G3:G45)").CastTo(); + value = ws.Evaluate(@"=MAX(G3:G45)"); Assert.AreEqual(96, value); - value = ws.Evaluate(@"=MAX(G:G)").CastTo(); + value = ws.Evaluate(@"=MAX(G:G)"); Assert.AreEqual(96, value); - value = workbook.Evaluate(@"=MAX(Data!G:G)").CastTo(); + value = workbook.Evaluate(@"=MAX(Data!G:G)"); Assert.AreEqual(96, value); + + // Although in most cases blank cells are considered 0, MAX just ignores them. + value = workbook.Evaluate(@"MAX(-10, Data!X:Z)"); + Assert.AreEqual(-10, value); + + // Arrays - numbers are used + value = workbook.Evaluate(@"MAX(-10, { -6, -5, 7 })"); + Assert.AreEqual(7, value); + + // Arrays - non-number and non-error values are skipped. + value = workbook.Evaluate(@"MAX(-10, { TRUE, FALSE, ""100"" })"); + Assert.AreEqual(-10, value); + + // Reference argument ignores everything but number. + ws.Cell("Z1").Value = Blank.Value; + ws.Cell("Z2").Value = true; + ws.Cell("Z3").Value = "100"; + ws.Cell("Z4").Value = "hello"; + ws.Cell("Z5").Value = -4; + Assert.AreEqual(-4, ws.Evaluate("MAX(Z1:Z5)")); + + AssertScalarToNumberConversion("MAX", 1); + AssertAnyErrorIsPropagated("MAX"); } [Test] - public void Median_CellRangeOfNonNumericValues_ThrowsApplicationException() + public void MaxA() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // Examples from specification + Assert.AreEqual(12.6, ws.Evaluate("MAXA(10.4,-3.5,12.6)")); + Assert.AreEqual(12.6, ws.Evaluate("MAXA(10.4,{-3.5,12.6})")); + Assert.AreEqual(0, ws.Evaluate("MAXA({\"ABC\",TRUE})")); + ws.Cell("B3").Value = Blank.Value; + Assert.AreEqual(-10, ws.Evaluate("MAX(-10,-12,-15,B3)")); + ws.Cell("B3").Value = 0; + Assert.AreEqual(0, ws.Evaluate("MAXA(-10,-12,-15,B3)")); + + // Array logical arguments are ignored + Assert.AreEqual(-2, workbook.Evaluate("MAXA({-2, TRUE, TRUE, FALSE, FALSE})")); + + // Array text arguments are ignored + Assert.AreEqual(-2, workbook.Evaluate("MAXA({-4, -2, \"hello\", \"10\" })")); + + // Reference argument only counts logical as 0/1, text as 0 and ignores blanks. + ws.Cell("A1").Value = Blank.Value; + ws.Cell("A2").Value = true; + ws.Cell("A3").Value = "100"; + ws.Cell("A4").Value = "hello"; + ws.Cell("A5").Value = -4; + Assert.AreEqual(1, ws.Evaluate("MAXA(A1:A5)")); + Assert.AreEqual(0, ws.Evaluate("MAXA(A3:A5)")); + + AssertScalarToNumberConversion("MAXA", 1); + AssertAnyErrorIsPropagated("MAXA"); + } + + [Test] + public void Median_with_area_without_numeric_values_returns_error() { - //Arrange var ws = workbook.Worksheets.First(); - //Act - Assert - Assert.Throws(() => - { - ws.Evaluate("AVERAGE(D3:D45)"); - }); + // Column D contains names of regions + Assert.AreEqual(XLError.NumberInvalid, ws.Evaluate("MEDIAN(D3:D45)")); } [Test] @@ -361,7 +677,7 @@ public void Median_EvenCountOfCellRange_ReturnsAverageOfTwoElementsInMiddleOfSor var ws = workbook.Worksheets.First(); //Act - double value = ws.Evaluate("MEDIAN(I3:I10)").CastTo(); + var value = (double)ws.Evaluate("MEDIAN(I3:I10)"); //Assert Assert.AreEqual(244.225, value, tolerance); @@ -371,7 +687,7 @@ public void Median_EvenCountOfCellRange_ReturnsAverageOfTwoElementsInMiddleOfSor public void Median_EvenCountOfManualNumbers_ReturnsAverageOfTwoElementsInMiddleOfSortedList() { //Act - double value = workbook.Evaluate("MEDIAN(-27.5,93.93,64.51,-70.56)").CastTo(); + var value = (double)workbook.Evaluate("MEDIAN(-27.5,93.93,64.51,-70.56)"); //Assert Assert.AreEqual(18.505, value, tolerance); @@ -384,7 +700,7 @@ public void Median_OddCountOfCellRange_ReturnsElementInMiddleOfSortedList() var ws = workbook.Worksheets.First(); //Act - double value = ws.Evaluate("MEDIAN(I3:I11)").CastTo(); + var value = (double)ws.Evaluate("MEDIAN(I3:I11)"); //Assert Assert.AreEqual(189.05, value, tolerance); @@ -394,62 +710,248 @@ public void Median_OddCountOfCellRange_ReturnsElementInMiddleOfSortedList() public void Median_OddCountOfManualNumbers_ReturnsElementInMiddleOfSortedList() { //Act - double value = workbook.Evaluate("MEDIAN(-27.5,93.93,64.51,-70.56,101.65)").CastTo(); + var value = (double)workbook.Evaluate("MEDIAN(-27.5,93.93,64.51,-70.56,101.65)"); //Assert Assert.AreEqual(64.51, value, tolerance); } + [Test] + public void Median_uses_only_numbers() + { + var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // Examples from specification + Assert.AreEqual(15, ws.Evaluate("MEDIAN(10, 20)")); + Assert.AreEqual(-1.05, ws.Evaluate("MEDIAN(-3.5, 1.4, 6.9, -4.5)")); + Assert.AreEqual(-1.05, ws.Evaluate("MEDIAN({ -3.5,1.4,6.9},-4.5)")); + + // Reference with no value will return error + ws.Cell("A1").Value = Blank.Value; + Assert.AreEqual(XLError.NumberInvalid, ws.Evaluate("MEDIAN(A1)")); + + // Array non-number values are ignored + Assert.AreEqual(7, ws.Evaluate("MEDIAN({7, TRUE,FALSE,\"1\"})")); + + // Only numbers are used from reference, rest is ignored + ws.Cell("A1").Value = Blank.Value; + ws.Cell("A2").Value = true; + ws.Cell("A3").Value = "100"; + ws.Cell("A4").Value = "hello"; + ws.Cell("A5").Value = 0; + ws.Cell("A6").Value = 4; + ws.Cell("A7").Value = 5; + Assert.AreEqual(4, ws.Evaluate("MEDIAN(A1:A7)")); + + AssertScalarToNumberConversion("MEDIAN", 0.5); + AssertAnyErrorIsPropagated("MEDIAN"); + } + [Test] public void Min() { var ws = workbook.Worksheets.First(); - int value; - value = ws.Evaluate(@"=MIN(D3:D45)").CastTo(); - Assert.AreEqual(0, value); - - value = ws.Evaluate(@"=MIN(G3:G45)").CastTo(); - Assert.AreEqual(2, value); - - value = ws.Evaluate(@"=MIN(G:G)").CastTo(); - Assert.AreEqual(2, value); + Assert.AreEqual(0, ws.Evaluate("MIN(D3:D45)")); + Assert.AreEqual(2, ws.Evaluate("MIN(G3:G45)")); + Assert.AreEqual(2, ws.Evaluate("MIN(G:G)")); + Assert.AreEqual(2, workbook.Evaluate("MIN(Data!G:G)")); + + // Array non-number arguments are ignored + Assert.AreEqual(5, workbook.Evaluate("MIN({5, TRUE, FALSE, \"1\", \"hello\"})")); + + // Reference non-number arguments are ignored + ws.Cell("Z1").Value = Blank.Value; + ws.Cell("Z2").Value = "1"; + ws.Cell("Z3").Value = "hello"; + ws.Cell("Z4").Value = false; + ws.Cell("Z5").Value = true; + ws.Cell("Z6").Value = 5; + Assert.AreEqual(5, ws.Evaluate("MIN(Z1:Z6)")); + + // If there is no value, return 0 + Assert.AreEqual(0, ws.Evaluate("MIN({\"hello\"})")); + + AssertScalarToNumberConversion("MIN", 0); + AssertAnyErrorIsPropagated("MIN"); + } - value = workbook.Evaluate(@"=MIN(Data!G:G)").CastTo(); - Assert.AreEqual(2, value); + [Test] + public void MinA() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // Examples from specification + Assert.AreEqual(-3.5, ws.Evaluate("MINA(10.4, -3.5, 12.6)")); + Assert.AreEqual(-3.5, ws.Evaluate("MINA(10.4, {-3.5, 12.6})")); + Assert.AreEqual(0, ws.Evaluate("MINA({\"ABC\", TRUE})")); + ws.Cell("B3").Value = Blank.Value; + Assert.AreEqual(10, ws.Evaluate("MINA(10, 12, 15, B3)")); + ws.Cell("B3").Value = "Text"; + Assert.AreEqual(0, ws.Evaluate("MINA(10, 12, 15, B3)")); + + // Blanks in references are ignored and when MINA doesn't have any values, it returns 0 + ws.Cell("A1").Value = Blank.Value; + Assert.AreEqual(0, ws.Evaluate("MINA(A1)")); + + // Array logical arguments are ignored + Assert.AreEqual(2, wb.Evaluate("MINA({2, TRUE, TRUE, FALSE, FALSE})")); + + // Array text arguments are ignored + Assert.AreEqual(2, wb.Evaluate("MINA({4, 2, \"hello\", \"1\"})")); + + // Reference argument only counts logical as 0/1, text as 0 and ignores blanks. + ws.Cell("A1").Value = Blank.Value; // Ignores + ws.Cell("A2").Value = true; // Includes + ws.Cell("A3").Value = "100"; // Considers 0 + ws.Cell("A4").Value = "hello"; // Considers 0 + ws.Cell("A5").Value = -4; // Included + Assert.AreEqual(1, ws.Evaluate("MINA(A1:A2)")); + Assert.AreEqual(0, ws.Evaluate("MINA(A1:A3)")); + Assert.AreEqual(-4, ws.Evaluate("MINA(A1:A5)")); + + AssertScalarToNumberConversion("MINA", 0); + AssertAnyErrorIsPropagated("MINA"); } [Test] + [DefaultFloatingPointTolerance(tolerance)] public void StDev() { var ws = workbook.Worksheets.First(); - double value; - Assert.That(() => ws.Evaluate(@"=STDEV(D3:D45)"), Throws.TypeOf()); - value = ws.Evaluate(@"=STDEV(H3:H45)").CastTo(); + // Only non-convertible text in D column, thus less than 2 samples will return error + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("STDEV(D3:D45)")); + + // Calculate StDev from numeric values (reference contains only numbers) + var value = (double)ws.Evaluate("STDEV(H3:H45)"); Assert.AreEqual(47.34511769, value, tolerance); - value = ws.Evaluate(@"=STDEV(H:H)").CastTo(); + // Ignores text values in the H column and only uses numeric ones, same as reference with only number + value = (double)ws.Evaluate("STDEV(H:H)"); Assert.AreEqual(47.34511769, value, tolerance); - value = workbook.Evaluate(@"=STDEV(Data!H:H)").CastTo(); + value = (double)workbook.Evaluate("STDEV(Data!H:H)"); Assert.AreEqual(47.34511769, value, tolerance); + + // Need at least two values, otherwise returns error + Assert.AreEqual(XLError.DivisionByZero, workbook.Evaluate("STDEV(1)")); + Assert.AreEqual(0, workbook.Evaluate("STDEV(0, 0)")); + + // Array non-number arguments are ignored + Assert.AreEqual(0.707106781, (double)workbook.Evaluate("STDEV({0, 1, \"Hello\", FALSE, TRUE})"), tolerance); + + // Reference argument only uses number, ignores blanks, logical and text + ws.Cell("Z1").Value = Blank.Value; + ws.Cell("Z2").Value = true; + ws.Cell("Z3").Value = "100"; + ws.Cell("Z4").Value = "hello"; + ws.Cell("Z5").Value = 0; + ws.Cell("Z6").Value = 1; + Assert.AreEqual(0.707106781, (double)ws.Evaluate("STDEV(Z1:Z6)"), tolerance); + + AssertScalarToNumberConversion("STDEV", 0.707106781); + AssertAnyErrorIsPropagated("STDEV"); + } + + [Test] + [DefaultFloatingPointTolerance(tolerance)] + public void StDevA() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // Example from specification + Assert.AreEqual(23.72902583, (double)ws.Evaluate("STDEVA(123, 134, 143, 173, 112, 109)")); + + // Array non-number arguments are ignored + Assert.AreEqual(0.707106781, (double)ws.Evaluate("STDEVA({0, 1, \"9\", \"Hello\", FALSE, TRUE})")); + + // Reference argument ignores blanks, uses numbers, logical and text as zero + ws.Cell("A1").Value = Blank.Value; // Ignore + ws.Cell("A2").Value = true; // Include + ws.Cell("A3").Value = ""; // Consider 0 + ws.Cell("A4").Value = "100"; // Consider 0 + ws.Cell("A5").Value = "hello"; // Consider 0 + ws.Cell("A6").Value = 5; + ws.Cell("A7").Value = 7; + Assert.AreEqual(3.060501048, (double)ws.Evaluate("STDEVA(A1:A7)")); + + // Need at least one sample, otherwise returns error (text in array is ignored) + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("STDEVA({\"hello\"})")); + + AssertScalarToNumberConversion("STDEVA", 0.707106781); + AssertAnyErrorIsPropagated("STDEVA"); } [Test] public void StDevP() { var ws = workbook.Worksheets.First(); - double value; - Assert.That(() => ws.Evaluate(@"=STDEVP(D3:D45)"), Throws.InvalidOperationException); - value = ws.Evaluate(@"=STDEVP(H3:H45)").CastTo(); - Assert.AreEqual(46.79135458, value, tolerance); + // Example from specification + Assert.AreEqual(21.66153785, (double)ws.Evaluate("STDEVP(123, 134, 143, 173, 112, 109)"), tolerance); + + // Column D contains only region names (non-convertible text), thus reference contains less than 1 sample that is required + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("STDEVP(D3:D45)")); + + // Calculate StDevP from numeric values (reference contains only numbers) + Assert.AreEqual(46.79135458, (double)ws.Evaluate("STDEVP(H3:H45)"), tolerance); - value = ws.Evaluate(@"=STDEVP(H:H)").CastTo(); - Assert.AreEqual(46.79135458, value, tolerance); + // StDevP ignores text values/blanks in the H column and only uses numeric ones, the result is same as the reference above that contains only numbers + Assert.AreEqual(46.79135458, (double)ws.Evaluate("STDEVP(H:H)"), tolerance); - value = workbook.Evaluate(@"=STDEVP(Data!H:H)").CastTo(); - Assert.AreEqual(46.79135458, value, tolerance); + Assert.AreEqual(46.79135458, (double)workbook.Evaluate("STDEVP(Data!H:H)"), tolerance); + + // If sample size is 0, return error + Assert.AreEqual(XLError.DivisionByZero, workbook.Evaluate("STDEVP({TRUE})")); + Assert.AreEqual(0, workbook.Evaluate("STDEVP(100)")); + + // Array non-number arguments are ignored + Assert.AreEqual(0.5, workbook.Evaluate("STDEVP({0, 1, \"Hello\", FALSE, TRUE})")); + + // Reference argument only uses numbers, ignores blanks, logical and text + ws.Cell("Z1").Value = Blank.Value; + ws.Cell("Z2").Value = true; + ws.Cell("Z3").Value = "100"; + ws.Cell("Z4").Value = "hello"; + ws.Cell("Z5").Value = 0; + ws.Cell("Z6").Value = 1; + Assert.AreEqual(0.5, ws.Evaluate("STDEVP(Z1:Z6)")); + + AssertScalarToNumberConversion("STDEVP", 0.5); + AssertAnyErrorIsPropagated("STDEVP"); + } + + [Test] + [DefaultFloatingPointTolerance(tolerance)] + public void StDevPA() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // Example from specification + Assert.AreEqual(21.66153785, (double)ws.Evaluate("STDEVPA(123, 134, 143, 173, 112, 109)")); + + // Array non-number arguments are ignored + Assert.AreEqual(0.5, (double)ws.Evaluate("STDEVPA({0, 1, \"9\", \"Hello\", FALSE, TRUE})")); + + // Reference argument ignores blanks, uses numbers, logical and text as zero + ws.Cell("A1").Value = Blank.Value; // Ignore + ws.Cell("A2").Value = true; // Include + ws.Cell("A3").Value = ""; // Consider 0 + ws.Cell("A4").Value = "100"; // Consider 0 + ws.Cell("A5").Value = "hello"; // Consider 0 + ws.Cell("A6").Value = 5; + ws.Cell("A7").Value = 7; + Assert.AreEqual(2.793842436, (double)ws.Evaluate("STDEVPA(A1:A7)")); + + // Need at least one sample, otherwise returns error (text in array is ignored) + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("STDEVPA({\"hello\"})")); + + AssertScalarToNumberConversion("STDEVPA", 0.5); + AssertAnyErrorIsPropagated("STDEVPA"); } [TestCase(@"=SUMIF(A1:A10, 1, A1:A10)", 1)] @@ -468,6 +970,28 @@ public void SumIf_MixedData(string formula, double expected) Assert.AreEqual(expected, ws.Evaluate(formula)); } + [Test] + public void SumIf_specification_examples() + { + // Test examples from specification. + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = 3; + ws.Cell("B1").Value = 10; + ws.Cell("C1").Value = 7; + ws.Cell("D1").Value = 10; + + Assert.AreEqual(20, ws.Evaluate("SUMIF(A1:D1,\"=10\")")); + Assert.AreEqual(27, ws.Evaluate("SUMIF(A1:D1,\">5\")")); + Assert.AreEqual(10, ws.Evaluate("SUMIF(A1:D1,\"<>10\")")); + + ws.Cell("A2").Value = "apples"; + ws.Cell("B2").Value = "melons"; + ws.Cell("C2").Value = 10; + ws.Cell("D2").Value = 15; + Assert.AreEqual(10, ws.Evaluate("SUMIF(A2:B2,\"*es\",C2:D2)")); + } + [Test] [TestCase("COUNT(G:I,G:G,H:I)", 258d, Description = "COUNT overlapping columns")] [TestCase("COUNT(6:8,6:6,7:8)", 30d, Description = "COUNT overlapping rows")] @@ -488,13 +1012,10 @@ public void TallySkipsEmptyCells(string formulaA1, double expectedResult) //Let's pre-initialize cells we need so they didn't affect the result ws.Range("A1:J45").Style.Fill.BackgroundColor = XLColor.Amber; ws.Cell("ZZ1000").Value = 1; - int initialCount = (ws as XLWorksheet).Internals.CellsCollection.Count; var actualResult = (double)ws.Evaluate(formulaA1); - int cellsCount = (ws as XLWorksheet).Internals.CellsCollection.Count; Assert.AreEqual(expectedResult, actualResult, tolerance); - Assert.AreEqual(initialCount, cellsCount); } } @@ -502,35 +1023,194 @@ public void TallySkipsEmptyCells(string formulaA1, double expectedResult) public void Var() { var ws = workbook.Worksheets.First(); - double value; - Assert.That(() => ws.Evaluate(@"=VAR(D3:D45)"), Throws.InvalidOperationException); - value = ws.Evaluate(@"=VAR(H3:H45)").CastTo(); - Assert.AreEqual(2241.560169, value, tolerance); + // Example from specification + Assert.AreEqual(2683.2, ws.Evaluate("VAR(1202,1220,1323,1254,1302)")); + + // Only non-convertible text in D column, thus less than 2 samples. + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("VAR(D3:D45)")); + + // Calculate VAR from numeric values (reference contains only numbers) + Assert.AreEqual(2241.560169, (double)ws.Evaluate("VAR(H3:H45)"), tolerance); + + // Ignores text values in the H column and only uses numeric ones, same as reference with only number + Assert.AreEqual(2241.560169, (double)ws.Evaluate("VAR(H:H)"), tolerance); + Assert.AreEqual(2241.560169, (double)workbook.Evaluate("VAR(Data!H:H)"), tolerance); - value = ws.Evaluate(@"=VAR(H:H)").CastTo(); - Assert.AreEqual(2241.560169, value, tolerance); + // Need at least two samples, otherwise returns error + Assert.AreEqual(XLError.DivisionByZero, workbook.Evaluate("VAR({\"hello\"})")); + Assert.AreEqual(XLError.DivisionByZero, workbook.Evaluate("VAR(5)")); + Assert.AreEqual(0.5, workbook.Evaluate("VAR(5, 6)")); - value = workbook.Evaluate(@"=VAR(Data!H:H)").CastTo(); - Assert.AreEqual(2241.560169, value, tolerance); + // Array non-number arguments are ignored + Assert.AreEqual(0.5, workbook.Evaluate("VAR({0, 1, \"Hello\", FALSE, TRUE})")); + + // Reference argument only uses number, ignores blanks, logical and text + ws.Cell("Z1").Value = Blank.Value; + ws.Cell("Z2").Value = true; + ws.Cell("Z3").Value = "100"; + ws.Cell("Z4").Value = "hello"; + ws.Cell("Z5").Value = 0; + ws.Cell("Z6").Value = 1; + Assert.AreEqual(0.5, ws.Evaluate("VAR(Z1:Z6)")); + + AssertScalarToNumberConversion("VAR", 0.5); + AssertAnyErrorIsPropagated("VAR"); + } + + [Test] + [DefaultFloatingPointTolerance(tolerance)] + public void VarA() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // Example from specification + Assert.AreEqual(2683.2, ws.Evaluate("VARA(1202, 1220, 1323, 1254, 1302)")); + + // Array non-number arguments are ignored + Assert.AreEqual(2, ws.Evaluate("VARA({5, 7, \"9\", \"Hello\", FALSE, TRUE})")); + + // Reference argument ignores blanks, uses numbers, logical and text as zero + ws.Cell("A1").Value = Blank.Value; // Ignore + ws.Cell("A2").Value = true; // Include + ws.Cell("A3").Value = ""; // Consider 0 + ws.Cell("A4").Value = "100"; // Consider 0 + ws.Cell("A5").Value = "hello"; // Consider 0 + ws.Cell("A6").Value = 5; + ws.Cell("A7").Value = 7; + Assert.AreEqual(9.366666667, (double)ws.Evaluate("VARA(A1:A7)")); + + // Need at least one sample, otherwise returns error (text in array is ignored) + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("VARA({\"hello\"})")); + + AssertScalarToNumberConversion("VARA", 0.5); + AssertAnyErrorIsPropagated("VARA"); } [Test] public void VarP() { var ws = workbook.Worksheets.First(); - double value; - Assert.That(() => ws.Evaluate(@"=VARP(D3:D45)"), Throws.InvalidOperationException); - value = ws.Evaluate(@"=VARP(H3:H45)").CastTo(); - Assert.AreEqual(2189.430863, value, tolerance); + // Example from specification + Assert.AreEqual(2146.56, (double)ws.Evaluate("VARP(1202,1220,1323,1254,1302)"), tolerance); + + // Only non-convertible text in D column, thus less than 1 sample. + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("VARP(D3:D45)")); + + // Calculate VARP from numeric values (reference contains only numbers) + Assert.AreEqual(2189.430863, (double)ws.Evaluate("VARP(H3:H45)"), tolerance); + + // Ignores text values in the H column and only uses numeric ones, same as reference with only number + Assert.AreEqual(2189.430863, (double)ws.Evaluate("VARP(H:H)"), tolerance); + Assert.AreEqual(2189.430863, (double)workbook.Evaluate("VARP(Data!H:H)"), tolerance); + + // Need at least one sample, otherwise returns error + Assert.AreEqual(XLError.DivisionByZero, workbook.Evaluate("VARP({\"hello\"})")); + Assert.AreEqual(0, workbook.Evaluate("VARP(5)")); + + // Array non-number arguments are ignored + Assert.AreEqual(0.25, workbook.Evaluate("VARP({0, 1, \"Hello\", FALSE, TRUE})")); + + // Reference argument only uses number, ignores blanks, logical and text + ws.Cell("Z1").Value = Blank.Value; + ws.Cell("Z2").Value = true; + ws.Cell("Z3").Value = "100"; + ws.Cell("Z4").Value = "hello"; + ws.Cell("Z5").Value = 0; + ws.Cell("Z6").Value = 1; + Assert.AreEqual(0.25, ws.Evaluate("VARP(Z1:Z6)")); + + AssertScalarToNumberConversion("VARP", 0.25); + AssertAnyErrorIsPropagated("VARP"); + } + + [Test] + [DefaultFloatingPointTolerance(tolerance)] + public void VarPA() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // Example from specification + Assert.AreEqual(2146.56, ws.Evaluate("VARPA(1202, 1220, 1323, 1254, 1302)")); + + // Array non-number arguments are ignored + Assert.AreEqual(1, ws.Evaluate("VARPA({5, 7, \"9\", \"Hello\", FALSE, TRUE})")); + + // Reference argument ignores blanks, uses numbers, logical and text as zero + ws.Cell("A1").Value = Blank.Value; // Ignore + ws.Cell("A2").Value = true; // Include + ws.Cell("A3").Value = ""; // Consider 0 + ws.Cell("A4").Value = "100"; // Consider 0 + ws.Cell("A5").Value = "hello"; // Consider 0 + ws.Cell("A6").Value = 5; + ws.Cell("A7").Value = 7; + Assert.AreEqual(7.805555556, (double)ws.Evaluate("VARPA(A1:A7)")); + + // Need at least one sample, otherwise returns error (text in array is ignored) + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("VARPA({\"hello\"})")); + + AssertScalarToNumberConversion("VARPA", 0.25); + AssertAnyErrorIsPropagated("VARPA"); + } + + [Test] + public void Large() + { + var ws = workbook.Worksheet("Data"); + var value = ws.Evaluate("LARGE(G1:G45, 1)"); + Assert.AreEqual(96, value); + + value = ws.Evaluate("LARGE(G1:G45, 7)"); + Assert.AreEqual(87, value); + + value = ws.Evaluate("LARGE(G1:G45, 0)"); + Assert.AreEqual(XLError.NumberInvalid, value); + + value = ws.Evaluate("LARGE(G1:G45, -1)"); + Assert.AreEqual(XLError.NumberInvalid, value); - value = ws.Evaluate(@"=VARP(H:H)").CastTo(); - Assert.AreEqual(2189.430863, value, tolerance); + value = ws.Evaluate("LARGE(G1:G45,\"test\")"); + Assert.AreEqual(XLError.IncompatibleValue, value); - value = workbook.Evaluate(@"=VARP(Data!H:H)").CastTo(); - Assert.AreEqual(2189.430863, value, tolerance); + value = ws.Evaluate("LARGE(C:C,7)"); + Assert.AreEqual(42623, value); + + value = ws.Evaluate("LARGE(D:D,7)"); + Assert.AreEqual(XLError.NumberInvalid, value); + + ws = workbook.Worksheet("MixedData"); + + value = ws.Evaluate("LARGE(A1:A7,6)"); + Assert.AreEqual(XLError.NumberInvalid, value); + + // Ignores non-numbers. + value = ws.Evaluate("LARGE(A1:A7,5)"); + Assert.AreEqual(1, value); + + // Accepts non-area references. + value = ws.Evaluate("LARGE((A1:A2,A4:A6),2)"); + Assert.AreEqual(3, value); + + // Errors are returned. + value = ws.Evaluate("LARGE({ 1, 2, #N/A }, 1)"); + Assert.AreEqual(XLError.NoValueAvailable, value); + + // Uses ceiling logic for number (1.1 -> 2) + can use arrays. + value = ws.Evaluate("LARGE({ 1, 2 }, 1.1)"); + Assert.AreEqual(1, value); + + // If a scalar number-like value supplied, it is converted to number. + value = ws.Evaluate("LARGE(\"1 1/2\", 1)"); + Assert.AreEqual(1.5, value); + + // When the scalar can't be converted, return conversion error. + value = ws.Evaluate("LARGE(\"test\", 1)"); + Assert.AreEqual(XLError.IncompatibleValue, value); } + private XLWorkbook SetupWorkbook() { var wb = new XLWorkbook(); @@ -592,5 +1272,44 @@ private XLWorkbook SetupWorkbook() return wb; } + + private static void AssertScalarToNumberConversion(string functionName, double result) + { + // Scalar blank is converted to 0 + Assert.AreEqual(result, (double)XLWorkbook.EvaluateExpr($"{functionName}(IF(TRUE,), 1)")); + + // Scalar logical is converted to a number + Assert.AreEqual(result, (double)XLWorkbook.EvaluateExpr($"{functionName}(FALSE, TRUE)")); + Assert.AreEqual(result, (double)XLWorkbook.EvaluateExpr($"{functionName}(0, TRUE)")); + Assert.AreEqual(result, (double)XLWorkbook.EvaluateExpr($"{functionName}(FALSE, 1)")); + + // Scalar text is converted to a number + Assert.AreEqual(result, (double)XLWorkbook.EvaluateExpr($"{functionName}(\"0\", \"1\")")); + Assert.AreEqual(result, (double)XLWorkbook.EvaluateExpr($"{functionName}(\"1\", \"0 0/2\")")); + + // Scalar text that is not convertible returns error + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"{functionName}(5, \"Hello\")")); + } + + /// + /// Assert that a function propagates any error, whether from scalar, array or reference argument. + /// + /// Name of a function that accepts any value as argument. + private static void AssertAnyErrorIsPropagated(string functionName) + { + // Scalar error is propagated + Assert.AreEqual(XLError.NullValue, XLWorkbook.EvaluateExpr($"{functionName}(1, #NULL!)")); + + // Array error is propagated + Assert.AreEqual(XLError.NullValue, XLWorkbook.EvaluateExpr($"{functionName}({{1, #NULL!}})")); + + // Reference error is propagated + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("B1").Value = XLError.NoValueAvailable; + ws.Cell("B2").Value = 1; + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate($"{functionName}(B1)")); + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate($"{functionName}(B1:B2)")); + } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/StructuredReferenceTests.cs b/ClosedXML.Tests/Excel/CalcEngine/StructuredReferenceTests.cs new file mode 100644 index 000000000..15b4e020d --- /dev/null +++ b/ClosedXML.Tests/Excel/CalcEngine/StructuredReferenceTests.cs @@ -0,0 +1,206 @@ +#nullable enable + +using System.Collections.Generic; +using System.Data; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.CalcEngine +{ + /// + /// Test cases per [MS-OI29500] 3.2.3.1.1 Structure References. + /// + [TestFixture] + internal class StructuredReferenceTests + { + private static IEnumerable TestCases + { + get + { + // `table-name[]` refers to all cells in table-name except Header Row and Total Row. + // `table-name[#Data]` refers to all table-name’s cells except Header Row and Total Row. It is equivalent to the form table-name[]. + yield return new object[] { "TableName[]", "E8:H10", "E8:H10" }; + yield return new object[] { "TableName[#Data]", "E8:H10", "E8:H10" }; + yield return new object[] { "tableName[]", "E8:H10", "E8:H10" }; + + // table-name[#Headers] refers to all cells in table-name’s Header Row. + yield return new object[] { "TableName[#Headers]", "E7:H7", "E7:H7" }; + + // `table-name[#Total Row] refers to all cells in the table-name’s Total Row + // No totals -> no area -> #REF! + yield return new object[] { "TableName[#Totals]", "E11:H11", "#REF!" }; + + // `table-name[#All]` refers to the entire table area. table-name[#All] is the union of + // table-name[#Headers], table-name[#Data], and table-name[#Total Row] + yield return new object[] { "TableName[#All]", "E7:H11", "E7:H10" }; + + // table-name[column-name] refers to all cells in the column named column-name except + // the cells from Header Row and Total Row. + // table-name[[column-name]] refers to all cells in the column named column-name except + // the cells from Header Row and Total Row. + // table-name[[#Data],[column-name]] is equivalent to table-name[column-name] + yield return new object[] { "TableName[Second]", "F8:F10", "F8:F10" }; + yield return new object[] { "TableName[second]", "F8:F10", "F8:F10" }; + yield return new object[] { "TableName[[Second]]", "F8:F10", "F8:F10" }; + yield return new object[] { "TableName[[#Data],[Second]]", "F8:F10", "F8:F10" }; + + // table-name[[column-name1]:[column-name2]] refers to all cells from column named column-name1 + // through column named column-name2 except the cells from Header Row and Total Row. + yield return new object[] { "TableName[[Second]:[Fourth]]", "F8:H10", "F8:H10" }; + yield return new object[] { "TableName[[Fourth]:[Second]]", "F8:H10", "F8:H10" }; + yield return new object[] { "tableName[[second]:[fourth]]", "F8:H10", "F8:H10" }; + + // table-name[[keyword],[column-name]], where keyword is one of #Headers, #Total Row, #Data, #All, + // refers to the intersection of the area defined by table-name[keyword] and all cells from the column + // named column-name. + yield return new object[] { "TableName[[#Headers],[Second]]", "F7:F7", "F7:F7" }; + yield return new object[] { "TableName[[#Totals],[Second]]", "F11:F11", "#REF!" }; + yield return new object[] { "TableName[[#Data],[Second]]", "F8:F10", "F8:F10" }; + yield return new object[] { "TableName[[#All],[Second]]", "F7:F11", "F7:F10" }; + + // table-name[[keyword],[column-name1]:[column-name2]], where keyword is one of #Headers, #Total + // Row, #Data, #All, refers to the intersection of the area defined by table-name[keyword] and all cells + // from the table from column named column - name1 through column named column-name2. + yield return new object[] { "TableName[[#Headers],[Second]:[Fourth]]", "F7:H7", "F7:H7" }; + yield return new object[] { "TableName[[#Totals],[Second]:[Fourth]]", "F11:H11", "#REF!" }; + yield return new object[] { "TableName[[#Data],[Second]:[Fourth]]", "F8:H10", "F8:H10" }; + yield return new object[] { "TableName[[#All],[Second]:[Fourth]]", "F7:H11", "F7:H10" }; + + // table-name[[#Headers],[#Data],[column-name]] is the union of table-name[[#Headers],[column-name]] + // and table-name[[#Data],[column-name]] + yield return new object[] { "TableName[[#Headers],[#Data],[Third]]", "G7:G10", "G7:G10" }; + + // table-name[[#Headers],[#Data],[column-name]] is the union of table-name[[#Headers],[column-name]] + // and table-name[[#Data],[column-name]] + yield return new object[] { "TableName[[#Data],[#Totals],[Third]]", "G8:G11", "G8:G10" }; + + // table-name[[#Headers],[#Data],[column-name1]:[column-name2]] is the union of + // table-name[[#Headers], [column-name1]:[column-name2]] and table-name[[#Data], + // [column-name1]:[column - name2]] + yield return new object[] { "TableName[[#Headers],[#Data],[Third]:[Fourth]]", "G7:H10", "G7:H10" }; + yield return new object[] { "TableName[[#Headers],[#Data],[Fourth]:[Third]]", "G7:H10", "G7:H10" }; + + // table-name[[#Data],[#Total Row], [column-name1]:[column-name2]] is the union of + // table-name[[#Data], [column-name1]:[column-name2]] and table-name[[#Total Row], + // [column-name1]:[column-name2]] + yield return new object[] { "TableName[[#Data],[#Totals],[Second]:[Third]]", "F8:G11", "F8:G10" }; + yield return new object[] { "TableName[[#Data],[#Totals],[Third]:[Second]]", "F8:G11", "F8:G10" }; + + // Incorrect name of table or column returns #REF! + yield return new object[] { "WrongName[]", "#REF!", "#REF!" }; + yield return new object[] { "TableName[[NonExistentCol]]", "#REF!", "#REF!" }; + yield return new object[] { "TableName[[First]:[NonExistentCol]]", "#REF!", "#REF!" }; + yield return new object[] { "TableName[[NonExistentCol]:[Fourth]]", "#REF!", "#REF!" }; + yield return new object[] { "TableName[[NonExistent1]:[NonExistent2]]", "#REF!", "#REF!" }; + } + } + + [TestCaseSource(nameof(TestCases))] + public void Structured_reference_is_resolved_to_reference( + string structuredReference, + string expectedWithTotals, + string expectedWithoutTotals) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + var table = Add4X3Table(ws, "E7"); + table.ShowTotalsRow = true; + + AssertRange(structuredReference, expectedWithTotals, ws); + + table.ShowTotalsRow = false; + AssertRange(structuredReference, expectedWithoutTotals, ws); + } + + [Test] + public void This_row_of_column_of_table_reference() + { + // table-name[[#This Row],[column-name]] refers to the cell in the intersection of table-name[column- + // name] and the current row; for example, the row of the cell that contains the formula with the + // structure reference. table-name[[#This Row],[column-name1]:[column-name2]]refers to the cells in + // the intersection of table-name[[column - name]:[column - name2]] and the current row; for example, + // the row of the cell that contains the formula with such structure reference.These two forms allow + //formulas to perform implicit intersection using structure references. + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + Add4X3Table(ws, "E7"); + + const string columnFormula = "TableName[[#This Row],[Second]]"; + AssertRange(columnFormula, "F8:F8", ws, "D8"); + AssertRange(columnFormula, "F10:F10", ws, "D10"); + + const string columnsFormula = "TableName[[#This Row],[Second]:[Third]]"; + AssertRange(columnsFormula, "F8:G8", ws, "D8"); + AssertRange(columnsFormula, "F10:G10", ws, "D10"); + } + + [TestCase("TableName[[#This Row],[Second]]")] + [TestCase("TableName[[#This Row],[Second]:[Fourth]]")] + [TestCase("TableName[[#This Row],[Fourth]:[Second]]")] + public void This_row_outside_data_area_of_table_reference(string formula) + { + // table-name[[#This Row],[column-name]] and table-name[[#This Row],[column-name1]:[column-name2]] + // return #VALUE! when the row is not in data range of rows. + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + var table = Add4X3Table(ws, "E7"); + table.ShowTotalsRow = true; + + // Right above header row + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate(formula, "D6")); + + // Header row + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate(formula, "D7")); + + // Whether there is a totals row or not, the result is #VALUE! + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate(formula, "D11")); + + table.ShowTotalsRow = false; + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate(formula, "D11")); + } + + private static IXLTable Add4X3Table(IXLWorksheet ws, string origin) + { + var dt = new DataTable("TableName"); + dt.Columns.AddRange(new[] + { + new DataColumn("First", typeof(int)), + new DataColumn("Second", typeof(int)), + new DataColumn("Third", typeof(int)), + new DataColumn("Fourth", typeof(int)), + }); + + for (var i = 1; i <= 3; ++i) + { + var row = dt.NewRow(); + row["First"] = i; + row["Second"] = i * 10; + row["Third"] = i * 100; + row["Fourth"] = i * 1000; + dt.Rows.Add(row); + } + + var table = ws.Cell(origin).InsertTable(dt, "TableName"); + table.SetShowTotalsRow(true); + return table; + } + + private static void AssertRange(string structureReference, string expectedArea, IXLWorksheet ws, string? formulaAddress = null) + { + if (expectedArea == "#REF!") + { + Assert.AreEqual(XLError.CellReference, ws.Evaluate(structureReference, formulaAddress)); + return; + } + + var expected = XLSheetRange.Parse(expectedArea); + Assert.AreEqual(expected.LeftColumn, ws.Evaluate($"COLUMN({structureReference})", formulaAddress)); + Assert.AreEqual(expected.TopRow, ws.Evaluate($"ROW({structureReference})", formulaAddress)); + Assert.AreEqual(expected.Height, ws.Evaluate($"ROWS({structureReference})", formulaAddress)); + Assert.AreEqual(expected.Width, ws.Evaluate($"COLUMNS({structureReference})", formulaAddress)); + } + } +} diff --git a/ClosedXML.Tests/Excel/CalcEngine/TextTests.cs b/ClosedXML.Tests/Excel/CalcEngine/TextTests.cs index 1f6841d48..8ba57e1b5 100644 --- a/ClosedXML.Tests/Excel/CalcEngine/TextTests.cs +++ b/ClosedXML.Tests/Excel/CalcEngine/TextTests.cs @@ -1,169 +1,302 @@ using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine; -using ClosedXML.Excel.CalcEngine.Exceptions; using NUnit.Framework; using System; using System.Globalization; -using System.Threading; namespace ClosedXML.Tests.Excel.CalcEngine { [TestFixture] + [SetCulture("en-US")] public class TextTests { - [SetUp] - public void Init() + [TestCase(@"ABCDEF123", @"ABCDEF123")] + [TestCase(@"ァィゥェォッャュョヮ", @"ァィゥェォッャュョヮ")] // Small katakana, there is no half wa variant + [TestCase(@"アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン", @"アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン")] + [TestCase("!"#\uff04%&'()*\uff0b,-./0123456789:;\uff1c\uff1d\uff1e?@", @"!""#$%&'()*+,-./0123456789:;<=>?@")] + [TestCase(@"ABCDEFGHIJKLMNOPQRSTUVWXYZ", @"ABCDEFGHIJKLMNOPQRSTUVWXYZ")] + [TestCase("[\]\uff3e_\uff40abcdefghijklmnopqrstuvwxyz{\uff5c}\uff5e", @"[\]^_`abcdefghijklmnopqrstuvwxyz{|}~")] + [TestCase(@"―‘’”、。「」゛゜・ー¥", @"ー`'""、。「」゙゚・ー\")] + public void Asc_converts_fullwidth_characters_to_halfwidth_characters(string input, string expected) { - // Make sure tests run on a deterministic culture - Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US"); + Assert.AreEqual(expected, XLWorkbook.EvaluateExpr($"ASC(\"{input}\")")); } [Test] - public void Char_Empty_Input_String() + public void Char_returns_error_on_empty_string() { - Assert.Throws(() => XLWorkbook.EvaluateExpr(@"Char("""")")); + // Calc engine tries to coerce it to number and fails. It never even reaches the functions. + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"CHAR("""")")); } - [Test] - public void Char_Input_Too_Large() + [TestCase(0)] + [TestCase(256)] + [TestCase(9797)] + public void Char_number_must_be_between_1_and_255(int number) { - Assert.Throws(() => XLWorkbook.EvaluateExpr(@"Char(9797)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"CHAR({number})")); } - [Test] - public void Char_Value() + [TestCase(48, '0')] + [TestCase(97, 'a')] + [TestCase(128, '€')] + [TestCase(138, 'Š')] + [TestCase(169, '©')] + [TestCase(182, '¶')] + [TestCase(230, 'æ')] + [TestCase(255, 'ÿ')] + [TestCase(255.9, 'ÿ')] + public void Char_interprets_number_as_win1252(double number, char expected) { - Object actual = XLWorkbook.EvaluateExpr(@"Char(97)"); - Assert.AreEqual("a", actual); + var actual = XLWorkbook.EvaluateExpr($"CHAR({number})"); + Assert.AreEqual(expected.ToString(), actual); } [Test] - public void Clean_Empty_Input_String() + public void Clean_empty_string_is_empty_string() { - Object actual = XLWorkbook.EvaluateExpr(@"Clean("""")"); - Assert.AreEqual("", actual); + Assert.AreEqual("", XLWorkbook.EvaluateExpr(@"CLEAN("""")")); } [Test] - public void Clean_Value() + public void Clean_removes_control_characters() { - Object actual = XLWorkbook.EvaluateExpr(@"Clean(CHAR(9)&""Monthly report""&CHAR(10))"); + var actual = XLWorkbook.EvaluateExpr(@"CLEAN(CHAR(9)&""Monthly report""&CHAR(10))"); Assert.AreEqual("Monthly report", actual); - actual = XLWorkbook.EvaluateExpr(@"Clean("" "")"); + actual = XLWorkbook.EvaluateExpr(@"CLEAN("" "")"); Assert.AreEqual(" ", actual); } [Test] - public void Code_Empty_Input_String() + public void Code_returns_error_on_empty_string() + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"CODE("""")")); + } + + [TestCase("A", 65)] + [TestCase("BCD", 66)] + [TestCase("€", 128)] + [TestCase("ÿ", 255)] + public void Code_returns_win1252_codepoint_of_first_character(string text, int expected) { - // Todo: more specific exception - ValueException? - Assert.That(() => XLWorkbook.EvaluateExpr(@"Code("""")"), Throws.TypeOf()); + var actual = XLWorkbook.EvaluateExpr($"CODE(\"{text}\")"); + Assert.AreEqual(expected, actual); } [Test] - public void Code_Value() + public void Code_is_inverse_to_char() { - Object actual = XLWorkbook.EvaluateExpr(@"Code(""A"")"); - Assert.AreEqual(65, actual); + for (var i = 1; i < 256; ++i) + Assert.AreEqual(i, XLWorkbook.EvaluateExpr($"CODE(CHAR({i}))")); + } - actual = XLWorkbook.EvaluateExpr(@"Code(""BCD"")"); - Assert.AreEqual(66, actual); + [TestCase("π")] + [TestCase("ب")] + [TestCase("😃")] + [TestCase("♫")] + [TestCase("ひ")] + public void Code_returns_question_mark_code_on_non_win1252_chars(string text) + { + var expected = XLWorkbook.EvaluateExpr("CODE(\"?\")"); + var actual = XLWorkbook.EvaluateExpr($"CODE(\"{text}\")"); + Assert.AreEqual(63, expected); + Assert.AreEqual(expected, actual); } [Test] - public void Concat_Value() + [SetCulture("cs-CZ")] + public void Concat_concatenates_scalar_values() { - Object actual = XLWorkbook.EvaluateExpr(@"Concat(""ABC"", ""123"")"); - Assert.AreEqual("ABC123", actual); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var actual = ws.Evaluate(@"CONCAT(""ABC"",123,TRUE,IF(TRUE,),1.25)"); + Assert.AreEqual("ABC123TRUE1,25", actual); - actual = XLWorkbook.EvaluateExpr(@"Concat("""", ""123"")"); + actual = ws.Evaluate(@"CONCAT("""",""123"")"); Assert.AreEqual("123", actual); - var ws = new XLWorkbook().AddWorksheet(); - - ws.FirstCell().SetValue(20) + ws.FirstCell().SetValue(20.5) .CellBelow().SetValue("AB") - .CellBelow().SetFormulaA1("=DATE(2019,1,1)") - .CellBelow().SetFormulaA1("=CONCAT(A1:A3)"); + .CellBelow().SetFormulaA1("DATE(2019,1,1)") + .CellBelow().SetFormulaA1("CONCAT(A1:A3)"); actual = ws.Cell("A4").Value; - Assert.AreEqual("20AB43466", actual); + Assert.AreEqual("20,5AB43466", actual); + } + + [Test] + public void Concat_concatenates_array_values() + { + Assert.AreEqual("ABC0123456789Z", XLWorkbook.EvaluateExpr(@"CONCAT({""A"",""B"",""C""},{0,1},{2;3},{4,5,6;7,8,9},""Z"")")); + } + + [Test] + public void Concat_concatenates_references() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("C2").InsertData(new object[] + { + ("A", "B", "C"), + (1, 2, 3, 4), + (5, 6, 7, 8), + }); + Assert.AreEqual("ABC12345678AZ", ws.Evaluate("CONCAT(C2:E2,C3:F4,C2,\"Z\")")); + } + + [Test] + public void Concat_has_limit_of_32767_characters() + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("CONCAT(REPT(\"A\",32768))")); + } + + [Test] + public void Concat_accepts_only_area_references() + { + // Only areas are accepted, not unions + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(XLError.IncompatibleValue, ws.Evaluate("CONCAT((C2:E2,C3:F4),C2,\"Z\")")); + } + + [Test] + public void Concat_propagates_error_values() + { + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr(@"CONCAT(""ABC"",#DIV/0!,5)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr(@"CONCAT(""ABC"",{""D"",#DIV/0!,7},5)")); + + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("B5").SetValue(XLError.DivisionByZero).CellBelow().SetValue(5); + Assert.AreEqual(XLError.DivisionByZero, ws.Evaluate("CONCAT(\"ABC\",B5:B6)")); } [Test] - public void Concat_EmptyValue() + public void Concat_treats_blanks_as_empty_string() { - Assert.AreEqual("ABC123", XLWorkbook.EvaluateExpr(@"CONCAT(""ABC"", , ""123"", )")); + Assert.AreEqual("ABC123", XLWorkbook.EvaluateExpr(@"CONCAT(""ABC"",,""123"",)")); } [Test] - public void Concatenate_Value() + [SetCulture("cs-CZ")] + public void Concatenate_concatenates_scalar_values() { - Object actual = XLWorkbook.EvaluateExpr(@"Concatenate(""ABC"", ""123"")"); - Assert.AreEqual("ABC123", actual); + using var wb = new XLWorkbook(); + var actual = wb.Evaluate(@"CONCATENATE(""ABC"",123,4.56,IF(TRUE,),TRUE)"); + Assert.AreEqual("ABC1234,56TRUE", actual); - actual = XLWorkbook.EvaluateExpr(@"Concatenate("""", ""123"")"); + actual = wb.Evaluate(@"CONCATENATE("""",""123"")"); Assert.AreEqual("123", actual); + } + + [Test] + public void Concatenate_with_references() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + ws.Cell("A1").Value = "Hello"; + ws.Cell("B1").Value = "World"; + ws.Cell("C1").FormulaA1 = "CONCATENATE(A1:A2,\" \",B1:B2)"; + ws.Cell("A3").FormulaA1 = "CONCATENATE(A1:A2,\" \",B1:B2)"; - // TODO: Fix CONCATENATE when calling cell references are available - // In Excel, it seems that if the parameter is a range, - // CONCATENATE will return the cell in the range that is the same row number as the calling cell, - // i.e. the cell with the CONCATENATE function. - // Therefore we need reference info about the calling cell to solve this. - // If we can solve ROW(), then we can solve this too. - // For the example below, the calling cell doesn't share any + Assert.AreEqual("Hello World", ws.Evaluate(@"CONCATENATE(A1,"" "",B1)")); - var ws = new XLWorkbook().AddWorksheet(); + // The result on C1 is on the same row (only one intersected cell) means implicit intersection + // results in a one value per intersection and thus correct value. The A3 intersects two cells + // and thus results in #VALUE! error. + Assert.AreEqual("Hello World", ws.Cell("C1").Value); + Assert.AreEqual(XLError.IncompatibleValue, ws.Cell("A3").Value); + } + + [Test] + public void Concatenate_has_limit_of_32767_characters() + { + Assert.AreNotEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("CONCATENATE(REPT(\"A\",32767))")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("CONCATENATE(REPT(\"A\",32768))")); + } + [Test] + public void Concatenate_uses_implicit_intersection_on_references() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); ws.FirstCell().SetValue(20) .CellBelow().SetValue("AB") - .CellBelow().SetFormulaA1("=DATE(2019,1,1)"); + .CellBelow().SetFormulaA1("DATE(2019,1,1)"); // Calling cell is 1st row, so formula should return A1 - //ws.Cell("B1").SetFormulaA1("=CONCATENATE(A1:A3)"); - //Assert.AreEqual("20", ws.Cell("B1").Value); + ws.Cell("B1").SetFormulaA1("CONCATENATE(A1:A3)"); + Assert.AreEqual("20", ws.Cell("B1").Value); // Calling cell is 2nd row, so formula should return A2 - //ws.Cell("B2").SetFormulaA1("=CONCATENATE(A1:A3)"); - //Assert.AreEqual("AB", ws.Cell("B2").Value); + ws.Cell("B2").SetFormulaA1("CONCATENATE(A1:A3)"); + Assert.AreEqual("AB", ws.Cell("B2").Value); // Calling cell is 3rd row, so formula should return A3's textual representation - //ws.Cell("B3").SetFormulaA1("=CONCATENATE(A1:A3)"); - //Assert.AreEqual("43466", ws.Cell("B3").Value); + ws.Cell("B3").SetFormulaA1("CONCATENATE(A1:A3)"); + Assert.AreEqual("43466", ws.Cell("B3").Value); - // Calling cell doesn't share row with any cell in parameter range. Throw CellValueException - //ws.Cell("A4").SetFormulaA1("=CONCATENATE(A1:A3)"); - //Assert.Throws(() => ws.Cell("A4").GetString()); + // Calling cell doesn't share row with any cell in parameter range. + ws.Cell("A4").SetFormulaA1("CONCATENATE(A1:A3)"); + Assert.AreEqual(XLError.IncompatibleValue, ws.Cell("A4").Value); } [Test] - public void Concatenate_with_references() + public void Dollar_coercion() { - var ws = new XLWorkbook().AddWorksheet(); + // Empty string is not coercible to number + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("DOLLAR(\"\", 3)")); + } - ws.Cell("A1").Value = "Hello"; - ws.Cell("B1").Value = "World"; + // en-US culture differs between .NET Fx and Core for negative currency -> no test for negative + [TestCase(123.54, 3, ExpectedResult = "$123.540")] + [TestCase(123.54, 3.9, ExpectedResult = "$123.540")] + [TestCase(1234.567, 2, ExpectedResult = "$1,234.57")] + [TestCase(1250, -2, ExpectedResult = "$1,300")] + [TestCase(1, -1E+100, ExpectedResult = "$0")] + public string Dollar_en(double number, double decimals) + { + using var wb = new XLWorkbook(); + return wb.Evaluate($"DOLLAR({number},{decimals})").GetText(); + } - Assert.AreEqual("Hello World", ws.Evaluate(@"=CONCATENATE(A1, "" "", B1)")); - Assert.Throws(() => ws.Evaluate(@"=CONCATENATE(A1:A2, "" "", B1:B2)")); + [SetCulture("cs-CZ")] + [TestCase(123.54, 3, ExpectedResult = "123,540 Kč")] + [TestCase(-1234.567, 4, ExpectedResult = "-1 234,5670 Kč")] + [TestCase(-1250, -2, ExpectedResult = "-1 300 Kč")] + public string Dollar_cs(double number, double decimals) + { + using var wb = new XLWorkbook(); + var formula = $"DOLLAR({number.ToString(CultureInfo.InvariantCulture)},{decimals.ToString(CultureInfo.InvariantCulture)})"; + return wb.Evaluate(formula).GetText(); } - [Test] - [Ignore("Enable when CalcEngine error handling works properly.")] - public void Dollar_Empty_Input_String() + [SetCulture("de-DE")] + [TestCase(1234.567, 2, ExpectedResult = "1.234,57 €")] + [TestCase(1234.567, -2, ExpectedResult = "1.200 €")] + [TestCase(-1234.567, 4, ExpectedResult = "-1.234,5670 €")] + public string Dollar_de(double number, double decimals) { - Assert.That(() => XLWorkbook.EvaluateExpr("Dollar(\"\", 3)"), Throws.TypeOf()); + using var wb = new XLWorkbook(); + var formula = $"DOLLAR({number.ToString(CultureInfo.InvariantCulture)},{decimals.ToString(CultureInfo.InvariantCulture)})"; + return wb.Evaluate(formula).GetText(); } [Test] - public void Dollar_Value() + public void Dollar_uses_two_decimal_places_by_default() { - Object actual = XLWorkbook.EvaluateExpr(@"Dollar(123.54)"); + using var wb = new XLWorkbook(); + var actual = wb.Evaluate("DOLLAR(123.543)"); Assert.AreEqual("$123.54", actual); + } - actual = XLWorkbook.EvaluateExpr(@"Dollar(123.54, 3)"); - Assert.AreEqual("$123.540", actual); + [Test] + public void Dollar_can_have_at_most_127_decimal_places() + { + using var wb = new XLWorkbook(); + Assert.AreEqual("$1." + new string('0', 99), wb.Evaluate("DOLLAR(1,99)")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("DOLLAR(1,128)")); } [Test] @@ -189,169 +322,325 @@ public void Exact_Value() Assert.AreEqual(false, actual); } + [Test] + public void Find_Empty_Pattern_And_Empty_Text() + { + // Different behavior from SEARCH + Assert.AreEqual(1, XLWorkbook.EvaluateExpr(@"FIND("""", """")")); + + Assert.AreEqual(2, XLWorkbook.EvaluateExpr(@"FIND("""", ""a"", 2)")); + } + + [Test] + public void Find_Empty_Search_Pattern_Returns_Start_Of_Text() + { + Assert.AreEqual(1, XLWorkbook.EvaluateExpr(@"FIND("""", ""asdf"")")); + } + + [Test] + public void Find_Looks_Only_From_Start_Position_Onward() + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"FIND(""This"", ""This is some text"", 2)")); + } + [Test] public void Find_Start_Position_Too_Large() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Find(""abc"", ""abcdef"", 10)"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"FIND(""abc"", ""abcdef"", 10)")); + } + + [Test] + public void Find_Start_Position_Too_Small() + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"FIND(""text"", ""This is some text"", 0)")); } [Test] - public void Find_String_In_Another_Empty_String() + public void Find_Empty_Searched_Text_Returns_Error() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Find(""abc"", """")"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"FIND(""abc"", """")")); } [Test] public void Find_String_Not_Found() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Find(""123"", ""asdf"")"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"FIND(""123"", ""asdf"")")); } [Test] public void Find_Case_Sensitive_String_Not_Found() { // Find is case-sensitive - Assert.That(() => XLWorkbook.EvaluateExpr(@"Find(""excel"", ""Microsoft Excel 2010"")"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"FIND(""excel"", ""Microsoft Excel 2010"")")); } [Test] public void Find_Value() { - Object actual = XLWorkbook.EvaluateExpr(@"Find(""Tuesday"", ""Today is Tuesday"")"); + var actual = XLWorkbook.EvaluateExpr(@"FIND(""Tuesday"", ""Today is Tuesday"")"); Assert.AreEqual(10, actual); - actual = XLWorkbook.EvaluateExpr(@"Find("""", """")"); - Assert.AreEqual(1, actual); + // Doesnt support wildcards + actual = XLWorkbook.EvaluateExpr(@"FIND(""T*y"", ""Today is Tuesday"")"); + Assert.AreEqual(XLError.IncompatibleValue, actual); + } - actual = XLWorkbook.EvaluateExpr(@"Find("""", ""asdf"")"); - Assert.AreEqual(1, actual); + [Test] + public void Find_Arguments_Are_Converted_To_Expected_Types() + { + var actual = XLWorkbook.EvaluateExpr(@"FIND(1.2, ""A1.2B"")"); + Assert.AreEqual(2, actual); + + actual = XLWorkbook.EvaluateExpr(@"FIND(TRUE, ""ATRUE"")"); + Assert.AreEqual(2, actual); + + actual = XLWorkbook.EvaluateExpr(@"FIND(23, 1.2345)"); + Assert.AreEqual(3, actual); + + actual = XLWorkbook.EvaluateExpr(@"FIND(""a"", ""aaaaa"", ""2 1/2"")"); + Assert.AreEqual(2, actual); + } + + [Test] + public void Find_Error_Arguments_Return_The_Error() + { + var actual = XLWorkbook.EvaluateExpr(@"FIND(#N/A, ""a"")"); + Assert.AreEqual(XLError.NoValueAvailable, actual); + + actual = XLWorkbook.EvaluateExpr(@"FIND("""", #N/A)"); + Assert.AreEqual(XLError.NoValueAvailable, actual); + + actual = XLWorkbook.EvaluateExpr(@"FIND(""a"", ""a"", #N/A)"); + Assert.AreEqual(XLError.NoValueAvailable, actual); + } + + [Test] + public void Fixed_coercion() + { + using var wb = new XLWorkbook(); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("""FIXED("asdf")""")); + Assert.AreEqual("1234.0", wb.Evaluate("""FIXED(1234,1,"TRUE")""")); + Assert.AreEqual("1,234.0", wb.Evaluate("""FIXED(1234,1,"FALSE")""")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("""FIXED(1234,1,"0")""")); } [Test] - public void Fixed_Input_Is_String() + public void Fixed_examples() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Fixed(""asdf"")"), Throws.TypeOf()); + using var wb = new XLWorkbook(); + Assert.AreEqual("1,234,567.00", wb.Evaluate("FIXED(1234567)")); + Assert.AreEqual("1234567.5556", wb.Evaluate("FIXED(1234567.555555,4,TRUE)")); + Assert.AreEqual("0.5555550000", wb.Evaluate("FIXED(.555555,10)")); + Assert.AreEqual("1,235,000", wb.Evaluate("FIXED(1234567,-3)")); } [Test] - public void Fixed_Value() + public void Fixed_en() { - Object actual = XLWorkbook.EvaluateExpr(@"Fixed(17300.67, 4)"); + var actual = XLWorkbook.EvaluateExpr("FIXED(17300.67,4)"); Assert.AreEqual("17,300.6700", actual); - actual = XLWorkbook.EvaluateExpr(@"Fixed(17300.67, 2, TRUE)"); + actual = XLWorkbook.EvaluateExpr("FIXED(17300.67,2,TRUE)"); Assert.AreEqual("17300.67", actual); - actual = XLWorkbook.EvaluateExpr(@"Fixed(17300.67)"); + actual = XLWorkbook.EvaluateExpr("FIXED(17300.67)"); Assert.AreEqual("17,300.67", actual); + + actual = XLWorkbook.EvaluateExpr("FIXED(1,-1E+300)"); + Assert.AreEqual("0", actual); } [Test] - public void Left_Bigger_Than_Length() + [SetCulture("cs-CZ")] + public void Fixed_cs() { - Object actual = XLWorkbook.EvaluateExpr(@"Left(""ABC"", 5)"); - Assert.AreEqual("ABC", actual); + using var wb = new XLWorkbook(); + var actual = wb.Evaluate("FIXED(17300.67,4)"); + Assert.AreEqual("17 300,6700", actual); + + actual = wb.Evaluate("FIXED(17300.67,2,TRUE)"); + Assert.AreEqual("17300,67", actual); + + actual = wb.Evaluate("FIXED(17300.67)"); + Assert.AreEqual("17 300,67", actual); } [Test] - public void Left_Default() + public void Fixed_can_have_at_most_127_decimal_places() { - Object actual = XLWorkbook.EvaluateExpr(@"Left(""ABC"")"); - Assert.AreEqual("A", actual); + using var wb = new XLWorkbook(); + Assert.AreEqual("1." + new string('0', 99), wb.Evaluate("FIXED(1,99)")); + Assert.AreEqual(XLError.IncompatibleValue, wb.Evaluate("FIXED(1,128)")); } [Test] - public void Left_Empty_Input_String() + public void Left_returns_whole_text_when_requested_length_is_greater_than_text_length() { - Object actual = XLWorkbook.EvaluateExpr(@"Left("""")"); - Assert.AreEqual("", actual); + var actual = XLWorkbook.EvaluateExpr(@"LEFT(""ABC"", 5)"); + Assert.AreEqual("ABC", actual); } [Test] - public void Left_Value() + public void Left_takes_one_character_by_default() { - Object actual = XLWorkbook.EvaluateExpr(@"Left(""ABC"", 2)"); - Assert.AreEqual("AB", actual); + var actual = XLWorkbook.EvaluateExpr("""LEFT("ABC")"""); + Assert.AreEqual("A", actual); } [Test] - public void Len_Empty_Input_String() + public void Left_returns_error_on_negative_number_of_chars() { - Object actual = XLWorkbook.EvaluateExpr(@"Len("""")"); - Assert.AreEqual(0, actual); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("""LEFT("ABC", -1)""")); } [Test] - public void Len_Value() + public void Left_returns_empty_string_on_empty_input() { - Object actual = XLWorkbook.EvaluateExpr(@"Len(""word"")"); - Assert.AreEqual(4, actual); + var actual = XLWorkbook.EvaluateExpr("""LEFT("")"""); + Assert.AreEqual("", actual); } - [Test] - public void Lower_Empty_Input_String() + [TestCase("ABC", 2, ExpectedResult = "AB")] + [TestCase("ABC", 2.9, ExpectedResult = "AB")] + [TestCase("ABC", 3, ExpectedResult = "ABC")] + [TestCase("\uD83D\uDC69Z", 1, ExpectedResult = "\uD83D\uDC69")] // Paired surrogate + [TestCase("\uD83D\uDC69Z", 2, ExpectedResult = "\uD83D\uDC69Z")] // Paired surrogate + public string Left_takes_specified_number_of_characters(string text, double numChars) { - Object actual = XLWorkbook.EvaluateExpr(@"Lower("""")"); - Assert.AreEqual("", actual); + return XLWorkbook.EvaluateExpr($"""LEFT("{text}", {numChars})""").GetText(); } - [Test] - public void Lower_Value() + [TestCase("", ExpectedResult = 0)] + [TestCase("word", ExpectedResult = 4)] + [TestCase("A\r\n", ExpectedResult = 3)] + [TestCase("H", ExpectedResult = 1)] + [TestCase("\ud83d\ude0a", ExpectedResult = 2)] // Smile emoji + [TestCase("Smile: \ud83d\ude0a!", ExpectedResult = 10)] // Smile emoji + public double Len_returns_number_of_code_units(string text) + { + return XLWorkbook.EvaluateExpr($"""LEN("{text}")""").GetNumber(); + } + + [SetCulture("en-US")] + [TestCase("", ExpectedResult = "")] + [TestCase("ABC", ExpectedResult = "abc")] + [TestCase("Intelligence 2.0!", ExpectedResult = "intelligence 2.0!")] + [TestCase("ͶꝎKǢ", ExpectedResult = "ͷꝏkǣ")] // Converts even non-latin chars + [TestCase("Σ SUM Σ end Σ", ExpectedResult = "σ sum σ end ς")] // Bug for bug behavior of Excel. Σ at the end is turned to ς + public string Lower_en(string text) { - Object actual = XLWorkbook.EvaluateExpr(@"Lower(""AbCdEfG"")"); - Assert.AreEqual("abcdefg", actual); + using var wb = new XLWorkbook(); + return wb.Evaluate($"""LOWER("{text}")""").GetText(); + } + + [SetCulture("tr-TR")] + [TestCase("INTELLIGENCE 2.0!", ExpectedResult = "ıntellıgence 2.0!")] // Turkey converts I to i without dot + [TestCase("ΣΣΣΣ", ExpectedResult = "σσσς")] + public string Lower_tr(string text) + { + using var wb = new XLWorkbook(); + return wb.Evaluate($"""LOWER("{text}")""").GetText(); } [Test] - public void Mid_Bigger_Than_Length() + public void Mid_returns_rest_of_text_when_end_is_out_of_text_bounds() { - Object actual = XLWorkbook.EvaluateExpr(@"Mid(""ABC"", 1, 5)"); + var actual = XLWorkbook.EvaluateExpr("""MID("ABC",1,5)"""); Assert.AreEqual("ABC", actual); } [Test] - public void Mid_Empty_Input_String() + public void Mid_when_start_is_after_end_of_text_return_empty_string() { - Object actual = XLWorkbook.EvaluateExpr(@"Mid("""", 1, 1)"); + var actual = XLWorkbook.EvaluateExpr("""MID("ABC",5,5)"""); Assert.AreEqual("", actual); } + [TestCase(0.9)] + [TestCase(0)] + [TestCase(-5)] + [TestCase(int.MaxValue + 1d)] + [TestCase(int.MaxValue + 5d)] + public void Mid_start_must_be_at_least_one_and_at_most_max_int(double start) + { + var actual = XLWorkbook.EvaluateExpr($"""MID("ABC",{start},1)"""); + Assert.AreEqual(XLError.IncompatibleValue, actual); + } + + [TestCase(-0.1)] + [TestCase(-5)] + [TestCase(int.MaxValue + 1d)] + [TestCase(int.MaxValue + 5d)] + public void Mid_length_must_be_at_least_zero_and_at_most_max_int(double length) + { + var actual = XLWorkbook.EvaluateExpr($"""MID("ABC",1,{length})"""); + Assert.AreEqual(XLError.IncompatibleValue, actual); + } + + [TestCase("", 1, 1, ExpectedResult = "")] + [TestCase("ABC", 2, 2, ExpectedResult = "BC")] + [TestCase("ABC", 2, 0, ExpectedResult = "")] + [TestCase("ABC", 3, 5, ExpectedResult = "")] + [TestCase(@"abcdef", 3, 2, ExpectedResult = "cd")] + [TestCase(@"abcdef", 4, 5, ExpectedResult = "def")] + public string Mid_returns_substring(string text, double start, double length) + { + return XLWorkbook.EvaluateExpr($"""MID("{text}",{start},{length})""").GetText(); + } + [Test] - public void Mid_Start_After() + public void Mid_uses_code_units() { - Object actual = XLWorkbook.EvaluateExpr(@"Mid(""ABC"", 5, 5)"); - Assert.AreEqual("", actual); + // MID returns unpaired surrogates + Assert.AreEqual("😊\uD83D", XLWorkbook.EvaluateExpr("""MID("😊😊😊",1,3)""")); + Assert.AreEqual("😊😊", XLWorkbook.EvaluateExpr("""MID("😊😊😊",1,4)""")); + Assert.AreEqual("\uDE0A😊\uD83D", XLWorkbook.EvaluateExpr("""MID("😊😊😊",2,4)""")); + Assert.AreEqual(3, XLWorkbook.EvaluateExpr("""LEN(MID("😊😊😊",1,3))""")); + } + + [TestCase("", 0d)] + [TestCase("+ 1", 1d)] + [TestCase("+1", 1d)] + [TestCase("+1.23", 1.23)] + [TestCase("- 1.23", -1.23)] + [TestCase(" - 0 1 2 . 3 4 ", -12.34)] + [TestCase(" - 0 \t1\t2\r .\n3 4 ", -12.34)] + [TestCase(".1", 0.1)] + [TestCase("-.1", -0.1)] + [TestCase("1.234567890E+307", 1.234567890E+307)] + [TestCase("1.234567890E-307", 1.234567890E-307d)] + [TestCase("1.234567890E-309", 0d)] + [TestCase("-1.234567890E-307", -1.234567890E-307d)] + [TestCase(".99999999999999", 0.99999999999999)] + [TestCase("1,23,4", 1234)] + [TestCase("1,234,56", 123456)] + [TestCase("1e-308", 0)] + [TestCase("-1e-308", 0)] + [TestCase("75825%", 758.25)] + [TestCase("75825%%", 7.5825)] + [TestCase("(56.4)", -56.4)] + [TestCase("(128)%", -1.28)] + public void NumberValue_converts_text_to_number(string text, double expectedResult) + { + var actual = (double)XLWorkbook.EvaluateExprCurrent($"NUMBERVALUE(\"{text}\")"); + Assert.AreEqual(expectedResult, actual); } [Test] - public void Mid_Value() + [SetCulture("de-DE")] + public void NumberValue_takes_separators_from_current_culture() { - Object actual = XLWorkbook.EvaluateExpr(@"Mid(""ABC"", 2, 2)"); - Assert.AreEqual("BC", actual); + var actual = (double)XLWorkbook.EvaluateExprCurrent("NUMBERVALUE(\"10.0.00.0,25\")"); + Assert.AreEqual(100000.25, actual); } - [TestCase("NUMBERVALUE(\"\")", 0d)] - [TestCase("NUMBERVALUE(\"1,234.56\", \".\", \",\")", 1234.56d)] - [TestCase("NUMBERVALUE(\"1.234,56\", \",\", \".\")", 1234.56d)] - [TestCase("NUMBERVALUE(\"+ 1\")", 1d)] - [TestCase("NUMBERVALUE(\"+1\")", 1d)] - [TestCase("NUMBERVALUE(\"+1.23\")", 1.23)] - [TestCase("NUMBERVALUE(\"- 1.23\")", -1.23)] - [TestCase("NUMBERVALUE(\" - 0 1 2 . 3 4 \")", -12.34)] - [TestCase("NUMBERVALUE(\" - 0 \t1\t2\r .\n3 4 \")", -12.34)] - [TestCase("NUMBERVALUE(\".1\")", 0.1)] - [TestCase("NUMBERVALUE(\"-.1\")", -0.1)] - [TestCase("NUMBERVALUE(\"1.234567890E+307\")", 1.234567890E+307)] - [TestCase("NUMBERVALUE(\"1.234567890E-307\")", 1.234567890E-307d)] - [TestCase("NUMBERVALUE(\"1.234567890E-309\")", 0d)] - [TestCase("NUMBERVALUE(\"-1.234567890E-307\")", -1.234567890E-307d)] - [TestCase("NUMBERVALUE(\".99999999999999\")", 0.99999999999999)] - [TestCase("NUMBERVALUE(\"1,23,4\")", 1234)] - [TestCase("NUMBERVALUE(\"1,234,56\")", 123456)] - public void NumberValue_Correct(string expression, double expectedResult) + [TestCase("1,234.56", ".", ",", 1234.56d)] + [TestCase("1.234,56", ",", ".", 1234.56d)] + [TestCase("1.234,56", ",ABC", ".DEF", 1234.56d)] // Only first char of separators is used + public void NumberValue_optional_parameters_can_set_decimal_and_group_separators(string text, string @decimal, string group, double expectedResult) { - var actual = (double)XLWorkbook.EvaluateExpr(expression); - Assert.AreEqual(expectedResult, actual, XLHelper.Epsilon); + var actual = (double)XLWorkbook.EvaluateExpr($"NUMBERVALUE(\"{text}\",\"{@decimal}\",\"{group}\")"); + Assert.AreEqual(expectedResult, actual); } [TestCase("NUMBERVALUE(\"123.45\", \".\", \".\")")] // Group separator same as decimal separator @@ -363,259 +652,456 @@ public void NumberValue_Correct(string expression, double expectedResult) [TestCase("NUMBERVALUE(\"-1.234567890E+308\")")] // Too large (negative) [TestCase("NUMBERVALUE(\"1.234567890E-310\")")] // Too tiny [TestCase("NUMBERVALUE(\"-1.234567890E-310\")")] // Too tiny (negative) - public void NumberValue_Invalid(string expression) + [TestCase("NUMBERVALUE(\"1\",\".\",\"\")")] // Empty group separator + [TestCase("NUMBERVALUE(\"1\",\"\",\",\")")] // Empty decimal separators + public void NumberValue_returns_error_on_unparsable_texts_out_of_range(string expression) { - TestDelegate action = () => XLWorkbook.EvaluateExpr(expression); - Assert.Throws(action); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(expression)); } - [Test] - public void Proper_Empty_Input_String() + [TestCase("", ExpectedResult = "")] + [TestCase("12aBC d123aD#$%sd^", ExpectedResult = "12Abc D123Ad#$%Sd^")] + [TestCase("this is a TITLE", ExpectedResult = "This Is A Title")] + [TestCase("2-way street", ExpectedResult = "2-Way Street")] + [TestCase("76BudGet", ExpectedResult = "76Budget")] + [TestCase("my name is francois botha", ExpectedResult = "My Name Is Francois Botha")] + [TestCase("\ud83a\udd32", ExpectedResult = "\ud83a\udd32")] // U+1E932 has uppercase variant, but nothing changes, because PROPER uses code units + public string Proper_upper_cases_first_letter_and_lower_cases_next_letters(string text) { - Object actual = XLWorkbook.EvaluateExpr(@"Proper("""")"); - Assert.AreEqual("", actual); + return XLWorkbook.EvaluateExpr($"""PROPER("{text}")""").GetText(); } - [Test] - public void Proper_Value() + [TestCase(1, 1)] + [TestCase(1, 0)] + [TestCase(1, 10)] + [TestCase(10, 1)] + [TestCase(10, 10)] + public void Replace_beyond_limit_appends_replacement(int startPos, int length) { - Object actual = XLWorkbook.EvaluateExpr(@"Proper(""my name is francois botha"")"); - Assert.AreEqual("My Name Is Francois Botha", actual); + var actual = XLWorkbook.EvaluateExpr($"""REPLACE("",{startPos},{length},"new text")"""); + Assert.AreEqual("new text", actual); + } + + [TestCase("Here is some obsolete text to replace.", 14, 13, "new text", ExpectedResult = "Here is some new text to replace.")] + [TestCase("ABC", 1, 2, "D", ExpectedResult = "DC")] + [TestCase("ABC", 3, 1, "D", ExpectedResult = "ABD")] + [TestCase("ABC", 3, 0, "D", ExpectedResult = @"ABDC")] + [TestCase("ABC", 4, 1, "D", ExpectedResult = @"ABCD")] + [TestCase("ABC", 4, 0, "D", ExpectedResult = @"ABCD")] + [TestCase("ABC", 1, 3, "D", ExpectedResult = "D")] + [TestCase("ABC", 2, 2, "D", ExpectedResult = "AD")] + [TestCase("ABC", 2, 0, "D", ExpectedResult = @"ADBC")] + [TestCase("ABC", 2, 3, "D", ExpectedResult = "AD")] + [TestCase(@"abcdefghijk", 3, 4, "XY", ExpectedResult = @"abXYghijk")] + [TestCase(@"abcdefghijk", 3, 1, "12345", ExpectedResult = @"ab12345defghijk")] + [TestCase(@"abcdefghijk", 15, 4, "XY", ExpectedResult = @"abcdefghijkXY")] + public string Replace_replaces_value(string text, double startPos, int length, string replacement) + { + return XLWorkbook.EvaluateExpr($"""REPLACE("{text}",{startPos},{length},"{replacement}")""").GetText(); } [Test] - public void Replace_Empty_Input_String() + public void Replace_start_position_must_be_from_1_to_32767() { - Object actual = XLWorkbook.EvaluateExpr(@"Replace("""", 1, 1, ""newtext"")"); - Assert.AreEqual("newtext", actual); + Assert.AreEqual(@"DABC", XLWorkbook.EvaluateExpr("""REPLACE("ABC",1,0,"D")""")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("""REPLACE("ABC",0.9,0,"D")""")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("""REPLACE("ABC",-1,0,"D")""")); + Assert.AreEqual("D", XLWorkbook.EvaluateExpr("""REPLACE("ABC",1,32767.9,"D")""")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("""REPLACE("ABC",1,32768,"D")""")); } [Test] - public void Replace_Value() + public void Replace_length_must_be_from_0_to_32767() { - Object actual = XLWorkbook.EvaluateExpr(@"Replace(""Here is some obsolete text to replace."", 14, 13, ""new text"")"); - Assert.AreEqual("Here is some new text to replace.", actual); + Assert.AreEqual("ABC", XLWorkbook.EvaluateExpr("""REPLACE("ABC",1,0,"")""")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("""REPLACE("ABC",1,-0.1,"D")""")); + Assert.AreEqual("D", XLWorkbook.EvaluateExpr("""REPLACE("ABC",1, 32767.9,"D")""")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("""REPLACE("ABC",1, 32768,"D")""")); } [Test] - public void Rept_Empty_Input_Strings() + public void Rept_returns_empty_string_when_text_is_empty_string() { - Object actual = XLWorkbook.EvaluateExpr(@"Rept("""", 3)"); + var actual = XLWorkbook.EvaluateExpr("""REPT("",3)"""); Assert.AreEqual("", actual); } - [Test] - public void Rept_Start_Is_Negative() + [TestCase(-1)] + [TestCase(-0.1)] + [TestCase(2147483648)] + public void Rept_returns_error_when_count_is_negative_or_greater_than_max_int(double count) { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Rept(""Francois"", -1)"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr($"""REPT("",{count})""")); } [Test] - public void Rept_Value() + public void Rept_limits_output_text_length_to_32767() { - Object actual = XLWorkbook.EvaluateExpr(@"Rept(""Francois Botha,"", 3)"); - Assert.AreEqual("Francois Botha,Francois Botha,Francois Botha,", actual); - - actual = XLWorkbook.EvaluateExpr(@"Rept(""123"", 5/2)"); - Assert.AreEqual("123123", actual); + Assert.AreEqual(new string('A', 32767), XLWorkbook.EvaluateExpr("""REPT("A",32767)""")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("""REPT("A",32768)""")); + } - actual = XLWorkbook.EvaluateExpr(@"Rept(""Francois"", 0)"); - Assert.AreEqual("", actual); + [TestCase("ABC", 3, ExpectedResult = @"ABCABCABC")] + [TestCase("123", 2.5, ExpectedResult = "123123")] + [TestCase("Francois", 0, ExpectedResult = "")] + [TestCase("Francois Botha,", 3, ExpectedResult = "Francois Botha,Francois Botha,Francois Botha,")] + public string Rept_Value(string text, double count) + { + return XLWorkbook.EvaluateExpr($"""REPT("{text}",{count})""").GetText(); } - [Test] - public void Right_Bigger_Than_Length() + [TestCase(5)] + [TestCase(3)] + public void Right_returns_whole_text_when_requested_length_is_greater_than_text_length(int length) { - Object actual = XLWorkbook.EvaluateExpr(@"Right(""ABC"", 5)"); + var actual = XLWorkbook.EvaluateExpr($"""RIGHT("ABC",{length})"""); Assert.AreEqual("ABC", actual); } [Test] - public void Right_Default() + public void Right_takes_one_character_by_default() { - Object actual = XLWorkbook.EvaluateExpr(@"Right(""ABC"")"); + var actual = XLWorkbook.EvaluateExpr("""RIGHT("ABC")"""); Assert.AreEqual("C", actual); } [Test] - public void Right_Empty_Input_String() + public void Right_returns_error_on_negative_number_of_chars() { - Object actual = XLWorkbook.EvaluateExpr(@"Right("""")"); - Assert.AreEqual("", actual); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("""RIGHT("ABC",-1)""")); } [Test] - public void Right_Value() + public void Right_returns_empty_string_on_empty_input() + { + var actual = XLWorkbook.EvaluateExpr("""RIGHT("")"""); + Assert.AreEqual("", actual); + } + + [TestCase("ABC", 0, ExpectedResult = "")] + [TestCase("ABC", 1, ExpectedResult = "C")] + [TestCase("ABC", 2, ExpectedResult = "BC")] + [TestCase("ABC", 3, ExpectedResult = "ABC")] + [TestCase("ABC", 4, ExpectedResult = "ABC")] + [TestCase("ABC", 2.9, ExpectedResult = "BC")] + [TestCase("Z\uD83D\uDC69", 1, ExpectedResult = "\uD83D\uDC69")] // Smiley emoji + [TestCase("\uD83D\uDC69Z", 2, ExpectedResult = "\uD83D\uDC69Z")] + [TestCase("\uD83D\uDC69Z", 3, ExpectedResult = "\uD83D\uDC69Z")] + public string Right_takes_specified_number_of_characters(string text, double numChars) { - Object actual = XLWorkbook.EvaluateExpr(@"Right(""ABC"", 2)"); - Assert.AreEqual("BC", actual); + return XLWorkbook.EvaluateExpr($"""RIGHT("{text}",{numChars})""").GetText(); } [Test] - public void Search_No_Parameters_With_Values() + public void Search_Empty_Pattern_And_Empty_Text() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Search("""", """")"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"SEARCH("""", """")")); } [Test] - public void Search_Empty_Search_String() + public void Search_Empty_Search_Pattern_Returns_Start_Of_Text() { - Object actual = XLWorkbook.EvaluateExpr(@"Search("""", ""asdf"")"); + var actual = XLWorkbook.EvaluateExpr(@"SEARCH("""", ""asdf"")"); Assert.AreEqual(1, actual); } + [Test] + public void Search_Looks_Only_From_Start_Position_Onward() + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"SEARCH(""This"", ""This is some text"", 2)")); + } + [Test] public void Search_Start_Position_Too_Large() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Search(""abc"", ""abcdef"", 10)"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"SEARCH(""abc"", ""abcdef"", 10)")); } [Test] - public void Search_Empty_Input_String() + public void Search_Start_Position_Too_Small() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Search(""abc"", """")"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"SEARCH(""text"", ""This is some text"", 0)")); } [Test] - public void Search_String_Not_Found() + public void Search_Empty_Searched_Text_Returns_Error() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Search(""123"", ""asdf"")"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"SEARCH(""abc"", """")")); } [Test] - public void Search_Wildcard_String_Not_Found() + public void Search_Text_Not_Found() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Search(""soft?2010"", ""Microsoft Excel 2010"")"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"SEARCH(""123"", ""asdf"")")); } [Test] - public void Search_Start_Position_Too_Large2() + public void Search_Wildcard_String_Not_Found() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Search(""text"", ""This is some text"", 15)"), Throws.TypeOf()); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"SEARCH(""soft?2010"", ""Microsoft Excel 2010"")")); } // http://www.excel-easy.com/examples/find-vs-search.html [Test] public void Search_Value() { - Object actual = XLWorkbook.EvaluateExpr(@"Search(""Tuesday"", ""Today is Tuesday"")"); + var actual = XLWorkbook.EvaluateExpr(@"SEARCH(""Tuesday"", ""Today is Tuesday"")"); Assert.AreEqual(10, actual); - // Find is case-INsensitive - actual = XLWorkbook.EvaluateExpr(@"Search(""excel"", ""Microsoft Excel 2010"")"); + // The search is case-insensitive + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""excel"", ""Microsoft Excel 2010"")"); Assert.AreEqual(11, actual); - actual = XLWorkbook.EvaluateExpr(@"Search(""soft*2010"", ""Microsoft Excel 2010"")"); + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""soft*2010"", ""Microsoft Excel 2010"")"); Assert.AreEqual(6, actual); - actual = XLWorkbook.EvaluateExpr(@"Search(""Excel 20??"", ""Microsoft Excel 2010"")"); + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""Excel 20??"", ""Microsoft Excel 2010"")"); Assert.AreEqual(11, actual); - actual = XLWorkbook.EvaluateExpr(@"Search(""text"", ""This is some text"", 14)"); + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""text"", ""This is some text"", 14)"); Assert.AreEqual(14, actual); } [Test] - public void Substitute_Value() + public void Search_Tilde_Escapes_Next_Char() + { + var actual = XLWorkbook.EvaluateExpr(@"SEARCH(""~a~b~"", ""ab"")"); + Assert.AreEqual(1, actual); + + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""a~*"", ""a*"")"); + Assert.AreEqual(1, actual); + + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""a~*"", ""ab"")"); + Assert.AreEqual(XLError.IncompatibleValue, actual); + + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""a~?"", ""a?"")"); + Assert.AreEqual(1, actual); + + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""a~?"", ""ab"")"); + Assert.AreEqual(XLError.IncompatibleValue, actual); + } + + [Test] + public void Search_Arguments_Are_Converted_To_Expected_Types() + { + var actual = XLWorkbook.EvaluateExpr(@"SEARCH(1.2, ""A1.2B"")"); + Assert.AreEqual(2, actual); + + actual = XLWorkbook.EvaluateExpr(@"SEARCH(TRUE, ""ATRUE"")"); + Assert.AreEqual(2, actual); + + actual = XLWorkbook.EvaluateExpr(@"SEARCH(23, 1.2345)"); + Assert.AreEqual(3, actual); + + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""a"", ""aaaaa"", ""2 1/2"")"); + Assert.AreEqual(2, actual); + } + + [Test] + public void Search_Error_Arguments_Return_The_Error() + { + var actual = XLWorkbook.EvaluateExpr(@"SEARCH(#N/A, ""a"")"); + Assert.AreEqual(XLError.NoValueAvailable, actual); + + actual = XLWorkbook.EvaluateExpr(@"SEARCH("""", #N/A)"); + Assert.AreEqual(XLError.NoValueAvailable, actual); + + actual = XLWorkbook.EvaluateExpr(@"SEARCH(""a"", ""a"", #N/A)"); + Assert.AreEqual(XLError.NoValueAvailable, actual); + } + + [Test] + public void Substitute_replaces_n_th_occurence() { - Object actual = XLWorkbook.EvaluateExpr(@"Substitute(""This is a Tuesday."", ""Tuesday"", ""Monday"")"); + var actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE(""This is a Tuesday."", ""Tuesday"", ""Monday"")"); Assert.AreEqual("This is a Monday.", actual); - actual = XLWorkbook.EvaluateExpr(@"Substitute(""This is a Tuesday. Next week also has a Tuesday."", ""Tuesday"", ""Monday"", 1)"); + actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE(""This is a Tuesday. Next week also has a Tuesday."", ""Tuesday"", ""Monday"", 1)"); Assert.AreEqual("This is a Monday. Next week also has a Tuesday.", actual); - actual = XLWorkbook.EvaluateExpr(@"Substitute(""This is a Tuesday. Next week also has a Tuesday."", ""Tuesday"", ""Monday"", 2)"); + actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE(""This is a Tuesday. Next week also has a Tuesday."", ""Tuesday"", ""Monday"", 2)"); Assert.AreEqual("This is a Tuesday. Next week also has a Monday.", actual); - actual = XLWorkbook.EvaluateExpr(@"Substitute("""", """", ""Monday"")"); - Assert.AreEqual("", actual); - - actual = XLWorkbook.EvaluateExpr(@"Substitute(""This is a Tuesday. Next week also has a Tuesday."", """", ""Monday"")"); + actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE(""This is a Tuesday. Next week also has a Tuesday."", """", ""Monday"")"); Assert.AreEqual("This is a Tuesday. Next week also has a Tuesday.", actual); - actual = XLWorkbook.EvaluateExpr(@"Substitute(""This is a Tuesday. Next week also has a Tuesday."", ""Tuesday"", """")"); + actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE(""This is a Tuesday. Next week also has a Tuesday."", ""Tuesday"", """")"); Assert.AreEqual("This is a . Next week also has a .", actual); } [Test] - public void T_Empty_Input_String() + public void Substitute_on_empty_string_returns_empty_string() { - Object actual = XLWorkbook.EvaluateExpr(@"T("""")"); + var actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE("""","""",""Monday"")"); Assert.AreEqual("", actual); } [Test] - public void T_Value() + public void Substitute_is_case_sensitive() { - Object actual = XLWorkbook.EvaluateExpr(@"T(""asdf"")"); - Assert.AreEqual("asdf", actual); + var actual = XLWorkbook.EvaluateExpr("""SUBSTITUTE("A","a","Z")"""); + Assert.AreEqual("A", actual); + } + + [Test] + public void Substitute_returns_original_string_when_occurrence_is_not_found() + { + var actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE(""ABCABC"",""A"",""Z"",3)"); + Assert.AreEqual(@"ABCABC", actual); + } + + [Test] + public void Substitute_searches_for_every_occurence() + { + // AA is matches at every character, it doesn't skip + var actual = XLWorkbook.EvaluateExpr("""SUBSTITUTE("AAAAAAAA","AA","ZZ",3)"""); + Assert.AreEqual(@"AAZZAAAA", actual); + } + + [Test] + public void Substitute_occurence_must_be_between_one_and_max_int() + { + var actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE(""ABC"",""B"",""ZZ"",0.9)"); + Assert.AreEqual(XLError.IncompatibleValue, actual); + + actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE(""ABC"",""B"",""ZZ"", 2147483646.9)"); + Assert.AreEqual("ABC", actual); + + actual = XLWorkbook.EvaluateExpr(@"SUBSTITUTE(""ABC"",""B"",""ZZ"", 2147483647)"); + Assert.AreEqual(XLError.IncompatibleValue, actual); + } - actual = XLWorkbook.EvaluateExpr(@"T(Today())"); + [Test] + public void T_returns_empty_string_on_non_text() + { + var actual = XLWorkbook.EvaluateExpr("T(TODAY())"); + Assert.AreEqual("", actual); + + actual = XLWorkbook.EvaluateExpr("T(IF(TRUE,,))"); Assert.AreEqual("", actual); - actual = XLWorkbook.EvaluateExpr(@"T(TRUE)"); + actual = XLWorkbook.EvaluateExpr("T(TRUE)"); Assert.AreEqual("", actual); + + actual = XLWorkbook.EvaluateExpr("T(123)"); + Assert.AreEqual("", actual); + } + + [Test] + public void T_propagates_error() + { + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("T(#DIV/0!)")); } [Test] - public void Text_Empty_Input_String() + public void T_returns_text_when_value_is_text() { - Object actual = XLWorkbook.EvaluateExpr(@"Text(1913415.93, """")"); + var actual = XLWorkbook.EvaluateExpr("""T("asdf")"""); + Assert.AreEqual("asdf", actual); + + actual = XLWorkbook.EvaluateExpr("""T("")"""); Assert.AreEqual("", actual); } [Test] - public void Text_Value() + public void T_returns_array_of_results_when_argument_is_array() + { + const string formula = """T({"A",5,"B"})"""; + Assert.AreEqual(3, XLWorkbook.EvaluateExpr($"""COLUMNS({formula})""")); + Assert.AreEqual(1, XLWorkbook.EvaluateExpr($"""ROWS({formula})""")); + Assert.AreEqual("A", XLWorkbook.EvaluateExpr($"""INDEX({formula},1,1)""")); + Assert.AreEqual("", XLWorkbook.EvaluateExpr($"""INDEX({formula},1,2)""")); + Assert.AreEqual("B", XLWorkbook.EvaluateExpr($"""INDEX({formula},1,3)""")); + + // Array doesn't propagate single error, but returns errors in the array + Assert.AreEqual("A", XLWorkbook.EvaluateExpr("""INDEX(T({"A",#REF!}),1,1)""")); + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("""INDEX(T({"A",#REF!}),1,2)""")); + } + + [Test] + public void T_returns_text_of_first_cell_in_reference() { - Object actual; - actual = XLWorkbook.EvaluateExpr(@"Text(Date(2010, 1, 1), ""yyyy-MM-dd"")"); - Assert.AreEqual("2010-01-01", actual); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("B3").Value = "ABC"; + ws.Cell("B4").Value = 10; + ws.Cell("B5").Value = XLError.NoValueAvailable; - actual = XLWorkbook.EvaluateExpr(@"Text(1469.07, ""0,000,000.00"")"); - Assert.AreEqual("0,001,469.07", actual); + Assert.AreEqual("ABC", ws.Evaluate("T(B3:B4)")); + Assert.AreEqual(2, ws.Evaluate("TYPE(T(B3:B4))")); // Is text, not array - actual = XLWorkbook.EvaluateExpr(@"Text(1913415.93, ""#,000.00"")"); - Assert.AreEqual("1,913,415.93", actual); + Assert.AreEqual(string.Empty, ws.Evaluate("T(B4:C4)")); - actual = XLWorkbook.EvaluateExpr(@"Text(2800, ""$0.00"")"); - Assert.AreEqual("$2800.00", actual); + Assert.AreEqual(XLError.NoValueAvailable, ws.Evaluate("T(B5:C5)")); + } - actual = XLWorkbook.EvaluateExpr(@"Text(0.4, ""0%"")"); - Assert.AreEqual("40%", actual); + [Test] + public void Text_returns_empty_string_on_empty_string() + { + var actual = XLWorkbook.EvaluateExpr(@"TEXT(1913415.93,"""")"); + Assert.AreEqual(string.Empty, actual); + } - actual = XLWorkbook.EvaluateExpr(@"Text(Date(2010, 1, 1), ""MMMM yyyy"")"); - Assert.AreEqual("January 2010", actual); + [TestCase("DATE(2010, 1, 1)", "yyyy-MM-dd", ExpectedResult = "2010-01-01")] + [TestCase("1469.07", "0,000,000.00", ExpectedResult = "0,001,469.07")] + [TestCase("1913415.93", "#,000.00", ExpectedResult = "1,913,415.93")] + [TestCase("2800", "$0.00", ExpectedResult = "$2800.00")] + [TestCase("0.4", "0%", ExpectedResult = "40%")] + [TestCase("DATE(2010, 1, 1)", "MMMM yyyy", ExpectedResult = "January 2010")] + [TestCase("DATE(2010, 1, 1)", "M/d/y", ExpectedResult = "1/1/10")] + [TestCase("1234.567", "$0.00", ExpectedResult = "$1234.57")] + [TestCase(".125", "$0.0%", ExpectedResult = "$12.5%")] + [TestCase("1234.567", "YYYY-MM-DD HH:MM:SS", ExpectedResult = "1903-05-18 13:36:28")] // Excel is one second off (29), but that is in the library + [TestCase("\"0.0245\"", "00%", ExpectedResult = "02%")] + public string Text_formats_number(string numberArg, string format) + { + return XLWorkbook.EvaluateExpr($"TEXT({numberArg},\"{format}\")").GetText(); + } - actual = XLWorkbook.EvaluateExpr(@"Text(Date(2010, 1, 1), ""M/d/y"")"); - Assert.AreEqual("1/1/10", actual); + [TestCase("\"211x\"", ExpectedResult = "211x")] + [TestCase("true", ExpectedResult = "TRUE")] + public string Text_returns_string_representation_of_non_numbers(string valueArg) + { + return XLWorkbook.EvaluateExpr($@"TEXT({valueArg},""#00"")").GetText(); + } + + [TestCase(2020, 11, 1, 9, 23, 11, "m/d/yyyy h:mm:ss", "11/1/2020 9:23:11")] + [TestCase(2023, 7, 14, 2, 12, 3, "m/d/yyyy h:mm:ss", "7/14/2023 2:12:03")] + [TestCase(2025, 10, 14, 2, 48, 55, "m/d/yyyy h:mm:ss", "10/14/2025 2:48:55")] + [TestCase(2023, 2, 19, 22, 1, 38, "m/d/yyyy h:mm:ss", "2/19/2023 22:01:38")] + [TestCase(2025, 12, 19, 19, 43, 58, "m/d/yyyy h:mm:ss", "12/19/2025 19:43:58")] + [TestCase(2034, 11, 16, 1, 48, 9, "m/d/yyyy h:mm:ss", "11/16/2034 1:48:09")] + [TestCase(2018, 12, 10, 11, 22, 42, "m/d/yyyy h:mm:ss", "12/10/2018 11:22:42")] + public void Text_formats_serial_dates(int year, int months, int days, int hour, int minutes, int seconds, string format, string expected) + { + Assert.AreEqual(expected, XLWorkbook.EvaluateExpr($@"TEXT(DATE({year},{months},{days}) + TIME({hour},{minutes},{seconds}),""{format}"")")); } [Test] - public void Text_String_Input() + public void Text_propagates_errors() { - Object actual = XLWorkbook.EvaluateExpr(@"TEXT(""211x"", ""#00"")"); - Assert.AreEqual("211x", actual); + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr(@"TEXT(#REF!,""#00"")")); } - [TestCase("=TEXTJOIN(\",\",TRUE,A1:B2)", "A,B,D")] - [TestCase("=TEXTJOIN(\",\",FALSE,A1:B2)", "A,,B,D")] - [TestCase("=TEXTJOIN(\",\",FALSE,A1,A2,B1,B2)", "A,B,,D")] - [TestCase("=TEXTJOIN(\",\",FALSE,1)", "1")] - [TestCase("=TEXTJOIN(\",\", TRUE, A:A, B:B)", "A,B,D")] - [TestCase("=TEXTJOIN(\",\", TRUE, D1:E2)", "")] - [TestCase("=TEXTJOIN(\",\", FALSE, D1:E2)", ",,,")] - [TestCase("=TEXTJOIN(\",\", FALSE, D1:D32768)", ",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,")] - [TestCase("=TEXTJOIN(0, FALSE, A1:B2)", "A00B0D")] - [TestCase("=TEXTJOIN(false, FALSE, A1:B2)", "AFALSEFALSEBFALSED")] - [TestCase("=TEXTJOIN(\",\", 0, A1:B2)", "A,,B,D")] - [TestCase("=TEXTJOIN(\",\", 100, A1:B2)", "A,B,D")] - [TestCase("=TEXTJOIN(B2, FALSE, A1:B2)", "ADDBDD")] - [TestCase("=TEXTJOIN(\",\", FALSE, 12345.67, DATE(2018, 10, 30))", "12345.67,43403")] - [TestCase("=TEXTJOIN(\",\", \"0\", A1:B2)", "A,,B,D")] // Excel does not accept text argument, LibreOffice does - public void TextJoin(string formula, string expectedOutput) + [TestCase("TEXTJOIN(\",\",TRUE,A1:B2)", "A,B,D")] + [TestCase("TEXTJOIN(\",\",FALSE,A1:B2)", "A,,B,D")] + [TestCase("TEXTJOIN(\",\",FALSE,A1,A2,B1,B2)", "A,B,,D")] + [TestCase("TEXTJOIN(\",\",FALSE,1)", "1")] + [TestCase("TEXTJOIN(\",\", TRUE, A:A, B:B)", "A,B,D")] + [TestCase("TEXTJOIN(\",\", TRUE, D1:E2)", "")] + [TestCase("TEXTJOIN(\",\", FALSE, D1:E2)", ",,,")] + [TestCase("TEXTJOIN(\",\", FALSE, D1:D32768)", ",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,")] + [TestCase("TEXTJOIN(0, FALSE, A1:B2)", "A00B0D")] + [TestCase("TEXTJOIN(false, FALSE, A1:B2)", @"AFALSEFALSEBFALSED")] + [TestCase("TEXTJOIN(\",\", 0, A1:B2)", "A,,B,D")] + [TestCase("TEXTJOIN(\",\", 100, A1:B2)", "A,B,D")] + [TestCase("TEXTJOIN(B2, FALSE, A1:B2)", @"ADDBDD")] + [TestCase("TEXTJOIN(\",\", FALSE, 12345.67, DATE(2018, 10, 30))", "12345.67,43403")] + [TestCase("TEXTJOIN(\",\", \"FALSE\", A1:B2)", "A,,B,D")] + public void TextJoin_joins_arguments_with_specified_delimiter(string formula, string expectedOutput) { - var wb = new XLWorkbook(); - IXLWorksheet ws = wb.AddWorksheet("Sheet1"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); ws.Cell("A1").Value = "A"; ws.Cell("A2").Value = "B"; ws.Cell("B1").Value = ""; @@ -624,80 +1110,128 @@ public void TextJoin(string formula, string expectedOutput) ws.Cell("C1").FormulaA1 = formula; var a = ws.Cell("C1").Value; - Assert.AreEqual(expectedOutput, a.ToString()); + Assert.AreEqual(expectedOutput, a); } - [TestCase("=TEXTJOIN(\",\", FALSE, D1:D32769)", "The value is too long")] - [TestCase("=TEXTJOIN(\",\", \"Invalid\", A1:B2)", "The second argument is invalid")] - public void TextJoinWithInvalidArgumentsThrows(string formula, string explain) + [TestCase("TEXTJOIN(\",\", FALSE, D1:D32769)")] + public void TextJoin_output_can_be_at_most_32767(string formula) { - var wb = new XLWorkbook(); - IXLWorksheet ws = wb.AddWorksheet("Sheet1"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); ws.Cell("C1").FormulaA1 = formula; - TestDelegate action = () => - { - var a = ws.Cell("C1").Value; - }; + // Excel actually returns #CALC!, but we don't have that error, mostly + // because parser doesn't recognize it. + Assert.AreEqual(XLError.IncompatibleValue, ws.Cell("C1").Value); + } - Assert.Throws(action, explain); + [TestCase("TEXTJOIN(\",\", \"Invalid\", \"Hello\", \"World\")")] + public void TextJoin_coercion(string formula) + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(formula)); } - [TestCase(2020, 11, 1, 9, 23, 11, "m/d/yyyy h:mm:ss", "11/1/2020 9:23:11")] - [TestCase(2023, 7, 14, 2, 12, 3, "m/d/yyyy h:mm:ss", "7/14/2023 2:12:03")] - [TestCase(2025, 10, 14, 2, 48, 55, "m/d/yyyy h:mm:ss", "10/14/2025 2:48:55")] - [TestCase(2023, 2, 19, 22, 1, 38, "m/d/yyyy h:mm:ss", "2/19/2023 22:01:38")] - [TestCase(2025, 12, 19, 19, 43, 58, "m/d/yyyy h:mm:ss", "12/19/2025 19:43:58")] - [TestCase(2034, 11, 16, 1, 48, 9, "m/d/yyyy h:mm:ss", "11/16/2034 1:48:09")] - [TestCase(2018, 12, 10, 11, 22, 42, "m/d/yyyy h:mm:ss", "12/10/2018 11:22:42")] - public void Text_DateFormats(int year, int months, int days, int hour, int minutes, int seconds, string format, string expected) + [TestCase("", ExpectedResult = "")] + [TestCase(" ", ExpectedResult = "")] + [TestCase(" ", ExpectedResult = "")] + [TestCase(" Break\r\n Line ", ExpectedResult = "Break\r\n Line")] + [TestCase("non-whitespace-text", ExpectedResult = "non-whitespace-text")] + [TestCase("white space text", ExpectedResult = "white space text")] + [TestCase(" some text with padding ", ExpectedResult = "some text with padding")] + [TestCase(" \t A \t ", ExpectedResult = "\t A \t")] + public string Trim_trims_spaces_and_removes_multi_spaces_from_inside_text(string text) { - Assert.AreEqual(expected, XLWorkbook.EvaluateExpr($@"TEXT(DATE({year}, {months}, {days}) + TIME({hour}, {minutes}, {seconds}), ""{format}"")")); + return XLWorkbook.EvaluateExpr($"""TRIM("{text}")""").GetText(); } [Test] - public void Trim_EmptyInput_String() + public void Upper_empty_string_returns_empty_string() { - Object actual = XLWorkbook.EvaluateExpr(@"Trim("""")"); - Assert.AreEqual("", actual); + Assert.AreEqual("", XLWorkbook.EvaluateExpr("""UPPER("")""")); } [Test] - public void Trim_Value() + public void Upper_converts_text_to_upper_case() { - Object actual = XLWorkbook.EvaluateExpr(@"Trim("" some text with padding "")"); - Assert.AreEqual("some text with padding", actual); + var actual = XLWorkbook.EvaluateExpr("""UPPER("AbCdEfG")"""); + Assert.AreEqual(@"ABCDEFG", actual); } + [SetCulture("tr-TR")] [Test] - public void Upper_Empty_Input_String() + public void Upper_uses_workbook_culture() { - Object actual = XLWorkbook.EvaluateExpr(@"Upper("""")"); - Assert.AreEqual("", actual); + // Türkiye converts i to İ, not I. + using var wb = new XLWorkbook(); + Assert.AreEqual("İNTELLİGENCE 2.0!", wb.Evaluate("""UPPER("intelligence 2.0!")""")); } [Test] - public void Upper_Value() + public void Value_Input_String_Is_Not_A_Number() { - Object actual = XLWorkbook.EvaluateExpr(@"Upper(""AbCdEfG"")"); - Assert.AreEqual("ABCDEFG", actual); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"VALUE(""asdf"")")); } [Test] - public void Value_Input_String_Is_Not_A_Number() + public void Value_FromBlankIsZero() { - Assert.That(() => XLWorkbook.EvaluateExpr(@"Value(""asdf"")"), Throws.TypeOf()); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.AreEqual(0d, ws.Evaluate("VALUE(A1)")); + } + + [Test] + public void Value_FromEmptyStringIsError() + { + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("VALUE(\"\")")); + } + + [Test] + public void Value_PassingUnexpectedTypes() + { + Assert.AreEqual(14d, XLWorkbook.EvaluateExpr(@"VALUE(14)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"VALUE(TRUE)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr(@"VALUE(FALSE)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr(@"VALUE(#DIV/0!)")); } [Test] public void Value_Value() { - Object actual = XLWorkbook.EvaluateExpr(@"Value(""123.54"")"); - Assert.AreEqual(123.54, actual); + using var wb = new XLWorkbook(); + + // Examples from spec + Assert.AreEqual(123.456d, wb.Evaluate("VALUE(\"123.456\")")); + Assert.AreEqual(1000d, wb.Evaluate("VALUE(\"$1,000\")")); + Assert.AreEqual(new DateTime(2002, 3, 23).ToSerialDateTime(), wb.Evaluate("VALUE(\"23-Mar-2002\")")); + Assert.AreEqual(0.188056d, (double)wb.Evaluate("VALUE(\"16:48:00\")-VALUE(\"12:17:12\")"), 0.000001d); + } + + [Test] + [SetCulture("cs-CZ")] + public void Value_NonEnglish() + { + using var wb = new XLWorkbook(); + + // Examples from spec + Assert.AreEqual(123.456d, wb.Evaluate("VALUE(\"123,456\")")); + Assert.AreEqual(1000d, wb.Evaluate("VALUE(\"1 000 Kč\")")); + Assert.AreEqual(37338d, wb.Evaluate("VALUE(\"23-bře-2002\")")); + Assert.AreEqual(0.188056d, (double)wb.Evaluate("VALUE(\"16:48:00\")-VALUE(\"12:17:12\")"), 0.000001d); + + // Various number/currency formats + Assert.AreEqual(-1d, wb.Evaluate("VALUE(\"(1)\")")); + Assert.AreEqual(-1d, wb.Evaluate("VALUE(\"(100%)\")")); + Assert.AreEqual(-1d, wb.Evaluate("VALUE(\"(100%)\")")); + Assert.AreEqual(-15d, wb.Evaluate("VALUE(\"(1,5e1 Kč)\")")); + Assert.AreEqual(-15d, wb.Evaluate("VALUE(\"(1,5e3%)\")")); + Assert.AreEqual(-15d, wb.Evaluate("VALUE(\"(1,5e3)%\")")); - actual = XLWorkbook.EvaluateExpr(@"Value(654.32)"); - Assert.AreEqual(654.32, actual); + var expectedSerialDate = new DateTime(2022, 3, 5).ToSerialDateTime(); + Assert.AreEqual(expectedSerialDate, wb.Evaluate("VALUE(\"5-březen-22\")")); + Assert.AreEqual(expectedSerialDate, wb.Evaluate("VALUE(\"05.03.2022\")")); + Assert.AreEqual(new DateTime(DateTime.Now.Year, 3, 5).ToSerialDateTime(), wb.Evaluate("VALUE(\"5-březen\")")); } } } diff --git a/ClosedXML.Tests/Excel/CalcEngine/TextToNumberCoercionTests.cs b/ClosedXML.Tests/Excel/CalcEngine/TextToNumberCoercionTests.cs new file mode 100644 index 000000000..15b3153ec --- /dev/null +++ b/ClosedXML.Tests/Excel/CalcEngine/TextToNumberCoercionTests.cs @@ -0,0 +1,322 @@ +using System; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.CalcEngine +{ + [TestFixture] + [SetCulture("en-US")] + public class TextToNumberCoercionTests + { + private const double Tolerance = 0.000001; + + [Test] + public void TimeSpan_MaximumResolutionIsOneMs() + { + var firstValue = (double)XLWorkbook.EvaluateExpr("\"0:0:0.0015\" * 1"); + var secondValue = (double)XLWorkbook.EvaluateExpr("\"0:0:0.0024\" * 1"); + Assert.AreEqual(firstValue, secondValue); + } + + [TestCase("100%", 1)] + [TestCase("-100%", -1)] + [TestCase("200%", 2)] + [TestCase("0000%", 0)] + [TestCase("1%", 0.01)] + [TestCase("+1%", 0.01)] + [TestCase(" -75 % ", -0.75)] + [TestCase(" - 100 % ", -1, Ignore = ".NET parser doesn't allow whitespace between sign and number.")] + public void Percent_Format9(string percent, double? expectedValue) // Format 9 '0%' + { + AssertCoercion(percent, expectedValue); + } + + [TestCase("100.5%", 1.005)] + [TestCase("100 . 5%", null)] + [TestCase(" - 100.59 % ", -1.0059, Ignore = ".NET parser doesn't allow whitespace between sign and number.")] + [TestCase("0.123456%", 0.00123456)] + [TestCase(".5%", 0.005)] + [TestCase(" -.375 % ", -0.00375)] + [TestCase("100.%", 1)] + public void Percent_Format10(string percent, double? expectedValue) // Format 10 '0.00%' + { + AssertCoercion(percent, expectedValue); + } + + [TestCase("(100%)", -1, Ignore = ".NET parser doesn't parse percents.")] + [TestCase("(-100%)", null)] // Can't have minus sign inside the brackets + [TestCase("-(100%)", null)] // Can't have minus sign outside the brackets + [TestCase("1,000.00%", 10)] + [TestCase("(1,000.00%)", -10, Ignore = ".NET parser doesn't parse percents.")] + [TestCase(" % 100", 1)] // Percents can be at start or end, position doesn't matter + public void Percent_UnlistedFormats(string percent, double? expectedValue) // + { + AssertCoercion(percent, expectedValue); + } + + [TestCase("0 1/2", 0.5)] + [TestCase("0 /20", null)] + [TestCase("0 1/32768", null)] // Denominator can be at most 2^15-1 + [TestCase("0 1/32767", 3.0518509475997192E-05d)] + [TestCase("0 32768/1", null)] // Nominator can be at most 2^15-1 + [TestCase("0 32767/1", 32767)] + [TestCase("1 32767/032767", null)] // Fraction can be only 5 digits at most + [TestCase("1 00100/025", 5)] + [TestCase("1 100/-2", null)] // Fractions can't be negative + [TestCase("1 -1/2", null)] + [TestCase("- 1 1/2", -1.5)] // can use minus sign + [TestCase("+1 1/2", 1.5)] // or plus sign + [TestCase("1.5 1/2", null)] // Can't use dot in whole part + [TestCase(" 1 10/20 ", 1.5)] + [TestCase("1 1/2", null)] // Between whole part and nominator must be exactly one space + [TestCase("1 1 /2", null)] // Can't have spaces between nominator and denominator + [TestCase("1 1/ 2", null)] + [TestCase("1 1/2", null)] // Tab and other whitespaces aren't allowed + [TestCase("0 1/0", null)] // Division by zero + public void Fraction_Format12_13(string fraction, double? expectedValue) // Format 12+13 '# ??/??' and '# ?/?' + { + AssertCoercion(fraction, expectedValue); + } + + [TestCase("02/28/20", 43889)] + [TestCase("002/28/20", null)] + [TestCase("02/028/20", null)] + [TestCase("02/28/022", null)] + public void Date_Format14(string date, double? expectedValue) // Format 14 is taken from region setting, but for en (and MS errata) says 'm/d/yyyy' + { + AssertCoercion(date, expectedValue); + } + + [TestCase("30-apr-2000", 36646)] + [TestCase("30-apr-20", 43951)] // 2020-04-30 + [TestCase("31-dec-9999", 2958465)] + [TestCase("1-jan-10000", null)] + [TestCase("1 - jan - 2022 ", 44562)] // Can have whitespace in the date + [TestCase(" 1-jan-2022", null, Ignore = ".NET parser doesn't respect the whitespace styles of a date during parsing.")] // Can't have whitespaces at the start + [TestCase("31-dec-1899", null)] // Check 1900 "leap" year issue... + [TestCase("1-jan-1900", 1)] + [TestCase("28-feb-1900", 59)] + [TestCase("1-mar-1900", 61)] + public void Date_Format15(string date, double? expectedValue) // Format 15 d-mmm-yy + { + AssertCoercion(date, expectedValue); + } + + [TestCase("0-mar", null)] // Zero day not accepted + [TestCase("1-mar", 44621)] + [TestCase("1-marc", 44621, Ignore = ".NET parser recognizes only abbreviation or full name of a month.")] + [TestCase("1-march", 44621)] + [TestCase(" 1 - apr ", 44652)] // Unlike many others, this format also allows space at the start, not just inside and at the end + [TestCase("31-apr", null)] // April has only 30 days + public void Date_Format16(string text, double? expectedValue) // Format 16 'd-mmm' + { + if (expectedValue is not null) + { + var date = DateTime.FromOADate(expectedValue.Value); + expectedValue = new DateTime(DateTime.Now.Year, date.Month, date.Day).ToOADate(); + } + + AssertCoercion(text, expectedValue); + } + + [SetCulture("cs-CZ")] + [TestCase("3-leden", 36528)] // Serial datetime is for 03-01-2000 + [TestCase("3-led", 36528)] // Serial datetime is for 03-01-2000 + public void Date_Format16_UsesCulture(string text, double? expectedValue) // Format 16 'd-mmm' + { + expectedValue += new DateTime(DateTime.Now.Year, 1, 1).ToOADate() - new DateTime(2000, 1, 1).ToOADate(); + AssertCoercion(text, expectedValue); + } + + // In en locale, there should be an extra pattern MMM-dd that is before the standard MMM-yy, but .NET Framework doesn't have it. + // To overcome missing locale, use numbers over 31 for year (otherwise they should be interpreted as days) + [TestCase("jan-02", 44563, Ignore = ".NET misses culture, en interprets it as MMM-dd, but czech as MMM-yy, so the MMM-dd is the extra culture for en.")] // interpreted as 2022-01-02 + [TestCase("jan-31", 44592, Ignore = "Missing excel culture mapping")] // 2022-01-02 + [TestCase("jan-32", 11689)] // 1932-01-01 + [TestCase("feb-29", 47150, Ignore = "Missing excel culture mapping")] // 2029-02-01 + [TestCase("feb-30", 10990)] // 1930-02-01 + [TestCase("feb-31", 11355)] // 1931-02-01 + [TestCase("feb-003", null)] // three digits not allowed + [TestCase("aug - 55", 20302)] // spaces are allowed inside the pattern + [TestCase(" aug-55", null, Ignore = ".NET allow whitespaces even without specified DateTimeStyle.AllowLeadingWhite")] // starting spaces not allowed + [TestCase("aug-55 ", 20302)] // trailing spaces allowed + [TestCase("MaR-42", 15401)] // case insensitive + [TestCase("marc-2", 44622, Ignore = ".NET parser recognizes only abbreviation or full name of a month.")] // name can be more than three long abbr + [TestCase("march-55", 20149)] + [TestCase("ma-2", null)] // Name of month must be at least three chars long + public void Date_Format17(string text, double? expectedValue) // Format 17 'mmm-yy' + { + AssertCoercion(text, expectedValue); + } + + [TestCase("1:20 AM", 0.055555555555555552d)] + [TestCase("1:20 aM", 0.055555555555555552d)] + [TestCase("1:60 AM", null)] // Minutes must be 0-59 range + [TestCase("1:59 AM", 0.082638888888888887d)] + [TestCase("13:00 AM", null)] // AM only allows hours in 0-12 range + [TestCase("7:30 A", 0.3125)] // only starting letter of AM + [TestCase("1:9 AM", 0.04791666666666667d)] // Single digit minutes + public void Date_Format18(string text, double? expectedValue) // Format 18 'h:mm AM/PM' + { + AssertCoercion(text, expectedValue); + } + + [TestCase("12:0:0 PM", 0.5)] + [TestCase("12:0:18 aM", 0.00020833333333333335d)] // case insensitive AM designator + [TestCase("13:0:0 PM", null)] // hours can't be outside of 0-12, unlike other format + [TestCase("13:0:0 AM", null)] + [TestCase("00:60:00 AM", null)] // minutes can't be outside of 0-59, unlike other format + [TestCase("00:59:00 AM", 0.040972222222222222d)] + [TestCase("00:00:60 AM", null)] // seconds can't be outside of 0-59, unlike other format + [TestCase("00:00:59 AM", 0.00068287037037037036d)] + [TestCase("00:00: AM", null)] // can't omit second part (differs from time span). + [TestCase("1:2:3 AM", 0.043090277777777776d)] + public void Date_Format19(string text, double? expectedValue) // Format 19 'h:mm:ss AM/PM' + { + AssertCoercion(text, expectedValue); + } + + [TestCase("2/5/2022 0:0", 44597)] + [TestCase("05/5/2022 0:0", 44686)] // Extra zero padding allowed + [TestCase("005/5/2022 0:0", null)] // 0 prefix requires at most 2 digits + [TestCase("13/5/2022 0:0", null)] // Month outside of range + [TestCase("11/030/2022 0:0", null)] + [TestCase("11/30/02022 0:0", null)] // Extra zero before year not allowed + [TestCase("11/30/2022 24:59", 44896.04097, Ignore = "Excel can have out of range parts, but .NET parsers can't.")] + [TestCase("11/30/2022 24:60", null)] // Both parts are out of range + [TestCase("11/30/2022 23:160", 44896.06944, Ignore = "Excel can have one of of range part, but .NET parser can't.")] + [TestCase("11/30/2022 9999:59", 45311.66597, Ignore = "Excel parser accepts numbers over limit for hours.")] + [TestCase("11/30/2022 10000:59", null)] // Hours can't be over 9999 + [TestCase("aug 10, 2022 14:10", 44783.590277777781d, Ignore = "Excel specific parsing of months accepts anything from three letters up to full name, but such pattern is not in any en-US DateTimeFormat pattern.")] + [TestCase("august 10, 2022 14:10", 44783.590277777781d)] + public void DateTime_Format22(string text, double? expectedValue) // Format 22 'm/d/yyyy h:mm'. Specification incorrectly states 'm/d/yy h:mm', but fixed per MS errata. + { + AssertCoercion(text, expectedValue); + } + + [TestCase("00:00", 0)] // Can parse zero + [TestCase("90:00", 3.75)] // Minutes can be can be over 60 + [TestCase("59:59", 2.499305556)] // Even if looks like mm:ss, it is actually parsed as h:mm + [TestCase("10:", 0.416666667)] // Last part can be omitted and zero is used + [TestCase("9999:", 416.625)] // Upper limit of first part is parseable + [TestCase("10000:", null)] // Part value over a limit is not parseable + [TestCase(":5", null)] // Can't omit first part + [TestCase("24:60", null)] // Only one part can be outside of limit, here are both + [TestCase("30:59", 1.290972222)] // Hour part can be over 23 + [TestCase("23:300", 1.166666667)] // Minute part over over 59 + public void TimeSpan_Format20(string timeSpan, double? expectedValue) // 'h:mm' + { + AssertCoercion(timeSpan, expectedValue, Tolerance); + } + + [TestCase("0:01:01", 0.000706019)] + [TestCase("000:01:01", null)] // Extra zeros. + [TestCase("00:001:01", null)] // Three digits in a part that starts with 0 + [TestCase("0:01:001", null)] // Three digits in a part that starts with 0 + [TestCase("00:60:60", null)] // Only one part can be over the limit, but here are minutes and seconds + [TestCase("24:60:00", null)] // Only one part can be over the limit, but here are hours and minutes + [TestCase("24:00:60", null)] // Only one part can be over the limit, but here are hours and seconds + [TestCase("23:60:06", 1.000069444)] + [TestCase(" 24 : 00 : 59 ", 1.00068287)] // Extra padding + [TestCase("24:0:", 1)] // Last part can be omitted + [TestCase("0::0", null)] // Parts in the middle can't be omitted + [TestCase(":0:0", null)] // First part can't be omitted + public void TimeSpan_Format21(string timeSpan, double? expectedValue) // 'h:mm:ss' + { + AssertCoercion(timeSpan, expectedValue, Tolerance); + } + + [TestCase("14:30.0", 0.010069444)] // Happy case, can be over 12 (to differ from AM/PM times) + [TestCase("14:300.0", 0.013194444)] // Seconds part can be outside of normal range + [TestCase("140:30.0", 0.097569444)] // Minutes part can be outside of normal range + [TestCase("30:300.0", 0.024305556)] // Both parts can be outside the range + [TestCase("140:60.0", null)] // Both hours and minutes are out of range + [TestCase("60:000.0", null)] // The minutes part starts with 0, but has over 2 digits + [TestCase("59:300.0", 0.044444444)] // Seconds are added to the minutes, the result is 1:04 minutes + [TestCase("59:300.59", 0.044451273)] // Can specify 2 digit ms + [TestCase("00:57.180", 0.000661806)] // Can specify 3 digit ms + public void TimeSpan_Format47(string timeSpan, double? expectedValue) // 'mm:ss.0' + { + AssertCoercion(timeSpan, expectedValue, Tolerance); + } + + [TestCase("1,000", 1000)] + [TestCase("1,00", null, Ignore = ".NET parse methods ignores thousands separator, but excel enforces them.")] + [TestCase("1,000,000", 1000000)] + [TestCase("1,00,000", null, Ignore = ".NET parse methods ignores thousands separator, but excel enforces them.")] + [TestCase("(1,000)", -1000)] + [TestCase("(100)", -100)] + [TestCase("(-1)", null)] + public void Number_Format37_38(string number, double? expectedValue) // Format 37+38 '#,##0 ;(#,##0)' '#,##0 ;[Red](#,##0)' + { + AssertCoercion(number, expectedValue); + } + + [TestCase("1,000.15", 1000.15)] + [TestCase("(1,000.54)", -1000.54)] + [TestCase(" ( 1,000.54 ) ", -1000.54, Ignore = "Excel can parse spaces within braces, but .NET parse method can't.")] + public void Number_Format39_40(string number, double? expectedValue) // Format 39+40 '#,##0.00;(#,##0.00)' '#,##0.00;[Red](#,##0.00)' + { + AssertCoercion(number, expectedValue); + } + + [TestCase("1e3", 1000)] + [TestCase("1e+3", 1000)] + [TestCase("1e-5", 0.00001)] + [TestCase("1e0", 1)] + [TestCase("1.5e2", 150)] + [TestCase("1e2.5", null)] // Exponent can't be a fraction + [TestCase("1.52e1", 15.2)] + [TestCase("-1e2", -100)] + [TestCase("1E2", 100)] + public void Number_Format48_11(string number, double? expectedValue) // Format 48+11 '##0.0E+0' '0.00E+00' + { + AssertCoercion(number, expectedValue); + } + + [TestCase("$1", 1)] + [TestCase("1$", null, Ignore = ".NET parser allows currency symbol at the start or end, but Excel requires correct placement.")] + [TestCase("($1)", -1)] + [TestCase("-($1)", null)] + [TestCase("$100.5", 100.5)] + [TestCase("$100%", null)] + [TestCase("($100%)", null)] + public void Currency(string currency, double? expectedValue) // Currency doesn't have a format in ECMA-376, Part 1, §18.8.30, but VALUE includes currency formats + { + AssertCoercion(currency, expectedValue); + } + + [SetCulture("cs-CZ")] + [TestCase("$1", null)] // Fallback currency doesn't work nor it should + [TestCase("Kč 1", null, Ignore = "Excel requires correct placement of currency symbol, while .NET parser accepts any position.")] // incorrect placement + [TestCase("100.5", null)] // incorrect decimal placement + [TestCase("10e2 Kč", 1000)] + [TestCase("30-apr-2000", null)] + [TestCase("02/28/20", null)] + [TestCase("10:30 AM", 0.4375)] // AM seems to work for some reason + [TestCase("10:30 dop.", 0.4375)] + [TestCase("1-leden-2020", 43831)] + [TestCase("1-led-2020", 43831)] + [TestCase("led-5", 38353)] + [TestCase("12:0:18 odp.", 0.50020833333333337d)] + [TestCase("12:0:18 PM", 0.50020833333333337d)] + [TestCase("12:0:18 odp", 0.50020833333333337d, Ignore = "Excel can parse even partial PM designator, but .NET requires a full PM designator including the dot at the end.")] + [TestCase("12:0:18 PM.", 0.50020833333333337d, Ignore = "Excel allows PM designator with a dot at the end.")] + [TestCase("11/30/2022 25:59", null)] + [TestCase("25:70,05", 0.018171875)] // For min:sec fraction timespan, both can be over limit, also note use of decimal separator + public void ParsingTokensAndFormatsDependOnCulture(string currency, double? expectedValue) + { + AssertCoercion(currency, expectedValue); + } + + private static void AssertCoercion(string text, double? expectedValue, double tolerance = 0) + { + using var wb = new XLWorkbook(); + var parsedValue = wb.Evaluate($"\"{text}\"*1"); + if (expectedValue is null) + Assert.AreEqual(XLError.IncompatibleValue, parsedValue); + else + Assert.AreEqual(expectedValue.Value, (double)parsedValue, tolerance); + } + } +} diff --git a/ClosedXML.Tests/Excel/CalcEngine/WildcardTests.cs b/ClosedXML.Tests/Excel/CalcEngine/WildcardTests.cs new file mode 100644 index 000000000..73df94cf1 --- /dev/null +++ b/ClosedXML.Tests/Excel/CalcEngine/WildcardTests.cs @@ -0,0 +1,114 @@ +using System; +using ClosedXML.Excel.CalcEngine; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.CalcEngine +{ + [TestFixture] + public class WildcardTests + { + [TestCase("")] + [TestCase("abc")] + public void Empty_Pattern_Matches_Any_String(string text) + { + Assert.AreEqual(0, SearchWildcard(text, string.Empty)); + } + + [TestCase("", "abc", 0)] + [TestCase("a", "abc", 0)] + [TestCase("ab", "abc", 0)] + [TestCase("abc", "abc", 0)] + [TestCase("bc", "abc", 1)] + [TestCase("c", "abc", 2)] + public void Substring_Of_Text_Matches_Text(string substringPattern, string text, int expectedIndex) + { + Assert.AreEqual(expectedIndex, SearchWildcard(text, substringPattern)); + } + + [TestCase("abcd", "abc")] + public void Pattern_Not_In_Text_Returns_Negative_One(string pattern, string text) + { + Assert.AreEqual(-1, SearchWildcard(text, pattern)); + } + + [Test] + public void Pattern_Comparison_Is_Case_Insensitive() + { + Assert.AreEqual(1, SearchWildcard("zabcd", "AbCd")); + } + + [Test] + public void Tilde_Is_Escape_Char() + { + Assert.AreEqual(1, SearchWildcard("_abc_", "~a~B~c")); + } + + [TestCase("~*", "*", 0)] + [TestCase("~*", "a", -1)] + [TestCase("~?", "?", 0)] + [TestCase("~?", "a", -1)] + [TestCase("~a~b~", "ab", 0)] + public void Escaped_Wildcards_Are_Matched_As_Chars(string pattern, string text, int expectedPosition) + { + Assert.AreEqual(expectedPosition, SearchWildcard(text, pattern)); + } + + [Test] + public void Question_Mark_Wildcard_Matches_Any_Char() + { + Assert.AreEqual(0, SearchWildcard("abc", "a?c")); + } + + [TestCase("abcd", "ab*cd", 0)] + [TestCase(@"aaab_____cd", "ab*cd", 2)] + [TestCase("*abc*", "***a*b*c***", 0)] + + public void Star_Wildcard_Matches_Any_Number_Of_Chars(string text, string pattern, int index) + { + Assert.AreEqual(index, SearchWildcard(text, pattern)); + } + + [Test] + public void Unpaired_Escape_Char_At_The_End_Of_Pattern_Is_Not_Char() + { + Assert.AreEqual(0, SearchWildcard("a", "a~")); + } + + [Test] + public void Star_Wildcard_At_The_Beginning_Matches_First_Char() + { + Assert.AreEqual(0, SearchWildcard("abcccd", "*ccd")); + } + + [Test] + public void Pattern_Size_Is_Limited_To_255_Chars() + { + Assert.AreEqual(0, SearchWildcard(new string('a', 1000), new string('a', 255))); + + Assert.AreEqual(-1, SearchWildcard(new string('a', 1000), new string('a', 256))); + } + + [TestCase("?", "a", true)] + [TestCase("?", "ab", false)] + [TestCase("a?", "ab", true)] + [TestCase("a?", "abc", false)] + [TestCase("?b", "ab", true)] + [TestCase("?b", "aab", false)] + [TestCase("a*", "abc", true)] + [TestCase("*a*", "abc", true)] + [TestCase("*c", "abc", true)] + [TestCase("*a*a", "abc", false)] + [TestCase("*a*a", "aba", true)] + [TestCase("*a*a", @"zaba", true)] + [TestCase("a*", @"zaba", false)] + public void Matches(string pattern, string text, bool matches) + { + Assert.AreEqual(matches, new Wildcard(pattern).Matches(text.AsSpan())); + } + + private static int SearchWildcard(string text, string pattern) + { + return new Wildcard(pattern).Search(text.AsSpan()); + } + } +} diff --git a/ClosedXML.Tests/Excel/CalcEngine/XLCalculationChainTests.cs b/ClosedXML.Tests/Excel/CalcEngine/XLCalculationChainTests.cs new file mode 100644 index 000000000..1576b906d --- /dev/null +++ b/ClosedXML.Tests/Excel/CalcEngine/XLCalculationChainTests.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClosedXML.Excel; +using ClosedXML.Excel.CalcEngine; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.CalcEngine +{ + [TestFixture] + public class XLCalculationChainTests + { + [Test] + public void Enumerating_empty_chain() + { + var chain = new XLCalculationChain(); + CollectionAssert.IsEmpty(GetPoints(chain)); + } + + [Test] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(40)] + public void Enumerating_whole_chain(int chainLength) + { + var chain = new XLCalculationChain(); + var expectedPoints = new List(); + for (var i = 0; i < chainLength; ++i) + { + var point = new XLBookPoint(1, new XLSheetPoint(1, i)); + chain.AddLast(point); + expectedPoints.Add(point); + } + + CollectionAssert.AreEqual(expectedPoints, GetPoints(chain)); + } + + [Test] + public void Remove_throws_on_missing_point() + { + var chain = new XLCalculationChain(); + + Assert.Throws( + () => chain.Remove(new XLBookPoint(1, new XLSheetPoint(1, 1)))); + } + + [Test] + public void Remove_link_from_chain() + { + var chain = new XLCalculationChain(); + var a1 = new XLBookPoint(1, new XLSheetPoint(1, 1)); + var b1 = new XLBookPoint(1, new XLSheetPoint(1, 2)); + var c1 = new XLBookPoint(1, new XLSheetPoint(1, 3)); + var d1 = new XLBookPoint(1, new XLSheetPoint(1, 4)); + + chain.AddLast(a1); + chain.AddLast(b1); + chain.AddLast(c1); + chain.AddLast(d1); + + // Remove point in the middle + chain.Remove(c1); + CollectionAssert.AreEqual(new[] { a1, b1, d1 }, GetPoints(chain)); + + // Remove last point in the sequence + chain.Remove(d1); + CollectionAssert.AreEqual(new[] { a1, b1 }, GetPoints(chain)); + + // Remove head + chain.Remove(a1); + CollectionAssert.AreEqual(new[] { b1 }, GetPoints(chain)); + + // Remove the only remaining + chain.Remove(b1); + CollectionAssert.IsEmpty(GetPoints(chain)); + } + + [Test] + public void AddAfter_adds_point() + { + var chain = new XLCalculationChain(); + var a1 = new XLBookPoint(1, new XLSheetPoint(1, 1)); + chain.AddLast(a1); + + // Add as tail for single link chain + var b1 = new XLBookPoint(1, new XLSheetPoint(1, 2)); + chain.AddAfter(a1, b1, 0); + CollectionAssert.AreEqual(new[] { a1, b1 }, GetPoints(chain)); + + // Add as tail for multi link chain + var c1 = new XLBookPoint(1, new XLSheetPoint(1, 3)); + chain.AddAfter(b1, c1, 0); + CollectionAssert.AreEqual(new[] { a1, b1, c1 }, GetPoints(chain)); + + // Add somewhere in the middle + var d1 = new XLBookPoint(1, new XLSheetPoint(1, 4)); + chain.AddAfter(b1, d1, 0); + CollectionAssert.AreEqual(new[] { a1, b1, d1, c1 }, GetPoints(chain)); + } + + [Test] + public void MoveToFront_moves_the_point_to_the_front() + { + var chain = new XLCalculationChain(); + var a1 = new XLBookPoint(1, new XLSheetPoint(1, 1)); + chain.AddLast(a1); + var b1 = new XLBookPoint(1, new XLSheetPoint(1, 2)); + chain.AddLast(b1); + var c1 = new XLBookPoint(1, new XLSheetPoint(1, 3)); + chain.AddLast(c1); + var d1 = new XLBookPoint(1, new XLSheetPoint(1, 4)); + chain.AddLast(d1); + + Assert.True(chain.MoveAhead()); + Assert.AreEqual(a1, chain.Current); + + // a,b,c,d -> d,a,b,c + chain.MoveToCurrent(d1); + Assert.AreEqual(d1, chain.Current); + Assert.AreEqual(new[] { d1, a1, b1, c1 }, GetPoints(chain)); + + // d,a,b,c -> b,d,a,c + chain.MoveToCurrent(b1); + Assert.AreEqual(b1, chain.Current); + Assert.AreEqual(new[] { b1, d1, a1, c1 }, GetPoints(chain)); + + Assert.True(chain.MoveAhead()); + Assert.AreEqual(d1, chain.Current); + Assert.AreEqual(new[] { b1, d1, a1, c1 }, GetPoints(chain)); + + // d,a,c -> a,d,c + chain.MoveToCurrent(a1); + Assert.AreEqual(a1, chain.Current); + Assert.AreEqual(new[] { b1, a1, d1, c1 }, GetPoints(chain)); + + // Move A1 to front when it's already at front + chain.MoveToCurrent(a1); + Assert.AreEqual(a1, chain.Current); + Assert.AreEqual(new[] { b1, a1, d1, c1 }, GetPoints(chain)); + + // a,d,c -> c,a,d + chain.MoveToCurrent(c1); + Assert.AreEqual(c1, chain.Current); + Assert.AreEqual(new[] { b1, c1, a1, d1 }, GetPoints(chain)); + + Assert.True(chain.MoveAhead()); + Assert.AreEqual(a1, chain.Current); + Assert.AreEqual(new[] { b1, c1, a1, d1 }, GetPoints(chain)); + + // a,d -> d,a + chain.MoveToCurrent(d1); + Assert.AreEqual(d1, chain.Current); + Assert.AreEqual(new[] { b1, c1, d1, a1 }, GetPoints(chain)); + + Assert.True(chain.MoveAhead()); + Assert.AreEqual(a1, chain.Current); + Assert.AreEqual(new[] { b1, c1, d1, a1 }, GetPoints(chain)); + + // a -> a + chain.MoveToCurrent(a1); + Assert.AreEqual(a1, chain.Current); + Assert.AreEqual(new[] { b1, c1, d1, a1 }, GetPoints(chain)); + + Assert.False(chain.MoveAhead()); + Assert.AreEqual(new[] { b1, c1, d1, a1 }, GetPoints(chain)); + } + + [Test] + public void Traversal_detects_cycles() + { + var chain = new XLCalculationChain(); + // `=C1+B1` + var a1 = new XLBookPoint(1, new XLSheetPoint(1, 1)); + chain.AddLast(a1); + // `=A1` + var b1 = new XLBookPoint(1, new XLSheetPoint(1, 2)); + chain.AddLast(b1); + // `=A1` + var c1 = new XLBookPoint(1, new XLSheetPoint(1, 3)); + chain.AddLast(c1); + + // Move to the first link. + Assert.True(chain.MoveAhead()); + + // Cycle a1, c1, when we first encounter c1, we don't know yet that it's a cycle + chain.MoveToCurrent(c1); + CollectionAssert.AreEqual(new[] { c1, a1, b1 }, GetPoints(chain)); + + // A1 is marked with a position, because they have been at the current + // C1 hasn't ben pushed back yet, so it keeps 0. + CollectionAssert.AreEqual(new[] { 0, 1, 0 }, GetPositions(chain)); + + // But then we get A1 again, without any other point being marked + // as done, therefore we are at cycle. + chain.MoveToCurrent(a1); + CollectionAssert.AreEqual(new[] { a1, c1, b1 }, GetPoints(chain)); + CollectionAssert.AreEqual(new[] { 1, 1, 0 }, GetPositions(chain)); + Assert.True(chain.IsCurrentInCycle); + + // When we encounter C1 again, it's obviously a cycle. + chain.MoveToCurrent(c1); + CollectionAssert.AreEqual(new[] { c1, a1, b1 }, GetPoints(chain)); + CollectionAssert.AreEqual(new[] { 1, 1, 0 }, GetPositions(chain)); + Assert.True(chain.IsCurrentInCycle); + + // Let's move on and get A1 to the current. Because the C1 has been + // marked as done, A1 is no longer in cycle. + chain.MoveAhead(); + CollectionAssert.AreEqual(new[] { c1, a1, b1 }, GetPoints(chain)); + + // C1 position has been cleared, because it has moved beyond + // current and A1 is now current. + CollectionAssert.AreEqual(new[] { 0, 1, 0 }, GetPositions(chain)); + + // A1 is no longer in a current, because current position is 2, but last position + // of A1 was 1 => there has been a processed node in the meantime. + Assert.False(chain.IsCurrentInCycle); + + chain.MoveToCurrent(b1); + CollectionAssert.AreEqual(new[] { c1, b1, a1 }, GetPoints(chain)); + CollectionAssert.AreEqual(new[] { 0, 0, 2 }, GetPositions(chain)); + Assert.False(chain.IsCurrentInCycle); + + chain.MoveToCurrent(a1); + CollectionAssert.AreEqual(new[] { c1, a1, b1 }, GetPoints(chain)); + CollectionAssert.AreEqual(new[] { 0, 2, 2 }, GetPositions(chain)); + Assert.True(chain.IsCurrentInCycle); + + chain.MoveAhead(); + CollectionAssert.AreEqual(new[] { c1, a1, b1 }, GetPoints(chain)); + CollectionAssert.AreEqual(new[] { 0, 0, 2 }, GetPositions(chain)); + Assert.False(chain.IsCurrentInCycle); + + chain.MoveAhead(); + CollectionAssert.AreEqual(new[] { c1, a1, b1 }, GetPoints(chain)); + CollectionAssert.AreEqual(new[] { 0, 0, 0 }, GetPositions(chain)); + } + + [Test] + public void Reset_clears_positions_ahead_of_current() + { + var chain = new XLCalculationChain(); + var a1 = new XLBookPoint(1, new XLSheetPoint(1, 1)); + chain.AddLast(a1); + var b1 = new XLBookPoint(1, new XLSheetPoint(1, 2)); + chain.AddLast(b1); + var c1 = new XLBookPoint(1, new XLSheetPoint(1, 3)); + chain.AddLast(c1); + + Assert.True(chain.MoveAhead()); + + chain.MoveToCurrent(b1); + chain.MoveToCurrent(a1); + Assert.True(chain.IsCurrentInCycle); + CollectionAssert.AreEqual(new[] { a1, b1, c1 }, GetPoints(chain)); + CollectionAssert.AreEqual(new[] { 1, 1, 0 }, GetPositions(chain)); + + chain.Reset(); + + CollectionAssert.AreEqual(new[] { 0, 0, 0 }, GetPositions(chain)); + } + + private static IEnumerable GetPoints(XLCalculationChain chain) + { + return chain.GetLinks().Select(x => x.Point); + } + + private static IEnumerable GetPositions(XLCalculationChain chain) + { + return chain.GetLinks().Select(x => x.LastPosition); + } + } +} diff --git a/ClosedXML.Tests/Excel/Cells/DataTypeTests.cs b/ClosedXML.Tests/Excel/Cells/DataTypeTests.cs deleted file mode 100644 index 26d2d8a3e..000000000 --- a/ClosedXML.Tests/Excel/Cells/DataTypeTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using ClosedXML.Excel; -using NUnit.Framework; -using System; - -namespace ClosedXML.Tests.Excel.Cells -{ - [TestFixture] - public class DataTypeTests - { - [Test] - public void ConvertNonNumericTextToNumberThrowsException() - { - using (var wb = new XLWorkbook()) - { - var ws = wb.Worksheets.Add("Sheet1"); - var c = ws.Cell("A1"); - c.Value = "ABC123"; - Assert.Throws(() => c.DataType = XLDataType.Number); - } - } - } -} diff --git a/ClosedXML.Tests/Excel/Cells/SharedStringTableTests.cs b/ClosedXML.Tests/Excel/Cells/SharedStringTableTests.cs new file mode 100644 index 000000000..e63fdd7e2 --- /dev/null +++ b/ClosedXML.Tests/Excel/Cells/SharedStringTableTests.cs @@ -0,0 +1,135 @@ +using System; +using System.Text; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Cells +{ + [TestFixture] + public class SharedStringTableTests + { + [Test] + public void SameStringIsNotStoredTwice() + { + using var wb = new XLWorkbook(); + var ws1 = wb.AddWorksheet(); + var ws2 = wb.AddWorksheet(); + var txt1 = "Hello"; + var txt2 = new StringBuilder("Hel").Append("lo").ToString(); + Assert.AreNotSame(txt1, txt2); + + ws1.Cell(1, 1).Value = txt1; + ws2.Cell(1, 1).Value = txt2; + + Assert.AreSame(ws1.Cell(1, 1).Value.GetText(), ws2.Cell(1, 1).Value.GetText()); + } + + [Test] + public void CanAccessTextThroughId() + { + var sst = new SharedStringTable(); + var id = sst.IncreaseRef("test", false); + Assert.AreEqual("test", sst[id]); + Assert.AreEqual(1, sst.Count); + } + + [Test] + public void TextsWithoutReferenceAreRemoved() + { + var sst = new SharedStringTable(); + var id = sst.IncreaseRef("test", false); + sst.DecreaseRef(id); + + Assert.AreEqual(0, sst.Count); + Assert.That(() => _ = sst[id], Throws.ArgumentException.With.Message.EqualTo("Id 0 has no text.")); + } + + [Test] + public void TextReferencedByMultipleThingsIsNotFreedUntilAllAreRelease() + { + const string text = "test"; + var sst = new SharedStringTable(); + var id = sst.IncreaseRef(text, false); + + sst.IncreaseRef(text, false); + Assert.AreEqual(text, sst[id]); + Assert.AreEqual(1, sst.Count); + + sst.DecreaseRef(id); + Assert.AreEqual(text, sst[id]); + Assert.AreEqual(1, sst.Count); + + sst.IncreaseRef(text, false); + Assert.AreEqual(text, sst[id]); + Assert.AreEqual(1, sst.Count); + + sst.DecreaseRef(id); + Assert.AreEqual(text, sst[id]); + Assert.AreEqual(1, sst.Count); + + sst.DecreaseRef(id); + Assert.Throws(() => _ = sst[id]); + } + + [Test] + public void FreedIdCanBeReusedForDifferentText() + { + var sst = new SharedStringTable(); + sst.IncreaseRef("zero", false); + var originalId = sst.IncreaseRef("original", false); + var laterId = sst.IncreaseRef("two", false); + + Assert.That(laterId, Is.GreaterThan(originalId)); + + sst.DecreaseRef(originalId); + Assert.Throws(() => _ = sst[originalId]); + + var replacementId = sst.IncreaseRef("replacement", false); + Assert.AreEqual(originalId, replacementId); + Assert.AreEqual("replacement", sst[replacementId]); + } + + [Test] + public void DereferencingFreedIdThrows() + { + var sst = new SharedStringTable(); + var id = sst.IncreaseRef("test", false); + sst.DecreaseRef(id); + Assert.Throws(() => sst.DecreaseRef(id)); + } + + [Test] + public void StringItem_without_text_is_loaded_as_empty_text() + { + // PR#2218: A text cell that references self-closed tag in SST is loaded without + // an error and is loaded as type TEXT. Although it's not very common, empty string is + // a valid value of a cell. + TestHelper.LoadAndAssert((_, ws) => + { + // Check that type is a empty string, just like in Excel. + Assert.AreEqual(2, ws.Evaluate("TYPE(B2)")); + Assert.IsEmpty(ws.Cell("B2").GetText()); + }, @"Other\Cells\EmptySi.xlsx"); + } + + [Test] + public void Empty_text_is_written_and_loaded_to_sst() + { + TestHelper.CreateSaveLoadAssert( + (_, ws) => + { + ws.Cell("A1").Value = "Empty text cell (B1):"; + ws.Cell("B1").Value = string.Empty; + + ws.Cell("A2").Value = "Empty rich text"; + ws.Cell("B2").CreateRichText().AddText(string.Empty); + }, + (_, ws) => + { + Assert.AreEqual("", ws.Cell("B1").CachedValue); + Assert.AreEqual("", ws.Cell("B2").GetRichText().Text); + }, + @"Other\Cells\EmptyText.xlsx"); + } + } +} diff --git a/ClosedXML.Tests/Excel/Cells/SliceTests.cs b/ClosedXML.Tests/Excel/Cells/SliceTests.cs new file mode 100644 index 000000000..124e7cde0 --- /dev/null +++ b/ClosedXML.Tests/Excel/Cells/SliceTests.cs @@ -0,0 +1,236 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Cells +{ + [TestFixture] + public class SliceTests + { + [Test] + public void Stores_Values() + { + var slice = new Slice(); + var point = new XLSheetPoint(574, 241); + slice.Set(point, 1); + Assert.AreEqual(1, slice[point]); + } + + [Test] + public void Setting_Value_To_Default_Clears_Element() + { + var slice = new Slice(); + var point = new XLSheetPoint(574, 241); + slice.Set(point, 1); + Assert.AreEqual(574, slice.MaxRow); + Assert.AreEqual(241, slice.MaxColumn); + + slice.Set(point, 0); + + Assert.AreEqual(0, slice.MaxRow); + Assert.AreEqual(0, slice.MaxColumn); + } + + [Test] + public void Keeps_Track_Of_Max_Used_Coordinates() + { + var slice = new Slice(); + slice.Set(54, 32, 1); + slice.Set(140, 32, 1); + slice.Set(140, 72, 1); + + Assert.AreEqual(140, slice.MaxRow); + Assert.AreEqual(72, slice.MaxColumn); + + slice.Set(140, 72, 0); + + Assert.AreEqual(140, slice.MaxRow); + Assert.AreEqual(32, slice.MaxColumn); + + slice.Set(140, 32, 0); + + Assert.AreEqual(54, slice.MaxRow); + Assert.AreEqual(32, slice.MaxColumn); + + slice.Set(54, 32, 0); + + Assert.AreEqual(0, slice.MaxRow); + Assert.AreEqual(0, slice.MaxColumn); + } + + [Test] + public void Keeps_Track_Of_Used_Rows() + { + var slice = new Slice(); + Assert.IsEmpty(slice.UsedRows); + + slice.Set(new XLSheetPoint(1, 1), 1); + CollectionAssert.AreEquivalent(new[] { 1 }, slice.UsedRows); + + slice.Set(new XLSheetPoint(70, 1), 1); + CollectionAssert.AreEquivalent(new[] { 1, 70 }, slice.UsedRows); + + slice.Set(new XLSheetPoint(35, 1), 1); + CollectionAssert.AreEquivalent(new[] { 1, 35, 70 }, slice.UsedRows); + + slice.Set(new XLSheetPoint(35, 2), 1); + CollectionAssert.AreEquivalent(new[] { 1, 35, 70 }, slice.UsedRows); + + slice.Set(new XLSheetPoint(35, 1), 0); + CollectionAssert.AreEquivalent(new[] { 1, 35, 70 }, slice.UsedRows); + + slice.Set(new XLSheetPoint(35, 2), 0); + CollectionAssert.AreEquivalent(new[] { 1, 70 }, slice.UsedRows); + + slice.Set(new XLSheetPoint(1, 1), 0); + CollectionAssert.AreEquivalent(new[] { 70 }, slice.UsedRows); + + slice.Set(new XLSheetPoint(70, 1), 0); + Assert.IsEmpty(slice.UsedRows); + } + + [Test] + public void Keeps_Track_Of_Used_Columns() + { + var slice = new Slice(); + Assert.IsEmpty(slice.UsedColumns); + + slice.Set(new XLSheetPoint(1, 5), 1); + CollectionAssert.AreEquivalent(new[] { 5 }, slice.UsedColumns); + + slice.Set(new XLSheetPoint(1, 750), 1); + CollectionAssert.AreEquivalent(new[] { 5, 750 }, slice.UsedColumns); + + slice.Set(new XLSheetPoint(1, 90), 1); + CollectionAssert.AreEquivalent(new[] { 5, 90, 750 }, slice.UsedColumns); + + slice.Set(new XLSheetPoint(2, 5), 1); + CollectionAssert.AreEquivalent(new[] { 5, 90, 750 }, slice.UsedColumns); + + slice.Set(new XLSheetPoint(1, 5), 0); + CollectionAssert.AreEquivalent(new[] { 5, 90, 750 }, slice.UsedColumns); + + slice.Set(new XLSheetPoint(2, 5), 0); + CollectionAssert.AreEquivalent(new[] { 90, 750 }, slice.UsedColumns); + + slice.Set(new XLSheetPoint(1, 750), 0); + CollectionAssert.AreEquivalent(new[] { 90 }, slice.UsedColumns); + + slice.Set(new XLSheetPoint(1, 90), 0); + Assert.IsEmpty(slice.UsedColumns); + } + + [Test] + public void Clear_Range_Sets_Values_To_Default() + { + var slice = new Slice(); + var outsideAddress = new XLSheetPoint(1, 1); + slice.Set(outsideAddress, 1); + var firstCorner = new XLSheetPoint(50, 20); + slice.Set(firstCorner, 1); + var insideAddress = new XLSheetPoint(55, 22); + slice.Set(insideAddress, 1); + var lastCorner = new XLSheetPoint(60, 30); + slice.Set(lastCorner, 1); + + slice.Clear(new XLSheetRange(firstCorner, lastCorner)); + Assert.AreEqual(1, slice[outsideAddress]); + Assert.AreEqual(0, slice[firstCorner]); + Assert.AreEqual(0, slice[insideAddress]); + Assert.AreEqual(0, slice[lastCorner]); + } + + [Test] + public void InsertAreaAndShiftDown_Moves_Area_Cells_Down_And_Purges_Values_Outside_Worksheet() + { + var slice = new Slice(); + slice.Set(1, 1, 1); + slice.Set(3, 1, 2); + var purgedAddress = new XLSheetPoint(XLHelper.MaxRowNumber, 2); + slice.Set(purgedAddress, 3); + + var outsideAddress = new XLSheetPoint(1, 3); + slice.Set(outsideAddress, 4); + + slice.InsertAreaAndShiftDown(new XLSheetRange(new XLSheetPoint(1, 1), new XLSheetPoint(2, 2))); + + Assert.AreEqual(1, slice[3, 1]); + Assert.AreEqual(2, slice[5, 1]); + Assert.AreEqual(0, slice[XLHelper.MaxRowNumber, 2]); + Assert.AreEqual(4, slice[outsideAddress]); + } + + [Test] + public void InsertAreaAndShiftRight_Moves_Area_Cells_Down_And_Purges_Values_Outside_Worksheet() + { + var slice = new Slice(); + slice.Set(1, 1, 1); + slice.Set(1, 3, 2); + var purgedAddress = new XLSheetPoint(2, XLHelper.MaxColumnNumber); + slice.Set(purgedAddress, 3); + + var outsideAddress = new XLSheetPoint(3, 1); + slice.Set(outsideAddress, 4); + + slice.InsertAreaAndShiftRight(new XLSheetRange(new XLSheetPoint(1, 1), new XLSheetPoint(2, 2))); + + Assert.AreEqual(1, slice[1, 3]); + Assert.AreEqual(2, slice[1, 5]); + Assert.AreEqual(0, slice[purgedAddress]); + Assert.AreEqual(4, slice[outsideAddress]); + } + + [Test] + public void DeleteAreaAndShiftUp_Moves_Area_Cells_Up() + { + var slice = new Slice(); + var aboveAddress = new XLSheetPoint(1, 3); + slice.Set(aboveAddress, 1); + var firstCorner = new XLSheetPoint(2, 2); + slice.Set(firstCorner, 2); + var secondCorner = new XLSheetPoint(4, 5); + slice.Set(secondCorner, 3); + var rightAddress = new XLSheetPoint(3, 6); + slice.Set(rightAddress, 4); + var belowAddress = new XLSheetPoint(5, 3); + slice.Set(belowAddress, 5); + var leftAddress = new XLSheetPoint(3, 1); + slice.Set(leftAddress, 6); + + var deleteArea = new XLSheetRange(firstCorner, secondCorner); + slice.DeleteAreaAndShiftUp(deleteArea); + Assert.AreEqual(0, slice[firstCorner]); + Assert.AreEqual(0, slice[secondCorner]); + Assert.AreEqual(5, slice[belowAddress.Row - deleteArea.Height, belowAddress.Column]); + Assert.AreEqual(1, slice[aboveAddress]); + Assert.AreEqual(4, slice[rightAddress]); + Assert.AreEqual(6, slice[leftAddress]); + } + + [Test] + public void DeleteAreaAndShiftLeft_Moves_Area_Cells_Left() + { + var slice = new Slice(); + var leftAddress = new XLSheetPoint(3, 1); + slice.Set(leftAddress, 1); + var firstCorner = new XLSheetPoint(2, 2); + slice.Set(firstCorner, 2); + var secondCorner = new XLSheetPoint(5, 4); + slice.Set(secondCorner, 3); + var belowAddress = new XLSheetPoint(6, 3); + slice.Set(belowAddress, 4); + var rightAddress = new XLSheetPoint(3, 5); + slice.Set(rightAddress, 5); + var aboveAddress = new XLSheetPoint(1, 3); + slice.Set(aboveAddress, 6); + + var deleteArea = new XLSheetRange(firstCorner, secondCorner); + slice.DeleteAreaAndShiftLeft(deleteArea); + Assert.AreEqual(0, slice[firstCorner]); + Assert.AreEqual(0, slice[secondCorner]); + Assert.AreEqual(5, slice[rightAddress.Row, rightAddress.Column - deleteArea.Width]); + Assert.AreEqual(1, slice[leftAddress]); + Assert.AreEqual(4, slice[belowAddress]); + Assert.AreEqual(6, slice[aboveAddress]); + } + } +} diff --git a/ClosedXML.Tests/Excel/Cells/ValueSliceTests.cs b/ClosedXML.Tests/Excel/Cells/ValueSliceTests.cs new file mode 100644 index 000000000..a3989154a --- /dev/null +++ b/ClosedXML.Tests/Excel/Cells/ValueSliceTests.cs @@ -0,0 +1,116 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Cells +{ + [TestFixture] + public class ValueSliceTests + { + [Test] + public void Deleting_worksheet_dereferences_all_texts_in_its_value_slice() + { + using var wb = new XLWorkbook(); + var sst = wb.SharedStringTable; + var keptWs = wb.AddWorksheet(); + var removedWs = wb.AddWorksheet(); + keptWs.Cell("A1").Value = "Double referenced text"; + removedWs.Cell("A1").Value = "Double referenced text"; + removedWs.Cell("B1").Value = "Single referenced text"; + + Assert.AreEqual(2, sst.Count); + + wb.Worksheets.Delete(removedWs.Name); + + Assert.AreEqual(1, sst.Count); + Assert.AreEqual("Double referenced text", keptWs.Cell(1, 1).Value); + } + + [Test] + public void Clear_dereferences_texts_in_the_range() + { + using var wb = new XLWorkbook(); + var sst = wb.SharedStringTable; + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = "Double referenced text"; + ws.Cell("B2").Value = "Double referenced text"; + ws.Cell("C2").Value = "Single referenced text"; + + Assert.AreEqual(2, sst.Count); + ((XLWorksheet)ws).Internals.CellsCollection.ValueSlice.Clear(new XLSheetRange(2, 2, 2, 3)); + Assert.AreEqual(1, sst.Count); + Assert.AreEqual("Double referenced text", ws.Cell("A1").Value); + } + + [Test] + public void DeleteAreaAndShiftLeft_dereferences_all_texts_deleted_area() + { + using var wb = new XLWorkbook(); + var sst = wb.SharedStringTable; + var ws = wb.AddWorksheet(); + ws.Cell("B2").Value = "Deleted Single Reference"; // id 0 + ws.Cell("C1").Value = "Kept Single Reference"; // id 1 + ws.Cell("B1").Value = "Kept Double Reference"; // id 2 + ws.Cell("C3").Value = "Kept Double Reference"; // id 2 + + ((XLWorksheet)ws).Internals.CellsCollection.ValueSlice.DeleteAreaAndShiftLeft(new XLSheetRange(2, 2, 3, 3)); + + Assert.AreEqual(2, sst.Count); + Assert.AreEqual("Kept Single Reference", sst[1]); + Assert.AreEqual("Kept Double Reference", sst[2]); + } + + [Test] + public void DeleteAreaAndShiftUp_dereferences_all_texts_deleted_area() + { + using var wb = new XLWorkbook(); + var sst = wb.SharedStringTable; + var ws = wb.AddWorksheet(); + ws.Cell("B2").Value = "Deleted Single Reference"; // id 0 + ws.Cell("A3").Value = "Kept Single Reference"; // id 1 + ws.Cell("A2").Value = "Kept Double Reference"; // id 2 + ws.Cell("C3").Value = "Kept Double Reference"; // id 2 + + ((XLWorksheet)ws).Internals.CellsCollection.ValueSlice.DeleteAreaAndShiftLeft(new XLSheetRange(2, 2, 3, 3)); + + Assert.AreEqual(2, sst.Count); + Assert.AreEqual("Kept Single Reference", sst[1]); + Assert.AreEqual("Kept Double Reference", sst[2]); + } + + [Test] + public void InsertAreaAndShiftDown_dereferences_all_texts_in_pushed_out_range() + { + using var wb = new XLWorkbook(); + var sst = wb.SharedStringTable; + var ws = wb.AddWorksheet(); + + ws.Cell("B2").Value = "Kept Single Reference"; // id 0 + ws.Cell("C1048576").Value = "Deleted Single Reference"; // id 1 + ws.Cell("B1048574").Value = "Kept Double Reference"; // id 2 + ws.Cell("B1048576").Value = "Kept Double Reference"; // id 2 + ((XLWorksheet)ws).Internals.CellsCollection.ValueSlice.InsertAreaAndShiftDown(new XLSheetRange(3, 2, 4, 3)); + + Assert.AreEqual(2, sst.Count); + Assert.AreEqual("Kept Single Reference", sst[0]); + Assert.AreEqual("Kept Double Reference", sst[2]); + } + + [Test] + public void InsertAreaAndShiftRight_dereferences_all_texts_in_pushed_out_range() + { + using var wb = new XLWorkbook(); + var sst = wb.SharedStringTable; + var ws = wb.AddWorksheet(); + + ws.Cell("B2").Value = "Kept Single Reference"; // id 0 + ws.Cell("XFD2").Value = "Deleted Single Reference"; // id 1 + ws.Cell("XFD3").Value = "Kept Double Reference"; // id 2 + ws.Cell("XFB3").Value = "Kept Double Reference"; // id 2 + ((XLWorksheet)ws).Internals.CellsCollection.ValueSlice.InsertAreaAndShiftRight(new XLSheetRange(2, 3, 3, 4)); + + Assert.AreEqual(2, sst.Count); + Assert.AreEqual("Kept Single Reference", sst[0]); + Assert.AreEqual("Kept Double Reference", sst[2]); + } + } +} diff --git a/ClosedXML.Tests/Excel/Cells/XLCellFormulaTests.cs b/ClosedXML.Tests/Excel/Cells/XLCellFormulaTests.cs new file mode 100644 index 000000000..44dd06144 --- /dev/null +++ b/ClosedXML.Tests/Excel/Cells/XLCellFormulaTests.cs @@ -0,0 +1,26 @@ +using NUnit.Framework; +using ClosedXML.Excel; + +namespace ClosedXML.Tests.Excel.Cells +{ + [TestFixture] + public class XLCellFormulaTests + { + [Test] + public void CellFormulaIsStrippedOfEqualSign() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell(1, 1).FormulaA1 = "=B1"; + Assert.AreEqual("B1", ws.Cell(1, 1).FormulaA1); + } + + [Test] + public void DataTable_MaintainProperties() + { + TestHelper.LoadSaveAndCompare( + @"Other\Formulas\DataTableFormula-Excel-Input.xlsx", + @"Other\Formulas\DataTableFormula-Output.xlsx"); + } + } +} diff --git a/ClosedXML.Tests/Excel/Cells/XLCellTests.cs b/ClosedXML.Tests/Excel/Cells/XLCellTests.cs index 9d1ab6140..d333707ec 100644 --- a/ClosedXML.Tests/Excel/Cells/XLCellTests.cs +++ b/ClosedXML.Tests/Excel/Cells/XLCellTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Data; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -13,6 +14,22 @@ namespace ClosedXML.Tests [TestFixture] public class XLCellTests { + [SuppressMessage("ReSharper", "RedundantCast")] + private static readonly object[] AllNumberTypes = + { + (sbyte)1, + (byte)2, + (short)3, + (ushort)4, + (int)5, + (uint)6, + (long)7, + (ulong)8, + (float)9.5f, + (double)10.75, + (decimal)11.875m + }; + [Test] public void CellsUsed() { @@ -61,46 +78,10 @@ public void CellUsedIncludesSparklines() ws.SparklineGroups.Add("B2", "C3:E3"); ws.SparklineGroups.Add("F5", "C4:E4"); - var range = ws.RangeUsed(true).RangeAddress.ToString(); + var range = ws.RangeUsed(XLCellsUsedOptions.All).RangeAddress.ToString(); Assert.AreEqual("B2:F5", range); } - [Test] - public void Double_Infinity_is_a_string() - { - IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - IXLCell cell = ws.Cell("A1"); - var doubleList = new List { 1.0 / 0.0 }; - - cell.Value = 5; - cell.Value = doubleList; - Assert.AreEqual(XLDataType.Text, cell.DataType); - Assert.AreEqual(CultureInfo.CurrentCulture.NumberFormat.PositiveInfinitySymbol, cell.Value); - - cell.Value = 5; - cell.SetValue(doubleList); - Assert.AreEqual(XLDataType.Text, cell.DataType); - Assert.AreEqual(CultureInfo.CurrentCulture.NumberFormat.PositiveInfinitySymbol, cell.Value); - } - - [Test] - public void Double_NaN_is_a_string() - { - IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - IXLCell cell = ws.Cell("A1"); - var doubleList = new List { 0.0 / 0.0 }; - - cell.Value = 5; - cell.Value = doubleList; - Assert.AreEqual(XLDataType.Text, cell.DataType); - Assert.AreEqual(CultureInfo.CurrentCulture.NumberFormat.NaNSymbol, cell.Value); - - cell.Value = 5; - cell.SetValue(doubleList); - Assert.AreEqual(XLDataType.Text, cell.DataType); - Assert.AreEqual(CultureInfo.CurrentCulture.NumberFormat.NaNSymbol, cell.Value); - } - [Test] public void GetValue_Nullable() { @@ -108,9 +89,9 @@ public void GetValue_Nullable() Assert.IsNull(cell.Clear().GetValue()); Assert.AreEqual(1.5, cell.SetValue(1.5).GetValue()); - Assert.AreEqual(2, cell.SetValue(1.5).GetValue()); - Assert.AreEqual(2.5, cell.SetValue(2.5.ToString(CultureInfo.CurrentCulture)).GetValue()); - Assert.Throws(() => cell.SetValue("text").GetValue()); + Assert.AreEqual(2, cell.SetValue(2).GetValue()); + Assert.IsNull(cell.SetValue(Blank.Value).GetValue()); + Assert.Throws(() => cell.SetValue("text").GetValue()); } [Test] @@ -122,7 +103,7 @@ public void InsertData1() } [Test] - public void InsertData2() + public void InsertData_DoesntTransposeDataOnFalseFlag() { IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); IXLRange range = ws.Cell(2, 2).InsertData(new[] { "a", "b", "c" }, false); @@ -130,13 +111,30 @@ public void InsertData2() } [Test] - public void InsertData3() + public void InsertData_TransposesDataOnTrueFlag() { IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); IXLRange range = ws.Cell(2, 2).InsertData(new[] { "a", "b", "c" }, true); Assert.AreEqual("Sheet1!B2:D2", range.ToString()); } + [Test] + public void InsertData_DifferentTypes() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + object[] values = { "Text", 45, DateTime.Today, true, "More text" }; + + ws.FirstCell().InsertData(values); + + Assert.AreEqual("Text", ws.FirstCell().GetString()); + Assert.AreEqual(45, ws.Cell("A2").GetDouble()); + Assert.AreEqual(DateTime.Today, ws.Cell("A3").GetDateTime()); + Assert.AreEqual(true, ws.Cell("A4").GetBoolean()); + Assert.AreEqual("More text", ws.Cell("A5").GetString()); + Assert.IsTrue(ws.Cell("A6").IsEmpty()); + } + [Test] public void InsertData_with_Guids() { @@ -144,7 +142,7 @@ public void InsertData_with_Guids() ws.FirstCell().InsertData(Enumerable.Range(1, 20).Select(i => new { Guid = Guid.NewGuid() })); Assert.AreEqual(XLDataType.Text, ws.FirstCell().DataType); - Assert.AreEqual(Guid.NewGuid().ToString().Length, ws.FirstCell().GetString().Length); + Assert.AreEqual(Guid.NewGuid().ToString().Length, ws.FirstCell().GetText().Length); } [Test] @@ -189,7 +187,46 @@ public void InsertData_with_Nulls_IEnumerable() ws.FirstCell().InsertData(dateTimeList); Assert.AreEqual(new DateTime(2000, 1, 1), ws.Cell("A1").GetDateTime()); - Assert.AreEqual(String.Empty, ws.Cell("A5").Value); + Assert.AreEqual(Blank.Value, ws.Cell("A5").Value); + } + + [Test] + public void InsertData_AllNumberTypes_AreInsertedAsNumbers() + { + var ws = new XLWorkbook().Worksheets.Add(); + + ws.FirstCell().InsertData(AllNumberTypes); + + for (var row = 1; row <= AllNumberTypes.Length; ++row) + { + var expectedValue = Convert.ChangeType(AllNumberTypes[row - 1], typeof(double)); + var actualValue = ws.Cell(row, 1).Value; + Assert.AreEqual(expectedValue, actualValue); + } + } + + [Test] + public void InsertTable_AllNumberTypes_AreInsertedAsNumbers() + { + var ws = new XLWorkbook().Worksheets.Add(); + + var table = new DataTable("Numbers"); + foreach (var number in AllNumberTypes) + { + var numberType = number.GetType(); + table.Columns.Add(numberType.Name, numberType); + } + + table.Rows.Add(AllNumberTypes); + + ws.FirstCell().InsertTable(table); + + for (var column = 1; column <= AllNumberTypes.Length; ++column) + { + var expectedValue = Convert.ChangeType(AllNumberTypes[column - 1], typeof(double)); + var actualValue = ws.Cell(2, column).Value; + Assert.AreEqual(expectedValue, actualValue); + } } [Test] @@ -296,13 +333,13 @@ public void TryGetValue_Boolean_False() } [Test] - public void TryGetValue_Boolean_Good() + public void TryGetValue_Boolean_FalseText() { IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - IXLCell cell = ws.Cell("A1").SetValue("True"); - bool success = cell.TryGetValue(out bool outValue); + IXLCell cell = ws.Cell("A1").SetValue("False"); + var success = cell.TryGetValue(out Boolean outValue); Assert.IsTrue(success); - Assert.IsTrue(outValue); + Assert.IsFalse(outValue); } [Test] @@ -316,13 +353,13 @@ public void TryGetValue_Boolean_True() } [Test] - public void TryGetValue_DateTime_Good() + public void TryGetValue_Boolean_TrueText() { IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - var date = "2018-01-01"; - bool success = ws.Cell("A1").SetValue(date).TryGetValue(out DateTime outValue); + IXLCell cell = ws.Cell("A1").SetValue("True"); + var success = cell.TryGetValue(out bool outValue); Assert.IsTrue(success); - Assert.AreEqual(new DateTime(2018, 1, 1), outValue); + Assert.IsTrue(outValue); } [Test] @@ -358,12 +395,12 @@ public void TryGetValue_DateTime_BadString() } [Test] - public void TryGetValue_DateTime_BadString2() + public void TryGetValue_DateTime_SerialDateTimeOutsideRange() { IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - var date = 5545454; - ws.FirstCell().SetValue(date).DataType = XLDataType.DateTime; - bool success = ws.FirstCell().TryGetValue(out DateTime outValue); + var serialDateTimeOutsideRange = 5545454; + ws.FirstCell().SetValue(serialDateTimeOutsideRange); + bool success = ws.FirstCell().TryGetValue(out DateTime _); Assert.IsFalse(success); } @@ -371,11 +408,11 @@ public void TryGetValue_DateTime_BadString2() public void TryGetValue_Enum_Good() { var ws = new XLWorkbook().AddWorksheet(); - Assert.IsTrue(ws.FirstCell().SetValue(NumberStyles.AllowCurrencySymbol).TryGetValue(out NumberStyles value)); + Assert.IsTrue(ws.FirstCell().SetValue(nameof(NumberStyles.AllowCurrencySymbol)).TryGetValue(out NumberStyles value)); Assert.AreEqual(NumberStyles.AllowCurrencySymbol, value); // Nullable alternative - Assert.IsTrue(ws.FirstCell().SetValue(NumberStyles.AllowCurrencySymbol).TryGetValue(out NumberStyles? value2)); + Assert.IsTrue(ws.FirstCell().SetValue(nameof(NumberStyles.AllowCurrencySymbol)).TryGetValue(out NumberStyles? value2)); Assert.AreEqual(NumberStyles.AllowCurrencySymbol, value2); } @@ -387,28 +424,6 @@ public void TryGetValue_Enum_BadString() Assert.IsFalse(ws.FirstCell().SetValue("ABC").TryGetValue(out NumberStyles? value2)); } - [Test] - public void TryGetValue_RichText_Bad() - { - IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - IXLCell cell = ws.Cell("A1").SetValue("Anything"); - bool success = cell.TryGetValue(out IXLRichText outValue); - Assert.IsTrue(success); - Assert.AreEqual(cell.GetRichText(), outValue); - Assert.AreEqual("Anything", outValue.ToString()); - } - - [Test] - public void TryGetValue_RichText_Good() - { - IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - IXLCell cell = ws.Cell("A1"); - cell.GetRichText().AddText("Anything"); - bool success = cell.TryGetValue(out IXLRichText outValue); - Assert.IsTrue(success); - Assert.AreEqual(cell.GetRichText(), outValue); - } - [Test] public void TryGetValue_TimeSpan_BadString() { @@ -429,32 +444,32 @@ public void TryGetValue_TimeSpan_Good() } [Test] - public void TryGetValue_TimeSpan_Good_Large() + public void TryGetValue_TimeSpan_Good2() { IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - var timeSpan = TimeSpan.FromMilliseconds((double)int.MaxValue + 1); - bool success = ws.Cell("A1").SetValue(timeSpan).TryGetValue(out TimeSpan outValue); + bool success = ws.Cell("A1").SetValue(0.0034722222222222199).TryGetValue(out TimeSpan outValue); Assert.IsTrue(success); - Assert.AreEqual(timeSpan, outValue); + Assert.AreEqual(TimeSpan.FromMinutes(5), outValue); } [Test] - public void TryGetValue_TimeSpan_GoodString() + public void TryGetValue_TimeSpan_Good_Large() { IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - var timeSpan = new TimeSpan(1, 1, 1); - bool success = ws.Cell("A1").SetValue(timeSpan.ToString()).TryGetValue(out TimeSpan outValue); + var timeSpan = TimeSpan.FromMilliseconds((double)int.MaxValue + 1); + bool success = ws.Cell("A1").SetValue(timeSpan).TryGetValue(out TimeSpan outValue); Assert.IsTrue(success); Assert.AreEqual(timeSpan, outValue); } [Test] - public void TryGetValue_sbyte_Bad() + [SetCulture("en-US")] + public void TryGetValue_TimeSpan_Good_FromText() { IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - IXLCell cell = ws.Cell("A1").SetValue(255); - bool success = cell.TryGetValue(out sbyte outValue); - Assert.IsFalse(success); + bool success = ws.Cell("A1").SetValue("300:14:50.453").TryGetValue(out TimeSpan outValue); + Assert.IsTrue(success); + Assert.AreEqual(new TimeSpan(12, 12, 14, 50, 453), outValue); } [Test] @@ -476,58 +491,6 @@ public void TryGetValue_sbyte_Good() Assert.AreEqual(5, outValue); } - [Test] - public void TryGetValue_sbyte_Good2() - { - IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - IXLCell cell = ws.Cell("A1").SetValue("5"); - bool success = cell.TryGetValue(out sbyte outValue); - Assert.IsTrue(success); - Assert.AreEqual(5, outValue); - } - - [Test] - public void TryGetValue_decimal_Good() - { - var ws = new XLWorkbook().Worksheets.Add("Sheet1"); - var cell = ws.Cell("A1").SetValue("5"); - bool success = cell.TryGetValue(out decimal outValue); - Assert.IsTrue(success); - Assert.AreEqual(5, outValue); - } - - [Test] - public void TryGetValue_decimal_Good2() - { - Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US"); - - var ws = new XLWorkbook().Worksheets.Add("Sheet1"); - var cell = ws.Cell("A1").SetValue("1.60000001869776E-06"); - bool success = cell.TryGetValue(out decimal outValue); - Assert.IsTrue(success); - Assert.AreEqual(1.60000001869776E-06, outValue); - } - - [Test] - public void TryGetValue_Hyperlink() - { - using (var wb = new XLWorkbook()) - { - var ws1 = wb.Worksheets.Add("Sheet1"); - var ws2 = wb.Worksheets.Add("Sheet2"); - - var targetCell = ws2.Cell("A1"); - - var linkCell1 = ws1.Cell("A1"); - linkCell1.Value = "Link to IXLCell"; - linkCell1.SetHyperlink(new XLHyperlink(targetCell)); - - var success = linkCell1.TryGetValue(out XLHyperlink hyperlink); - Assert.IsTrue(success); - Assert.AreEqual("Sheet2!A1", hyperlink.InternalAddress); - } - } - [Test] public void TryGetValue_Unicode_String() { @@ -561,65 +524,14 @@ public void TryGetValue_Nullable() ws.Cell("A3").SetValue(2.5.ToString(CultureInfo.CurrentCulture)); ws.Cell("A4").SetValue("text"); - foreach (var cell in ws.Range("A1:A3").Cells()) - { - Assert.IsTrue(cell.TryGetValue(out double? value)); - } - + Assert.IsTrue(ws.Cell("A1").TryGetValue(out double? _)); + Assert.IsTrue(ws.Cell("A2").TryGetValue(out double? _)); + Assert.IsTrue(ws.Cell("A3").TryGetValue(out double? _)); Assert.IsFalse(ws.Cell("A4").TryGetValue(out double? _)); } - [TestCase(2019, 11, 5, 11, 30, 5, 0, ExpectedResult = "2019-11-05 11:30:05.000")] - [TestCase(2019, 11, 5, 11, 30, 5, 2, ExpectedResult = "2019-11-05 11:30:05.000")] - [TestCase(2019, 11, 5, 11, 30, 5, -10, ExpectedResult = "2019-11-05 11:30:05.000")] - public string ValueSetDateTimeOffset(int year, int month, int days, int hours, int minutes, int seconds, int offsetInHours) - { - var cell = new XLWorkbook().Worksheets.Add("Sheet1").FirstCell(); - - cell.Value = new DateTimeOffset(year, month, days, hours, minutes, seconds, new TimeSpan(offsetInHours * TimeSpan.TicksPerHour)); - - // C# Supports 7 digits milliseconds, but excel only 3 - const string format = "yyyy-MM-dd HH:mm:ss.fff"; - - return cell.GetDateTime().ToString(format); - } - [Test] - public void SetCellValueToGuid() - { - var ws = new XLWorkbook().AddWorksheet("Sheet1"); - var guid = Guid.NewGuid(); - ws.FirstCell().Value = guid; - Assert.AreEqual(XLDataType.Text, ws.FirstCell().DataType); - Assert.AreEqual(guid.ToString(), ws.FirstCell().Value); - Assert.AreEqual(guid.ToString(), ws.FirstCell().GetString()); - - guid = Guid.NewGuid(); - ws.FirstCell().SetValue(guid); - Assert.AreEqual(XLDataType.Text, ws.FirstCell().DataType); - Assert.AreEqual(guid.ToString(), ws.FirstCell().Value); - Assert.AreEqual(guid.ToString(), ws.FirstCell().GetString()); - } - - [Test] - public void SetCellValueToEnum() - { - var ws = new XLWorkbook().AddWorksheet("Sheet1"); - var dataType = XLDataType.Number; - ws.FirstCell().Value = dataType; - Assert.AreEqual(XLDataType.Text, ws.FirstCell().DataType); - Assert.AreEqual(dataType.ToString(), ws.FirstCell().Value); - Assert.AreEqual(dataType.ToString(), ws.FirstCell().GetString()); - - dataType = XLDataType.TimeSpan; - ws.FirstCell().SetValue(dataType); - Assert.AreEqual(XLDataType.Text, ws.FirstCell().DataType); - Assert.AreEqual(dataType.ToString(), ws.FirstCell().Value); - Assert.AreEqual(dataType.ToString(), ws.FirstCell().GetString()); - } - - [Test] - public void SetCellValueToRange() + public void CopyRangeAtCellAddress() { var ws = new XLWorkbook().AddWorksheet("Sheet1"); @@ -630,7 +542,7 @@ public void SetCellValueToRange() var range = ws.Range("1:1"); - ws.Cell("B2").Value = range; + ws.Cell("B2").CopyFrom(range); Assert.AreEqual(2, ws.Cell("B2").Value); Assert.AreEqual(3, ws.Cell("C2").Value); @@ -647,30 +559,12 @@ public void ValueSetToEmptyString() IXLCell cell = ws.Cell(1, 1); cell.Value = new DateTime(2000, 1, 2); cell.Value = String.Empty; - Assert.AreEqual(expected, cell.GetString()); + Assert.AreEqual(expected, cell.GetText()); Assert.AreEqual(expected, cell.Value); cell.Value = new DateTime(2000, 1, 2); cell.SetValue(string.Empty); - Assert.AreEqual(expected, cell.GetString()); - Assert.AreEqual(expected, cell.Value); - } - - [Test] - public void ValueSetToNull() - { - string expected = String.Empty; - - IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); - IXLCell cell = ws.Cell(1, 1); - cell.Value = new DateTime(2000, 1, 2); - cell.Value = null; - Assert.AreEqual(expected, cell.GetString()); - Assert.AreEqual(expected, cell.Value); - - cell.Value = new DateTime(2000, 1, 2); - cell.SetValue(null as string); - Assert.AreEqual(expected, cell.GetString()); + Assert.AreEqual(expected, cell.GetText()); Assert.AreEqual(expected, cell.Value); } @@ -690,36 +584,6 @@ public void ValueSetDateWithShortUserDateFormat() Assert.AreEqual(expected, actual); } - [Test] - public void SetStringCellValues() - { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet1"); - var cell = ws.FirstCell(); - - object expected; - - var date = new DateTime(2018, 4, 18); - expected = date.ToString(); - cell.Value = expected; - Assert.AreEqual(XLDataType.DateTime, cell.DataType); - Assert.AreEqual(date, cell.Value); - - var b = true; - expected = b.ToString(); - cell.Value = expected; - Assert.AreEqual(XLDataType.Boolean, cell.DataType); - Assert.AreEqual(b, cell.Value); - - var ts = new TimeSpan(8, 12, 4); - expected = ts.ToString(); - cell.Value = expected; - Assert.AreEqual(XLDataType.TimeSpan, cell.DataType); - Assert.AreEqual(ts, cell.Value); - } - } - [Test] public void SetStringValueTooLong() { @@ -736,28 +600,6 @@ public void SetStringValueTooLong() } } - [Test] - public void SetDateOutOfRange() - { - Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("en-ZA"); - - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet1"); - - ws.FirstCell().Value = 5; - - var date = XLCell.BaseDate.AddDays(-1); - ws.FirstCell().Value = date; - - // Should default to string representation using current culture's date format - Assert.AreEqual(XLDataType.Text, ws.FirstCell().DataType); - Assert.AreEqual(date.ToString(), ws.FirstCell().Value); - - Assert.Throws(() => ws.FirstCell().SetValue(XLCell.BaseDate.AddDays(-1))); - } - } - [Test] public void SetCellValueWipesFormulas() { @@ -818,28 +660,6 @@ public void TestInvalidXmlCharacters() } } - [Test] - public void CanClearCellValueBySettingNullValue() - { - using (var wb = new XLWorkbook()) - { - var ws = wb.AddWorksheet("Sheet1"); - var cell = ws.FirstCell(); - - cell.Value = "Test"; - Assert.AreEqual("Test", cell.Value); - Assert.AreEqual(XLDataType.Text, cell.DataType); - - string s = null; - cell.SetValue(s); - Assert.AreEqual(string.Empty, cell.Value); - - cell.Value = "Test"; - cell.Value = null; - Assert.AreEqual(string.Empty, cell.Value); - } - } - [Test] public void CanClearDateTimeCellValue() { @@ -871,7 +691,7 @@ public void CanClearDateTimeCellValue() { var ws = wb.Worksheets.First(); var c = ws.FirstCell(); - Assert.AreEqual(XLDataType.Text, c.DataType); + Assert.AreEqual(XLDataType.Blank, c.DataType); Assert.True(c.IsEmpty()); } } @@ -976,6 +796,12 @@ public void CurrentRegion() Assert.AreEqual("D7:I12", ws.Cell("D7").CurrentRegion.RangeAddress.ToString()); Assert.AreEqual("E8:J13", ws.Cell("J13").CurrentRegion.RangeAddress.ToString()); + + // Four corners of a sheet + Assert.AreEqual("A1:D3", ws.Cell(1, 1).CurrentRegion.RangeAddress.ToString()); + Assert.AreEqual("XFD1:XFD1", ws.Cell(1, XLHelper.MaxColumnNumber).CurrentRegion.RangeAddress.ToString()); + Assert.AreEqual("XFD1048576:XFD1048576", ws.Cell(XLHelper.MaxRowNumber, XLHelper.MaxColumnNumber).CurrentRegion.RangeAddress.ToString()); + Assert.AreEqual("A1048576:A1048576", ws.Cell(XLHelper.MaxRowNumber, 1).CurrentRegion.RangeAddress.ToString()); } } @@ -997,19 +823,19 @@ public void ConsiderEmptyValueAsNumericInSumFormula() ws.Cell("C2").SetFormulaA1("=SUM(C1)"); ws.Cell("C3").SetFormulaA1("=C2"); - object b1 = ws.Cell("B1").Value; - object b2 = ws.Cell("B2").Value; - object b3 = ws.Cell("B3").Value; + var b1 = ws.Cell("B1").Value; + var b2 = ws.Cell("B2").Value; + var b3 = ws.Cell("B3").Value; - Assert.AreEqual("", b1); + Assert.AreEqual(Blank.Value, b1); Assert.AreEqual(0, b2); Assert.AreEqual(0, b3); - object c1 = ws.Cell("C1").Value; - object c2 = ws.Cell("C2").Value; - object c3 = ws.Cell("C3").Value; + var c1 = ws.Cell("C1").Value; + var c2 = ws.Cell("C2").Value; + var c3 = ws.Cell("C3").Value; - Assert.AreEqual("", c1); + Assert.AreEqual(Blank.Value, c1); Assert.AreEqual(0, c2); Assert.AreEqual(0, c3); } @@ -1045,6 +871,24 @@ public void SetFormulaR1C1AffectsA1() } } + [TestCase(" = 1 + SUM({ 1; 7}) - A8 ", "1 + SUM({ 1; 7}) - A8")] + public void FormulaA1_setter_trims_and_removes_equal_if_present(string formula, string expectedResult) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").FormulaA1 = formula; + Assert.AreEqual(expectedResult, ws.Cell("A1").FormulaA1); + } + + [TestCase(" = 1 + R[1]C[7] ", "1 + R[1]C[7]")] + public void FormulaR1C1_setter_trims_and_removes_equal_if_present(string formula, string expectedResult) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("A1").FormulaR1C1 = formula; + Assert.AreEqual(expectedResult, ws.Cell("A1").FormulaR1C1); + } + [Test] public void FormulaWithCircularReferenceFails() { @@ -1057,10 +901,10 @@ public void FormulaWithCircularReferenceFails() A2.FormulaA1 = "A1 + 1"; Assert.Throws( - Is.TypeOf().And.Message.Contains("circular"), + Is.TypeOf().And.Message.Contains("cycle"), () => _ = A1.Value); Assert.Throws( - Is.TypeOf().And.Message.Contains("circular"), + Is.TypeOf().And.Message.Contains("cycle"), () => _ = A2.Value); } } @@ -1079,7 +923,7 @@ public void InvalidFormulaShiftProducesREF() Assert.AreEqual(3, ws.Cell("B2").Value); - ws.Range("A2").Value = ws.Range("B2"); + ws.Range("B2").CopyTo(ws.Range("A2")); var fA2 = ws.Cell("A2").FormulaA1; wb.SaveAs(ms); @@ -1124,23 +968,6 @@ public void TryGetValueFormula_EvaluationFail_ReturnFalse() } } - [Test] - public void SetValue_IEnumerable() - { - using var wb = new XLWorkbook(); - var ws = wb.AddWorksheet(); - object[] values = { "Text", 45, DateTime.Today, true, "More text" }; - - ws.FirstCell().SetValue(values); - - Assert.AreEqual("Text", ws.FirstCell().GetString()); - Assert.AreEqual(45, ws.Cell("A2").GetDouble()); - Assert.AreEqual(DateTime.Today, ws.Cell("A3").GetDateTime()); - Assert.AreEqual(true, ws.Cell("A4").GetBoolean()); - Assert.AreEqual("More text", ws.Cell("A5").GetString()); - Assert.IsTrue(ws.Cell("A6").IsEmpty()); - } - [Test] public void ToStringNoFormatString() { @@ -1191,12 +1018,85 @@ public void ToStringInvalidFormat() } [Test] - public void ConvertOtherSupportedTypes() + public void Property_Active_is_true_when_cell_has_same_address_as_active_cell_in_worksheet() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.IsNull(ws.ActiveCell); + Assert.False(ws.Cell(1, 1).Active); + + ws.ActiveCell = ws.Cell("C4"); + Assert.True(ws.Cell("C4").Active); + Assert.False(ws.Cell("C5").Active); + + ws.ActiveCell = null; + Assert.False(ws.Cell("C4").Active); + } + + [Test] + public void Property_Active_deactivates_cell_only_when_the_cell_is_active() { - Assert.AreEqual("", XLCell.ConvertOtherSupportedTypes(DBNull.Value)); - Assert.AreEqual("748bdf0c-3e7d-415e-967d-a875a27634ed", XLCell.ConvertOtherSupportedTypes(new Guid("748BDF0C-3E7D-415E-967D-A875A27634ED"))); - Assert.AreEqual(new DateTime(2022, 06, 30, 12, 57, 00), XLCell.ConvertOtherSupportedTypes(new DateTimeOffset(2022, 06, 30, 12, 57, 00, new TimeSpan(2 * TimeSpan.TicksPerHour)))); - Assert.AreEqual(DateTime.Today, XLCell.ConvertOtherSupportedTypes(DateTime.Today)); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.ActiveCell = ws.Cell("A2"); + + ws.Cell("B2").Active = false; + Assert.AreEqual(ws.Cell("A2"), ws.ActiveCell); + + ws.Cell("A2").Active = false; + Assert.IsNull(ws.ActiveCell); + } + + [Test] + public void Property_Active_sets_cell_as_active_cell_of_worksheet() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.IsNull(ws.ActiveCell); + + ws.Cell("B2").Active = true; + Assert.AreEqual(ws.Cell("B2"), ws.ActiveCell); + } + + [TestCase("PY(4)", "_xlfn._xlws.PY(4)")] + [TestCase("5 + py(abs(4) )", "5 + _xlfn._xlws.PY(abs(4) )")] + [TestCase("COT(COTH(A5 + 2 * SIN(B7)))", "_xlfn.COT(_xlfn.COTH(A5 + 2 * SIN(B7)))")] + [TestCase("_xlfn.COT(_xlfn.COTH(A5 + 2 * SIN(B7)))", "_xlfn.COT(_xlfn.COTH(A5 + 2 * SIN(B7)))")] + public void FormulaA1_adds_prefix_to_future_functions(string formula, string expected) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var cell = ws.Cell("A1"); + cell.FormulaA1 = formula; + + Assert.AreEqual(expected, cell.FormulaA1); + } + + [TestCase("PY(4)", "_xlfn._xlws.PY(4)")] + [TestCase("5 + py(abs(4) )", "5 + _xlfn._xlws.PY(abs(4) )")] + [TestCase("COT(COTH(R[3]C[5] + 2 * SIN(R[7]C[2])))", "_xlfn.COT(_xlfn.COTH(R[3]C[5] + 2 * SIN(R[7]C[2])))")] + [TestCase("_xlfn.COT(_xlfn.COTH(R[3]C[5] + 2 * SIN(R[7]C[2])))", "_xlfn.COT(_xlfn.COTH(R[3]C[5] + 2 * SIN(R[7]C[2])))")] + public void FormulaR1C1_adds_prefix_to_future_functions(string formula, string expected) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var cell = ws.Cell("A1"); + cell.FormulaR1C1 = formula; + + Assert.AreEqual(expected, cell.FormulaR1C1); + } + + [Test] + public void FormulaA1_adds_prefix_to_all_future_functions() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var cell = ws.Cell("A1"); + foreach (var (simpleName, prefixedName) in XLConstants.FutureFunctionMap.Value) + { + cell.FormulaA1 = simpleName + "()"; + Assert.AreEqual(prefixedName + "()", cell.FormulaA1); + } } } } diff --git a/ClosedXML.Tests/Excel/Cells/XLCellValueTests.cs b/ClosedXML.Tests/Excel/Cells/XLCellValueTests.cs new file mode 100644 index 000000000..1746082da --- /dev/null +++ b/ClosedXML.Tests/Excel/Cells/XLCellValueTests.cs @@ -0,0 +1,605 @@ +using NUnit.Framework; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using ClosedXML.Excel; + +namespace ClosedXML.Tests.Excel.Cells +{ + [TestFixture] + public class XLCellValueTests + { + [Test] + public void Creation_Blank() + { + XLCellValue blank = Blank.Value; + Assert.AreEqual(XLDataType.Blank, blank.Type); + Assert.True(blank.IsBlank); + } + + [Test] + public void Creation_Boolean() + { + XLCellValue logical = true; + Assert.AreEqual(XLDataType.Boolean, logical.Type); + Assert.True(logical.GetBoolean()); + Assert.True(logical.IsBoolean); + } + + [Test] + public void Creation_Number() + { + XLCellValue number = 14.0; + Assert.AreEqual(XLDataType.Number, number.Type); + Assert.True(number.IsNumber); + Assert.AreEqual(14.0, number.GetNumber()); + } + + [TestCase(Double.NaN)] + [TestCase(Double.PositiveInfinity)] + [TestCase(Double.NegativeInfinity)] + public void Creation_Number_CantBeNonNumber(Double nonNumber) + { + Assert.Throws(() => _ = (XLCellValue)nonNumber); + } + + // Decimal is not allowed as a member of an attribute, so TestCase can't be used. + private static readonly object[] DecimalTestCases = + { + new object[] { 5.875m, 5.875d }, + new object[] { Decimal.MaxValue, 7.922816251426434E+28 }, + new object[] { 1.0E-28m, 1.0000000000000001E-28d } + }; + + [TestCaseSource(nameof(DecimalTestCases))] + public void Creation_Decimal(Decimal decimalNumber, Double expectedNumber) + { + XLCellValue cellValue = decimalNumber; + Assert.True(cellValue.IsNumber); + Assert.AreEqual(expectedNumber, cellValue.GetNumber()); + } + + [Test] + public void Creation_Text() + { + XLCellValue text = "Hello World"; + Assert.AreEqual(XLDataType.Text, text.Type); + Assert.AreEqual("Hello World", text.GetText()); + } + + [Test] + public void NullString_IsConvertedToBlank() + { + XLCellValue value = (string)null; + Assert.IsTrue(value.IsBlank); + Assert.IsFalse(value.IsText); + } + + [Test] + public void Creation_Text_HasLimitedLength() + { + var longText = new string('A', 32768); + Assert.Throws(() => _ = (XLCellValue)longText); + } + + [Test] + public void Creation_Error() + { + XLCellValue error = XLError.NumberInvalid; + Assert.AreEqual(XLDataType.Error, error.Type); + Assert.True(error.IsError); + Assert.AreEqual(XLError.NumberInvalid, error.GetError()); + } + + [Test] + public void Creation_DateTime() + { + XLCellValue dateTime = new DateTime(2021, 1, 1); + Assert.AreEqual(XLDataType.DateTime, dateTime.Type); + Assert.True(dateTime.IsDateTime); + Assert.AreEqual(new DateTime(2021, 1, 1), dateTime.GetDateTime()); + } + + [Test] + public void Creation_TimeSpan() + { + XLCellValue dateTime = new TimeSpan(10, 1, 2, 3, 456); + Assert.AreEqual(XLDataType.TimeSpan, dateTime.Type); + Assert.True(dateTime.IsTimeSpan); + Assert.AreEqual(new TimeSpan(10, 1, 2, 3, 456), dateTime.GetTimeSpan()); + } + + [Test] + public void Creation_FromObject() + { + Assert.AreEqual(XLDataType.Blank, XLCellValue.FromObject(null).Type); + Assert.AreEqual(XLDataType.Blank, XLCellValue.FromObject(Blank.Value).Type); + Assert.AreEqual(XLDataType.Boolean, XLCellValue.FromObject(true).Type); + Assert.AreEqual(XLDataType.Text, XLCellValue.FromObject("Hello World").Type); + Assert.AreEqual(XLDataType.Error, XLCellValue.FromObject(XLError.NumberInvalid).Type); + Assert.AreEqual(XLDataType.DateTime, XLCellValue.FromObject(new DateTime(2021, 1, 1)).Type); + Assert.AreEqual(XLDataType.TimeSpan, XLCellValue.FromObject(new TimeSpan(10, 1, 2, 3, 456)).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((sbyte)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((byte)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((short)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((ushort)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((int)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((uint)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((long)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((ulong)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((float)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((double)42).Type); + Assert.AreEqual(XLDataType.Number, XLCellValue.FromObject((decimal)42).Type); + Assert.AreEqual(XLDataType.Text, XLCellValue.FromObject(DayOfWeek.Sunday).Type); + } + + [Test] + public void NumberTypes_HaveUnambiguousConversion() + { + { + sbyte sbyteNumber = 5; + XLCellValue sbyteCellValue = sbyteNumber; + Assert.IsTrue(sbyteCellValue.IsNumber); + Assert.AreEqual(5d, sbyteCellValue.GetNumber()); + } + { + byte byteNumber = 6; + XLCellValue byteCellValue = byteNumber; + Assert.IsTrue(byteCellValue.IsNumber); + Assert.AreEqual(6d, byteCellValue.GetNumber()); + } + { + short shortNumber = 7; + XLCellValue shortCellValue = shortNumber; + Assert.IsTrue(shortCellValue.IsNumber); + Assert.AreEqual(7d, shortCellValue.GetNumber()); + } + { + ushort ushortNumber = 8; + XLCellValue ushortCellValue = ushortNumber; + Assert.IsTrue(ushortCellValue.IsNumber); + Assert.AreEqual(8d, ushortCellValue.GetNumber()); + } + { + int intNumber = 9; + XLCellValue intCellValue = intNumber; + Assert.IsTrue(intCellValue.IsNumber); + Assert.AreEqual(9d, intCellValue.GetNumber()); + } + { + uint uintNumber = 10; + XLCellValue uintCellValue = uintNumber; + Assert.IsTrue(uintCellValue.IsNumber); + Assert.AreEqual(10d, uintCellValue.GetNumber()); + } + { + long longNumber = 11; + XLCellValue longCellValue = longNumber; + Assert.IsTrue(longCellValue.IsNumber); + Assert.AreEqual(11d, longCellValue.GetNumber()); + } + { + ulong ulongNumber = 12; + XLCellValue ulongCellValue = ulongNumber; + Assert.IsTrue(ulongCellValue.IsNumber); + Assert.AreEqual(12d, ulongCellValue.GetNumber()); + } + { + float floatNumber = 13.5f; + XLCellValue floatCellValue = floatNumber; + Assert.IsTrue(floatCellValue.IsNumber); + Assert.AreEqual(13.5d, floatCellValue.GetNumber()); + } + { + double doubleNumber = 14.5; + XLCellValue doubleCellValue = doubleNumber; + Assert.IsTrue(doubleCellValue.IsNumber); + Assert.AreEqual(14.5d, doubleCellValue.GetNumber()); + } + { + decimal decimalNumber = 15.75m; + XLCellValue decimalCellValue = decimalNumber; + Assert.IsTrue(decimalCellValue.IsNumber); + Assert.AreEqual(15.75d, decimalCellValue.GetNumber()); + } + } + + [Test] + [SuppressMessage("ReSharper", "ExpressionIsAlwaysNull")] + public void NullableNumber_WithNullValue_AreConvertedToBlank() + { + { + sbyte? sbyteNull = null; + XLCellValue sbyteCellValue = sbyteNull; + Assert.IsFalse(sbyteCellValue.IsNumber); + Assert.IsTrue(sbyteCellValue.IsBlank); + } + { + byte? byteNull = null; + XLCellValue byteCellValue = byteNull; + Assert.IsFalse(byteCellValue.IsNumber); + Assert.IsTrue(byteCellValue.IsBlank); + } + { + short? shortNull = null; + XLCellValue shortCellValue = shortNull; + Assert.IsFalse(shortCellValue.IsNumber); + Assert.IsTrue(shortCellValue.IsBlank); + } + { + ushort? ushortNull = null; + XLCellValue ushortCellValue = ushortNull; + Assert.IsFalse(ushortCellValue.IsNumber); + Assert.IsTrue(ushortCellValue.IsBlank); + } + { + int? intNull = null; + XLCellValue intCellValue = intNull; + Assert.IsFalse(intCellValue.IsNumber); + Assert.IsTrue(intCellValue.IsBlank); + } + { + uint? uintNull = null; + XLCellValue uintCellValue = uintNull; + Assert.IsFalse(uintCellValue.IsNumber); + Assert.IsTrue(uintCellValue.IsBlank); + } + { + long? longNull = null; + XLCellValue longCellValue = longNull; + Assert.IsFalse(longCellValue.IsNumber); + Assert.IsTrue(longCellValue.IsBlank); + } + { + ulong? ulongNull = null; + XLCellValue ulongCellValue = ulongNull; + Assert.IsFalse(ulongCellValue.IsNumber); + Assert.IsTrue(ulongCellValue.IsBlank); + } + { + float? floatValue = null; + XLCellValue floatCellValue = floatValue; + Assert.IsFalse(floatCellValue.IsNumber); + Assert.IsTrue(floatCellValue.IsBlank); + } + { + double? doubleValue = null; + XLCellValue doubleCellValue = doubleValue; + Assert.IsFalse(doubleCellValue.IsNumber); + Assert.IsTrue(doubleCellValue.IsBlank); + } + { + decimal? decimalValue = null; + XLCellValue decimalCellValue = decimalValue; + Assert.IsFalse(decimalCellValue.IsNumber); + Assert.IsTrue(decimalCellValue.IsBlank); + } + } + + [Test] + public void NullableNumber_WithNumberValue_AreConvertedToNumber() + { + { + sbyte? sbyteNumber = 5; + XLCellValue sbyteCellValue = sbyteNumber; + Assert.IsTrue(sbyteCellValue.IsNumber); + Assert.AreEqual(5d, sbyteCellValue.GetNumber()); + } + { + byte? byteNumber = 6; + XLCellValue byteCellValue = byteNumber; + Assert.IsTrue(byteCellValue.IsNumber); + Assert.AreEqual(6d, byteCellValue.GetNumber()); + } + { + short? shortNumber = 7; + XLCellValue shortCellValue = shortNumber; + Assert.IsTrue(shortCellValue.IsNumber); + Assert.AreEqual(7d, shortCellValue.GetNumber()); + } + { + ushort? ushortNumber = 8; + XLCellValue ushortCellValue = ushortNumber; + Assert.IsTrue(ushortCellValue.IsNumber); + Assert.AreEqual(8d, ushortCellValue.GetNumber()); + } + { + int? intNumber = 9; + XLCellValue intCellValue = intNumber; + Assert.IsTrue(intCellValue.IsNumber); + Assert.AreEqual(9d, intCellValue.GetNumber()); + } + { + uint? uintNumber = 9; + XLCellValue uintCellValue = uintNumber; + Assert.IsTrue(uintCellValue.IsNumber); + Assert.AreEqual(9d, uintCellValue.GetNumber()); + } + { + long? longNumber = 10; + XLCellValue longCellValue = longNumber; + Assert.IsTrue(longCellValue.IsNumber); + Assert.AreEqual(10d, longCellValue.GetNumber()); + } + { + ulong? ulongNumber = 11; + XLCellValue ulongCellValue = ulongNumber; + Assert.IsTrue(ulongCellValue.IsNumber); + Assert.AreEqual(11d, ulongCellValue.GetNumber()); + } + { + float? floatNumber = 12.875f; + XLCellValue floatCellValue = floatNumber; + Assert.IsTrue(floatCellValue.IsNumber); + Assert.AreEqual(12.875d, floatCellValue.GetNumber()); + } + { + double? doubleNumber = 13.875d; + XLCellValue doubleCellValue = doubleNumber; + Assert.IsTrue(doubleCellValue.IsNumber); + Assert.AreEqual(13.875d, doubleCellValue.GetNumber()); + } + { + decimal? decimalNumber = 14.875m; + XLCellValue decimalCellValue = decimalNumber; + Assert.IsTrue(decimalCellValue.IsNumber); + Assert.AreEqual(14.875d, decimalCellValue.GetNumber()); + } + } + + [Test] + [SuppressMessage("ReSharper", "ExpressionIsAlwaysNull")] + public void NullableDateTime_WithNullValue_IsConvertedToBlank() + { + DateTime? dateTimeNull = null; + XLCellValue dateTimeCellValue = dateTimeNull; + Assert.IsFalse(dateTimeCellValue.IsDateTime); + Assert.IsTrue(dateTimeCellValue.IsBlank); + } + + [Test] + public void NullableDateTime_WithDateValue_IsConvertedToDateTime() + { + DateTime? dateTime = new DateTime(2020, 5, 14, 8, 14, 30); + XLCellValue dateTimeCellValue = dateTime; + Assert.IsTrue(dateTimeCellValue.IsDateTime); + Assert.AreEqual(dateTime.Value, dateTimeCellValue.GetDateTime()); + } + + [Test] + [SuppressMessage("ReSharper", "ExpressionIsAlwaysNull")] + public void NullableTimeSpan_WithNullValue_IsConvertedToBlank() + { + TimeSpan? timeSpanNull = null; + XLCellValue timeSpanCellValue = timeSpanNull; + Assert.IsFalse(timeSpanCellValue.IsTimeSpan); + Assert.IsTrue(timeSpanCellValue.IsBlank); + } + + [Test] + public void NullableTimeSpan_WithTimeSpanValue_IsConvertedToTimeSpan() + { + TimeSpan? timeSpan = new TimeSpan(48, 12, 45, 30); + XLCellValue timeSpanCellValue = timeSpan; + Assert.IsTrue(timeSpanCellValue.IsTimeSpan); + Assert.AreEqual(timeSpan.Value, timeSpanCellValue.GetTimeSpan()); + } + + [Test] + public void UnifiedNumber_IsFormOf_Number_DateTime_And_TimeSpan() + { + XLCellValue value = Blank.Value; + Assert.False(value.IsUnifiedNumber); + + value = true; + Assert.False(value.IsUnifiedNumber); + + value = 14; + Assert.True(value.IsUnifiedNumber); + Assert.AreEqual(14.0, value.GetUnifiedNumber()); + + value = new DateTime(1900, 1, 1); + Assert.True(value.IsUnifiedNumber); + Assert.AreEqual(1.0, value.GetUnifiedNumber()); + + value = new TimeSpan(2, 12, 0, 0); + Assert.True(value.IsUnifiedNumber); + Assert.AreEqual(2.5, value.GetUnifiedNumber()); + + value = "Text"; + Assert.False(value.IsUnifiedNumber); + + value = XLError.CellReference; + Assert.False(value.IsUnifiedNumber); + } + + [TestCase("1900-01-01", 1)] + [TestCase("1900-01-02", 2)] + [TestCase("1900-02-01", 32)] + [TestCase("1900-02-28", 59)] // Excel assumes 1900 was a leap year and 29.1.1900 existed + [TestCase("1900-03-01", 61)] + [TestCase("2017-01-01", 42736)] + public void SerialDateTime(string dateString, double expectedSerial) + { + XLCellValue date = DateTime.Parse(dateString); + Assert.AreEqual(expectedSerial, date.GetUnifiedNumber()); + } + + [Test] + [SetCulture("cs-CZ")] + public void ToString_RespectsCulture() + { + XLCellValue v = Blank.Value; + Assert.AreEqual(String.Empty, v.ToString()); + + v = true; + Assert.AreEqual("TRUE", v.ToString()); + + v = 25.4; + Assert.AreEqual("25,4", v.ToString()); + + v = "Hello"; + Assert.AreEqual("Hello", v.ToString()); + + v = XLError.IncompatibleValue; + Assert.AreEqual("#VALUE!", v.ToString()); + + v = new DateTime(1900, 1, 2); + Assert.AreEqual("02.01.1900 0:00:00", v.ToString()); + + v = new DateTime(1900, 3, 1, 4, 10, 5); + Assert.AreEqual("01.03.1900 4:10:05", v.ToString()); + + v = new TimeSpan(4, 5, 6, 7, 82); + Assert.AreEqual("101:06:07,082", v.ToString()); + } + + [Test] + public void TryConvert_Blank() + { + XLCellValue value = Blank.Value; + Assert.True(value.TryConvert(out Blank blank)); + Assert.AreEqual(Blank.Value, blank); + + value = String.Empty; + Assert.True(value.TryConvert(out blank)); + Assert.AreEqual(Blank.Value, blank); + } + + [Test] + public void TryConvert_Boolean() + { + XLCellValue value = true; + Assert.True(value.TryConvert(out Boolean boolean)); + Assert.True(boolean); + + value = "True"; + Assert.True(value.TryConvert(out boolean)); + Assert.True(boolean); + + value = "False"; + Assert.True(value.TryConvert(out boolean)); + Assert.False(boolean); + + value = 0; + Assert.True(value.TryConvert(out boolean)); + Assert.False(boolean); + + value = 0.001; + Assert.True(value.TryConvert(out boolean)); + Assert.True(boolean); + } + + [Test] + public void TryConvert_Number() + { + var c = CultureInfo.GetCultureInfo("cs-CZ"); + XLCellValue value = 5; + Assert.True(value.TryConvert(out Double number, c)); + Assert.AreEqual(5.0, number); + + value = "1,5"; + Assert.True(value.TryConvert(out number, c)); + Assert.AreEqual(1.5, number); + + value = "1 1/4"; + Assert.True(value.TryConvert(out number, c)); + Assert.AreEqual(1.25, number); + + value = "3.1.1900"; + Assert.True(value.TryConvert(out number, c)); + Assert.AreEqual(3, number); + + value = true; + Assert.True(value.TryConvert(out number, c)); + Assert.AreEqual(1.0, number); + + value = false; + Assert.True(value.TryConvert(out number, c)); + Assert.AreEqual(0.0, number); + + value = new DateTime(2020, 4, 5, 10, 14, 5); + Assert.True(value.TryConvert(out number, c)); + Assert.AreEqual(43926.42644675926, number); + + value = new TimeSpan(18, 0, 0); + Assert.True(value.TryConvert(out number, c)); + Assert.AreEqual(0.75, number); + } + + [Test] + public void TryConvert_DateTime() + { + XLCellValue v = new DateTime(2020, 1, 1); + Assert.True(v.TryConvert(out DateTime dt)); + Assert.AreEqual(new DateTime(2020, 1, 1), dt); + + var lastSerialDate = 2958465; + v = lastSerialDate; + Assert.True(v.TryConvert(out dt)); + Assert.AreEqual(new DateTime(9999, 12, 31), dt); + + v = lastSerialDate + 1; + Assert.False(v.TryConvert(out dt)); + + v = new TimeSpan(14, 0, 0, 0); + Assert.True(v.TryConvert(out dt)); + Assert.AreEqual(new DateTime(1900, 1, 14), dt); + } + + [Test] + public void TryConvert_TimeSpan() + { + var c = CultureInfo.GetCultureInfo("cs-CZ"); + XLCellValue v = new TimeSpan(10, 15, 30); + Assert.True(v.TryConvert(out TimeSpan ts, c)); + Assert.AreEqual(new TimeSpan(10, 15, 30), ts); + + v = "26:15:30,5"; + Assert.True(v.TryConvert(out ts, c)); + Assert.AreEqual(new TimeSpan(1, 2, 15, 30, 500), ts); + + v = 0.75; + Assert.True(v.TryConvert(out ts, c)); + Assert.AreEqual(new TimeSpan(18, 0, 0), ts); + } + + [TestCase(1)] + [TestCase(10)] // microsecond + [TestCase(3000000001)] // 5 min 1 tick + public void TimeSpan_can_have_sub_millisecond_precision(long ticks) + { + var subMsTimeSpan = TimeSpan.FromTicks(ticks); + XLCellValue value = subMsTimeSpan; + Assert.AreEqual(subMsTimeSpan, value.GetTimeSpan()); + } + + [TestCase(1)] + [TestCase(10)] // microsecond + [TestCase(3000000001)] // 5 min 1 tick + public void TimeSpan_with_sub_millisecond_precision_is_written_and_loaded_correctly(long ticks) + { + // NetFx converts double to string using G15. Core changed it to G17, but ClosedXML still use G15. + var subMsTimeSpan = TimeSpan.FromTicks(ticks); + TestHelper.CreateSaveLoadAssert( + (_, ws) => + { + ws.Cell("A1").Value = subMsTimeSpan; + }, + (_, ws) => + { + var cellValue = ws.Cell("A1").CachedValue; + Assert.AreEqual(subMsTimeSpan, cellValue.GetTimeSpan()); + }); + } + + [TestCase(long.MaxValue / (double)TimeSpan.TicksPerDay + 0.01)] + [TestCase(long.MinValue / (double)TimeSpan.TicksPerDay - 0.01)] + public void TimeSpan_throws_when_not_representable(double serialDateTime) + { + var value = XLCellValue.FromSerialTimeSpan(serialDateTime); + var ex = Assert.Throws(() => value.GetTimeSpan())!; + Assert.AreEqual("The serial date time value is too large to be represented in a TimeSpan.", ex.Message); + } + } +} diff --git a/ClosedXML.Tests/Excel/Clearing/ClearingTests.cs b/ClosedXML.Tests/Excel/Clearing/ClearingTests.cs index 48a7ab572..3cddb175a 100644 --- a/ClosedXML.Tests/Excel/Clearing/ClearingTests.cs +++ b/ClosedXML.Tests/Excel/Clearing/ClearingTests.cs @@ -55,9 +55,9 @@ private IXLWorkbook SetupWorkbook() .Border.SetOutsideBorderColor(XLColor.Blue) .Font.SetBold(); - Assert.AreEqual(XLDataType.Text, ws.Cell("A1").DataType); - Assert.AreEqual(XLDataType.Text, ws.Cell("A2").DataType); - Assert.AreEqual(XLDataType.DateTime, ws.Cell("A3").DataType); + Assert.AreEqual(XLDataType.Text, ws.Cell("A1").Value.Type); + Assert.AreEqual(XLDataType.Text, ws.Cell("A2").Value.Type); + Assert.AreEqual(XLDataType.DateTime, ws.Cell("A3").Value.Type); Assert.AreEqual(false, ws.Cell("A1").HasFormula); Assert.AreEqual(true, ws.Cell("A2").HasFormula); @@ -88,7 +88,7 @@ public void WorksheetClearAll() foreach (var c in ws.Range("A1:A10").Cells()) { Assert.IsTrue(c.IsEmpty()); - Assert.AreEqual(XLDataType.Text, c.DataType); + Assert.AreEqual(XLDataType.Blank, c.DataType); Assert.AreEqual(ws.Style.Fill.BackgroundColor, c.Style.Fill.BackgroundColor); Assert.AreEqual(ws.Style.Font.FontColor, c.Style.Font.FontColor); Assert.IsFalse(ws.ConditionalFormats.Any()); @@ -109,34 +109,9 @@ public void WorksheetClearContents() foreach (var c in ws.Range("A1:A3").Cells()) { + Assert.AreEqual(XLDataType.Blank, ws.Cell("A1").DataType); Assert.IsTrue(c.IsEmpty(XLCellsUsedOptions.Contents)); - Assert.AreEqual(backgroundColor, c.Style.Fill.BackgroundColor); - Assert.AreEqual(foregroundColor, c.Style.Font.FontColor); - Assert.IsTrue(ws.ConditionalFormats.Any()); - Assert.IsTrue(c.HasComment); - } - - Assert.AreEqual("B1", ws.Cell("A1").GetDataValidation().Value); - Assert.AreEqual(XLDataType.Text, ws.Cell("A1").DataType); - Assert.AreEqual(XLDataType.Text, ws.Cell("A2").DataType); - Assert.AreEqual(XLDataType.DateTime, ws.Cell("A3").DataType); - } - } - - [Test] - public void WorksheetClearDataType() - { - using (var wb = SetupWorkbook()) - { - var ws = wb.Worksheets.First(); - - ws.Clear(XLClearOptions.DataType); - - foreach (var c in ws.Range("A1:A3").Cells()) - { - Assert.IsFalse(c.IsEmpty()); - Assert.AreEqual(XLDataType.Text, c.DataType); Assert.AreEqual(backgroundColor, c.Style.Fill.BackgroundColor); Assert.AreEqual(foregroundColor, c.Style.Font.FontColor); Assert.IsTrue(ws.ConditionalFormats.Any()); @@ -259,7 +234,7 @@ public void DeleteClearedCellValue() using (var wb = SetupWorkbook()) { var ws = wb.Worksheets.First(); - Assert.AreEqual("Hello world!", ws.Cell("A1").GetString()); + Assert.AreEqual("Hello world!", ws.Cell("A1").GetText()); Assert.AreEqual(new DateTime(2018, 1, 15), ws.Cell("A3").GetDateTime()); wb.SaveAs(ms); @@ -269,8 +244,8 @@ public void DeleteClearedCellValue() { var ws = wb.Worksheets.First(); ws.Clear(XLClearOptions.Contents); - Assert.AreEqual("", ws.Cell("A1").GetString()); - Assert.Throws(() => ws.Cell("A3").GetDateTime()); + Assert.AreEqual(Blank.Value, ws.Cell("A1").Value); + Assert.Throws(() => ws.Cell("A3").GetDateTime()); wb.Save(); } @@ -278,8 +253,8 @@ public void DeleteClearedCellValue() using (var wb = new XLWorkbook(ms)) { var ws = wb.Worksheets.First(); - Assert.AreEqual("", ws.Cell("A1").GetString()); - Assert.Throws(() => ws.Cell("A3").GetDateTime()); + Assert.AreEqual(Blank.Value, ws.Cell("A1").Value); + Assert.Throws(() => ws.Cell("A3").GetDateTime()); } } } diff --git a/ClosedXML.Tests/Excel/Columns/ColumnTests.cs b/ClosedXML.Tests/Excel/Columns/ColumnTests.cs index fa24da381..d674efe8e 100644 --- a/ClosedXML.Tests/Excel/Columns/ColumnTests.cs +++ b/ClosedXML.Tests/Excel/Columns/ColumnTests.cs @@ -76,7 +76,7 @@ public void InsertingColumnsBefore1() Assert.AreEqual(XLColor.Red, ws.Column(4).Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, ws.Column(4).Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", ws.Column(3).Cell(2).GetString()); + Assert.AreEqual("X", ws.Column(3).Cell(2).GetText()); Assert.AreEqual(ws.Style.Fill.BackgroundColor, columnIns.Cell(1).Style.Fill.BackgroundColor); Assert.AreEqual(ws.Style.Fill.BackgroundColor, columnIns.Cell(2).Style.Fill.BackgroundColor); @@ -94,7 +94,7 @@ public void InsertingColumnsBefore1() Assert.AreEqual(XLColor.Red, column3.Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, column3.Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", column2.Cell(2).GetString()); + Assert.AreEqual("X", column2.Cell(2).GetText()); } [Test] @@ -129,7 +129,7 @@ public void InsertingColumnsBefore2() Assert.AreEqual(XLColor.Red, ws.Column(4).Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, ws.Column(4).Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", ws.Column(3).Cell(2).GetString()); + Assert.AreEqual("X", ws.Column(3).Cell(2).GetText()); Assert.AreEqual(XLColor.Red, columnIns.Cell(1).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, columnIns.Cell(2).Style.Fill.BackgroundColor); @@ -147,7 +147,7 @@ public void InsertingColumnsBefore2() Assert.AreEqual(XLColor.Red, column3.Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, column3.Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", column2.Cell(2).GetString()); + Assert.AreEqual("X", column2.Cell(2).GetText()); } [Test] @@ -182,7 +182,7 @@ public void InsertingColumnsBefore3() Assert.AreEqual(XLColor.Red, ws.Column(4).Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, ws.Column(4).Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", ws.Column(2).Cell(2).GetString()); + Assert.AreEqual("X", ws.Column(2).Cell(2).GetText()); Assert.AreEqual(XLColor.Yellow, columnIns.Cell(1).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Green, columnIns.Cell(2).Style.Fill.BackgroundColor); @@ -200,7 +200,7 @@ public void InsertingColumnsBefore3() Assert.AreEqual(XLColor.Red, column3.Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, column3.Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", column2.Cell(2).GetString()); + Assert.AreEqual("X", column2.Cell(2).GetText()); } [Test] diff --git a/ClosedXML.Tests/Excel/Comments/CommentsTests.cs b/ClosedXML.Tests/Excel/Comments/CommentsTests.cs index 3c7ec825e..ccb739633 100644 --- a/ClosedXML.Tests/Excel/Comments/CommentsTests.cs +++ b/ClosedXML.Tests/Excel/Comments/CommentsTests.cs @@ -2,6 +2,7 @@ using DocumentFormat.OpenXml.Packaging; using NUnit.Framework; using System; +using System.Drawing; using System.IO; using System.Linq; @@ -10,45 +11,25 @@ namespace ClosedXML.Tests.Excel.Comments public class CommentsTests { [Test] - public void CanGetColorFromIndex81() + public void CanConvertVmlPaletteEntriesToColors() { - using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"TryToLoad\CommentsWithIndexedColor81.xlsx"))) + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"TryToLoad\CommentsWithColorNamesAndIndexes.xlsx"))) using (var wb = new XLWorkbook(stream)) { var ws = wb.Worksheets.First(); var c = ws.FirstCellUsed(); - var xlColor = c.GetComment().Style.ColorsAndLines.LineColor; - Assert.AreEqual(XLColorType.Indexed, xlColor.ColorType); - Assert.AreEqual(81, xlColor.Indexed); + // None indicates an absence of a color + var lineColor = c.GetComment().Style.ColorsAndLines.LineColor; + Assert.AreEqual(XLColorType.Color, lineColor.ColorType); + Assert.AreEqual("00000000", lineColor.Color.ToHex()); - var color = xlColor.Color.ToHex(); - Assert.AreEqual("FF000000", color); + var bgColor = c.GetComment().Style.ColorsAndLines.FillColor; + Assert.AreEqual(XLColorType.Color, bgColor.ColorType); + Assert.AreEqual("FFFFFFE1", bgColor.Color.ToHex()); } } - [Test] - public void AddingCommentDoesNotAffectCollections() - { - var ws = new XLWorkbook().AddWorksheet() as XLWorksheet; - ws.Cell("A1").SetValue(10); - ws.Cell("A4").SetValue(10); - ws.Cell("A5").SetValue(10); - - ws.Rows("1,4").Height = 20; - - Assert.AreEqual(2, ws.Internals.RowsCollection.Count); - Assert.AreEqual(3, ws.Internals.CellsCollection.RowsCollection.SelectMany(r => r.Value.Values).Count()); - - ws.Cell("A4").GetComment().AddText("Comment"); - Assert.AreEqual(2, ws.Internals.RowsCollection.Count); - Assert.AreEqual(3, ws.Internals.CellsCollection.RowsCollection.SelectMany(r => r.Value.Values).Count()); - - ws.Row(1).Delete(); - Assert.AreEqual(1, ws.Internals.RowsCollection.Count); - Assert.AreEqual(2, ws.Internals.CellsCollection.RowsCollection.SelectMany(r => r.Value.Values).Count()); - } - [Test] public void CopyCommentStyle() { @@ -190,5 +171,32 @@ public void CanLoadCommentVisibility() Assert.False(ws.Cell("A4").GetComment().Visible); } } + + [Test] + public void Margins_are_converted_to_physical_length() + { + // Technically, it's insets on a textbox. Each comment uses a different unit, but all + // should have same final dimension at left and top margin (easily visible in the + // sheet). Tested units: in, cm, mm, pt, pc, emu, px, em, ex. Pixels are converted + // through supplied DPI. + // The last comment in vmlDrawing1 also has invalid units and number. These are + // converted to 0, so we don't crash on load (Excel also ignores invalid values). + var commentCells = new[] { "A1", "A7", "A16", "A22", "A28" }; + TestHelper.LoadAndAssert((_, ws) => + { + foreach (var commentCell in commentCells) + { + var cell = ws.Cell(commentCell); + Assert.True(cell.HasComment); + var margins = cell.GetComment().Style.Margins; + + Assert.AreEqual(0.5, margins.Left); + Assert.AreEqual(0.75, margins.Top); + + Assert.AreEqual(0, margins.Right); + Assert.AreEqual(0, margins.Bottom); + } + }, @"Other\Comments\InsetsUnitConversion.xlsx", new LoadOptions { Dpi = new Point(120, 120) }); + } } } diff --git a/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs b/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs index e18b5d8d0..ddb7bf432 100644 --- a/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs +++ b/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs @@ -114,5 +114,50 @@ public void SaveConditionalFormat_CultureIndependent(string culture) } } } + + [Test] + public void CellIs_type_reads_only_required_formula_arguments() + { + // The CellIs uses formula tags as arguments. Some producers generate extra empty + // formula tags and ClosedXml should be able to load CellIs conditional formatting + // with such extra tags without an exception. The test file has been modified to + // include extra formula tags and test checks that extra tags are ignored. + TestHelper.LoadAndAssert((_, ws) => + { + AssertFormulaArgs(ws, XLCFOperator.Between, "$D$2", "$E$2"); + AssertFormulaArgs(ws, XLCFOperator.NotBetween, "$D$3", "$E$3"); + AssertFormulaArgs(ws, XLCFOperator.GreaterThan, "$D$4"); + AssertFormulaArgs(ws, XLCFOperator.LessThan, "$D$5"); + AssertFormulaArgs(ws, XLCFOperator.Equal, "$D$6"); + }, @"Other\ConditionalFormats\Extra_formulas_CellIs_type.xlsx"); + + static void AssertFormulaArgs(IXLWorksheet ws, XLCFOperator cfOperator, params string[] expectedFormulas) + { + var cf = ws.ConditionalFormats.Single(cf => cf.ConditionalFormatType == XLConditionalFormatType.CellIs && cf.Operator == cfOperator); + Assert.AreEqual(expectedFormulas.Length, cf.Values.Count); + CollectionAssert.AreEqual(expectedFormulas, cf.Values.Select(v => v.Value.Value)); + } + } + + [Test] + public void Expression_type_skips_empty_formula_tags() + { + // The Expression uses formula tag as arguments. Some producers generate extra empty + // formula tags and ClosedXml should be able to load Expression conditional formatting + // with such extra tags without an exception. The test file has been modified to + // include extra formula tags and test checks that extra tags are ignored. + TestHelper.LoadAndAssert((_, ws) => + { + AssertFormulaArgs(ws, "A1:A1", "$C$1=5"); + AssertFormulaArgs(ws, "A2:A2", "$C$2=4"); + }, @"Other\ConditionalFormats\Extra_formulas_Expression_type.xlsx"); + + static void AssertFormulaArgs(IXLWorksheet ws, string range, string expectedFormula) + { + var cf = ws.ConditionalFormats.Single(cf => cf.ConditionalFormatType == XLConditionalFormatType.Expression && cf.Range.RangeAddress.ToString() == range); + Assert.AreEqual(1, cf.Values.Count); + CollectionAssert.AreEqual(expectedFormula, cf.Values[1].Value); + } + } } } diff --git a/ClosedXML.Tests/Excel/Coordinates/EmuTests.cs b/ClosedXML.Tests/Excel/Coordinates/EmuTests.cs new file mode 100644 index 000000000..125127677 --- /dev/null +++ b/ClosedXML.Tests/Excel/Coordinates/EmuTests.cs @@ -0,0 +1,38 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Coordinates; + +[TestFixture] +internal class EmuTests +{ + [TestCase(0.14, AbsLengthUnit.Inch, 128_016)] + [TestCase(2.43, AbsLengthUnit.Centimeter, 874_800)] + [TestCase(748, AbsLengthUnit.Millimeter, 26_928_000)] + [TestCase(23.9, AbsLengthUnit.Point, 303_530)] + [TestCase(4.157, AbsLengthUnit.Pica, 633_527)] + [TestCase(14.6, AbsLengthUnit.Emu, 15)] + [TestCase(2348.52, AbsLengthUnit.Inch, null)] + public void From_converts_value_to_emu(double value, AbsLengthUnit unit, int? emu) + { + Assert.AreEqual(emu, Emu.From(value, unit)?.Value); + } + + [TestCase(AbsLengthUnit.Inch, 5.9912904636920388)] + [TestCase(AbsLengthUnit.Centimeter, 15.217877777777778)] + [TestCase(AbsLengthUnit.Millimeter, 152.17877777777778)] + [TestCase(AbsLengthUnit.Point, 431.3729133858268)] + [TestCase(AbsLengthUnit.Pica, 35.94774278215223)] + [TestCase(AbsLengthUnit.Emu, 5_478_436)] + public void To_converts_to_specified_unit(AbsLengthUnit unit, double value) + { + Assert.AreEqual(value, Emu.From(5_478_436, AbsLengthUnit.Emu)?.To(unit)); + } + + [Test] + [SetCulture("cs-CZ")] + public void ToString_uses_culture_invariant_format() + { + Assert.AreEqual("1.4mm", Emu.From(1.4, AbsLengthUnit.Millimeter).ToString()); + } +} diff --git a/ClosedXML.Tests/Excel/Coordinates/XLNameTests.cs b/ClosedXML.Tests/Excel/Coordinates/XLNameTests.cs new file mode 100644 index 000000000..c1317fee9 --- /dev/null +++ b/ClosedXML.Tests/Excel/Coordinates/XLNameTests.cs @@ -0,0 +1,34 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Coordinates +{ + [TestFixture] + public class XLNameTests + { + [Test] + public void Workbook_scoped_name_is_compared_case_insensitive() + { + var lowerCase = new XLName("name"); + var upperCase = new XLName("NAME"); + + Assert.AreEqual(lowerCase, upperCase); + Assert.AreEqual(lowerCase.GetHashCode(), upperCase.GetHashCode()); + + Assert.AreNotEqual(lowerCase, new XLName("different_name")); + } + + [Test] + public void Sheet_scoped_name_is_compared_case_insensitive() + { + var lowerCase = new XLName("sheet", "name"); + var upperCase = new XLName("SHEET", "NAME"); + + Assert.AreEqual(lowerCase, upperCase); + Assert.AreEqual(lowerCase.GetHashCode(), upperCase.GetHashCode()); + + Assert.AreNotEqual(lowerCase, new XLName("Different sheet", "name")); + Assert.AreNotEqual(lowerCase, new XLName("sheet", "different_name")); + } + } +} diff --git a/ClosedXML.Tests/Excel/Coordinates/XLSheetAreaTests.cs b/ClosedXML.Tests/Excel/Coordinates/XLSheetAreaTests.cs new file mode 100644 index 000000000..fd0e6a028 --- /dev/null +++ b/ClosedXML.Tests/Excel/Coordinates/XLSheetAreaTests.cs @@ -0,0 +1,32 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Coordinates +{ + [TestFixture] + public class XLSheetAreaTests + { + [Test] + public void Sheet_name_is_compared_case_insensitive() + { + var upperCase = new XLBookArea("NAME", new XLSheetRange(1, 2, 3, 4)); + var lowerCase = new XLBookArea("name", new XLSheetRange(1, 2, 3, 4)); + Assert.AreEqual(upperCase.GetHashCode(), lowerCase.GetHashCode()); + Assert.AreEqual(upperCase, lowerCase); + } + + [Test] + public void Intersection_produces_range_intersection_in_same_sheet() + { + var sheetArea1 = new XLBookArea("SHEET", XLSheetRange.Parse("A1:C3")); + var sheetArea2 = new XLBookArea("sheet", XLSheetRange.Parse("B2:D4")); + var otherSheetArea = new XLBookArea("Other", XLSheetRange.Parse("B2:D4")); + + var sameSheetIntersection = sheetArea1.Intersect(sheetArea2); + Assert.AreEqual(new XLBookArea("sheet", XLSheetRange.Parse("B2:C3")), sameSheetIntersection); + + var differentSheetIntersection = sheetArea1.Intersect(otherSheetArea); + Assert.Null(differentSheetIntersection); + } + } +} diff --git a/ClosedXML.Tests/Excel/Coordinates/XLSheetPointTests.cs b/ClosedXML.Tests/Excel/Coordinates/XLSheetPointTests.cs new file mode 100644 index 000000000..30d218eff --- /dev/null +++ b/ClosedXML.Tests/Excel/Coordinates/XLSheetPointTests.cs @@ -0,0 +1,64 @@ +using ClosedXML.Excel; +using NUnit.Framework; +using System; + +namespace ClosedXML.Tests.Excel.Coordinates +{ + [TestFixture] + public class XLSheetPointTests + { + [TestCase("A1", 1, 1)] + [TestCase("AA1", 27, 1)] + [TestCase("AAA1", 703, 1)] + [TestCase("Z1", 26, 1)] + [TestCase("ZZ1", 702, 1)] + [TestCase("XFD1", 16384, 1)] + [TestCase("A1", 1, 1)] + [TestCase("A999", 1, 999)] + [TestCase("XFD1048576", 16384, 1048576)] + public void ParseCellRefsAccordingToGrammar(string cellRef, int columnNumber, int rowNumber) + { + var sheetPoint = XLSheetPoint.Parse(cellRef.AsSpan()); + Assert.AreEqual(columnNumber, sheetPoint.Column); + Assert.AreEqual(rowNumber, sheetPoint.Row); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("A")] + [TestCase("AA")] + [TestCase("1")] + [TestCase("11")] + [TestCase(" A1")] + [TestCase("A1 ")] + [TestCase("A 1")] + [TestCase("@1")] // @ is a char 'A' - 1 + [TestCase("[1")] // [ is a char 'Z' + 1 + [TestCase("A:")] // : is a char '9' + 1 + [TestCase("A/")] // / is a char '0' - 1 + [TestCase("A1:")] + [TestCase("A1/")] + [TestCase("A@1")] + [TestCase("A[1")] + [TestCase("XFE1")] + [TestCase("AAAA1")] + [TestCase("A1048577")] + [TestCase("A01")] + [TestCase("A0")] + [TestCase("A-1")] + public void InvalidInputsAreNotParsed(string cellRef) + { + Assert.Throws(() => XLSheetPoint.Parse(cellRef.AsSpan())); + } + + [TestCase("A1")] + [TestCase("DE1")] + [TestCase("D174")] + [TestCase("XFD1048576")] + public void CanFormatToString(string cellRef) + { + var r = XLSheetPoint.Parse(cellRef); + Assert.AreEqual(cellRef, r.ToString()); + } + } +} diff --git a/ClosedXML.Tests/Excel/Coordinates/XLSheetRangeTests.cs b/ClosedXML.Tests/Excel/Coordinates/XLSheetRangeTests.cs new file mode 100644 index 000000000..ad3735865 --- /dev/null +++ b/ClosedXML.Tests/Excel/Coordinates/XLSheetRangeTests.cs @@ -0,0 +1,243 @@ +using System; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Coordinates +{ + [TestFixture] + public class XLSheetRangeTests + { + [TestCase("A1", 1, 1, 1, 1)] + [TestCase("A1:Z100", 1, 1, 100, 26)] + [TestCase("BD14:EG256", 14, 56, 256, 137)] + [TestCase("A1:XFD1048576", 1, 1, 1048576, 16384)] + [TestCase("XFD1048576", 1048576, 16384, 1048576, 16384)] + [TestCase("XFD1048576:XFD1048576", 1048576, 16384, 1048576, 16384)] + public void ParseCellRefsAccordingToGrammar(string refText, int firstRow, int firstCol, int lastRow, int lastCol) + { + var reference = XLSheetRange.Parse(refText); + Assert.AreEqual(firstRow, reference.FirstPoint.Row); + Assert.AreEqual(firstCol, reference.FirstPoint.Column); + Assert.AreEqual(lastRow, reference.LastPoint.Row); + Assert.AreEqual(lastCol, reference.LastPoint.Column); + } + + [TestCase("")] + [TestCase("A1:")] + [TestCase(":A1")] + [TestCase("A1: A1")] + [TestCase(" A1:A1")] + [TestCase("A1:A1 ")] + [TestCase("B1:A1")] + [TestCase("A2:A1")] + public void InvalidInputsAreNotParsed(string invalidRef) + { + Assert.Throws(() => XLSheetRange.Parse(invalidRef)); + } + + [TestCase("A1:A1", "A1")] + [TestCase("DO974:LAR2487", "DO974:LAR2487")] + [TestCase("XFD1048576:XFD1048576", "XFD1048576")] + [TestCase("XFD1048575:XFD1048576", "XFD1048575:XFD1048576")] + public void CanFormatToString(string cellRef, string expected) + { + var r = XLSheetRange.Parse(cellRef); + Assert.AreEqual(expected, r.ToString()); + } + + [TestCase("A1", "A1", "A1")] + [TestCase("A1", "B3", "A1:B3")] + [TestCase("C2", "B3", "B2:C3")] + [TestCase("I6:J9", "L7", "I6:L9")] + [TestCase("B2:B4", "A3:C3", "A2:C4")] + [TestCase("B2:C3", "E5:F6", "B2:F6")] + public void RangeOperation(string leftOperand, string rightOperand, string expectedRange) + { + var left = XLSheetRange.Parse(leftOperand); + var right = XLSheetRange.Parse(rightOperand); + var expected = XLSheetRange.Parse(expectedRange); + + Assert.AreEqual(expected, left.Range(right)); + } + + [TestCase("A1", "A1", "A1")] + [TestCase("A1", "A2", null)] + [TestCase("B1:B3", "A2:C2", "B2")] + [TestCase("A1:A3", "B2:C2", null)] + [TestCase("A1:D6", "B2:C3", "B2:C3")] + [TestCase("A1:C6", "B4:E10", "B4:C6")] + public void IntersectOperation(string leftOperand, string rightOperand, string expectedRange) + { + var left = XLSheetRange.Parse(leftOperand); + var right = XLSheetRange.Parse(rightOperand); + var expected = expectedRange is null ? (XLSheetRange?)null : XLSheetRange.Parse(expectedRange); + + Assert.AreEqual(expected, left.Intersect(right)); + } + + [TestCase("A1", "A1", true)] + [TestCase("A1", "A2", false)] + [TestCase("B1:B3", "A2:C2", true)] + [TestCase("A1:A3", "B2:C2", false)] + [TestCase("A1:D6", "B2:C3", true)] + [TestCase("A1:C6", "B4:E10", true)] + public void Intersects_checks_whether_the_range_has_intersection_with_another(string leftOperand, string rightOperand, bool expected) + { + var left = XLSheetRange.Parse(leftOperand); + var right = XLSheetRange.Parse(rightOperand); + + Assert.AreEqual(expected, left.Intersects(right)); + } + + [TestCase("A1", "A1", true)] + [TestCase("B1:C3", "B1:C3", true)] + [TestCase("A1:D4", "B2:C3", true)] + [TestCase("B3:C3", "B2:C3", false)] + [TestCase("A2:C2", "B2:C3", false)] + public void Overlaps_checks_whether_left_fully_overlaps_right(string leftOperand, string rightOperand, bool expected) + { + var left = XLSheetRange.Parse(leftOperand); + var right = XLSheetRange.Parse(rightOperand); + + Assert.AreEqual(expected, left.Overlaps(right)); + } + + [TestCase("C4:F8", "C1:F3", "C4:F8")] // Inserted area is fully above + [TestCase("C4:F8", "A9:G12", "C4:F8")] // Inserted area is fully below + [TestCase("C4:F8", "G1:H5", "C4:F8")] // Inserted are is fully to the right + [TestCase("C4:F8", "C1:D11", "E4:H8")] // Inserted area at the left column of the area + [TestCase("C4:F8", "A1:B8", "E4:H8")] // Inserted area is fully to the left + [TestCase("C4:F8", "D4:E8", "C4:H8")] // Inserted into the area + [TestCase("C4:F8", "D2:I8", "C4:L8")] // Inside the area, overlapping = extend + [TestCase("C4:F8", "F4:F8", "C4:G8")] // Last column of the area, overlapping = extend + [TestCase("XFD1", "XFB1", null)] // Completely pushed out of the range + [TestCase("XFA1:XFD1", "XEZ1:XFA1", "XFC1:XFD1")] // Partially pushed out of the range + [TestCase("XFA1:XFD1", "XFB1:XFC1", "XFA1:XFD1")] // Extend below last row + public void TryInsertAreaAndShiftRight_without_partial_cover(string original, string inserted, string repositioned) + { + var originalArea = XLSheetRange.Parse(original); + var insertedArea = XLSheetRange.Parse(inserted); + var repositionedArea = repositioned is not null ? XLSheetRange.Parse(repositioned) : (XLSheetRange?)null; + + var success = originalArea.TryInsertAreaAndShiftRight(insertedArea, out var result); + + Assert.True(success); + Assert.AreEqual(repositionedArea, result); + } + + [TestCase("C4:F8", "B3:B4")] // Partially above + [TestCase("C4:F8", "B5:C7")] // In the middle + [TestCase("C4:F8", "A5:B9")] // Partially below + public void TryInsertAreaAndShiftRight_with_partial_cover(string original, string inserted) + { + var originalArea = XLSheetRange.Parse(original); + var insertedArea = XLSheetRange.Parse(inserted); + + Assert.False(originalArea.TryInsertAreaAndShiftRight(insertedArea, out var result)); + } + + [TestCase("D6:G10", "A1:C15", "D6:G10")] // Inserted are is fully to the left + [TestCase("D6:G10", "H1:K15", "D6:G10")] // Inserted are is fully to the right + [TestCase("D6:G10", "A11:K15", "D6:G10")] // Inserted are is fully below + [TestCase("D6:G10", "D6:G11", "D12:G16")] // Inserted area at the top row of the area + [TestCase("D6:G10", "C4:H7", "D10:G14")] // Inserted above the area + [TestCase("D6:G10", "D7:G9", "D6:G13")] // Inserted into the area + [TestCase("D6:G10", "A7:H9", "D6:G13")] // Inside the area, overlapping = extend + [TestCase("D6:G10", "D10:G11", "D6:G12")] // Last row of the area, overlapping = extend + [TestCase("A1048576", "A1048575", null)] // Completely pushed out of the range + [TestCase("A1048574:A1048576", "A1048570:A1048571", "A1048576")] // Partially pushed out of the range + [TestCase("A1048570:A1048572", "A1048571:A1048576", "A1048570:A1048576")] // Extend below last row + public void TryInsertAreaAndShiftDown_without_partial_cover(string original, string inserted, string repositioned) + { + var originalArea = XLSheetRange.Parse(original); + var insertedArea = XLSheetRange.Parse(inserted); + var repositionedArea = repositioned is not null ? XLSheetRange.Parse(repositioned) : (XLSheetRange?)null; + + var success = originalArea.TryInsertAreaAndShiftDown(insertedArea, out var result); + + Assert.True(success); + Assert.AreEqual(repositionedArea, result); + } + + [TestCase("D6:G10", "A6:E6")] // Left + [TestCase("D6:G10", "D5:D5")] // Above + [TestCase("D6:G10", "E7:H15")] // Right + public void TryInsertAreaAndShiftDown_with_partial_cover(string original, string inserted) + { + var originalArea = XLSheetRange.Parse(original); + var insertedArea = XLSheetRange.Parse(inserted); + + Assert.False(originalArea.TryInsertAreaAndShiftDown(insertedArea, out var result)); + } + + [TestCase("E4:G4", "B3:C5", "C4:E4")] // Deleted area fully to the left with overlapping width + [TestCase("E4:G4", "A2:D5", "A4:C4")] // The deleted are ends exactly at the column to the left of the area + [TestCase("E4:G4", "F1:F7", "E4:F4")] // The deleted is fully within the area, but not at left/right column + [TestCase("E4:G4", "E4:G4", null)] // Delete are exactly covers the area + [TestCase("E4:G4", "A1:Z9", null)] // Delete fully covers the area + [TestCase("E4:G4", "H1:K10", "E4:G4")] // The deleted is fully to the right of the area. + [TestCase("E4:G4", "G3:H5", "E4:F4")] // The deleted partially intersects the area and is to the right. + [TestCase("D4:E4", "A5:F9", "D4:E4")] // Deleted area is fully downward + [TestCase("D4:E4", "A1:F3", "D4:E4")] // Deleted area is fully upwards + [TestCase("D4:E4", "A5:F10", "D4:E4")] // Partial deletion is below -> not affected + public void TryDeleteAreaAndShiftLeft_without_partial_cover(string original, string deleted, string repositioned) + { + var originalArea = XLSheetRange.Parse(original); + var deletedArea = XLSheetRange.Parse(deleted); + var repositionedArea = repositioned is not null ? XLSheetRange.Parse(repositioned) : (XLSheetRange?)null; + + var success = originalArea.TryDeleteAreaAndShiftLeft(deletedArea, out var result); + + Assert.True(success); + Assert.AreEqual(repositionedArea, result); + } + + [TestCase("D4:E8", "A1:B5")] // Partial left + [TestCase("D4:E8", "D2:E7")] // Partial inside + [TestCase("D4:E8", "C4:D6")] // Partial left and inside + public void TryDeleteAreaAndShiftLeft_with_partial_cover(string original, string deleted) + { + var originalArea = XLSheetRange.Parse(original); + var deletedArea = XLSheetRange.Parse(deleted); + var success = originalArea.TryDeleteAreaAndShiftLeft(deletedArea, out var result); + + Assert.False(success); + Assert.Null(result); + } + + [TestCase("B5:B8", "A1:C3", "B2:B5")] // Deleted area fully above (with a row space) with overlapping width + [TestCase("B5:B8", "A2:C4", "B2:B5")] // The deleted are ends exactly at the row above the area + [TestCase("B5:B8", "A6:C7", "B5:B6")] // The deleted is fully within the area, but not at top/bottom row + [TestCase("B5:B8", "A5:C8", null)] // Delete are exactly covers the area + [TestCase("B5:B8", "A4:C9", null)] // Delete fully covers the area + [TestCase("B5:B8", "A9:C10", "B5:B8")] // The deleted is fully below the area. + [TestCase("B5:B8", "A6:C10", "B5:B5")] // The deleted partially intersects the area and is below. + [TestCase("B5:B8", "A1:A10", "B5:B8")] // Deleted area is fully on the left + [TestCase("B5:B8", "C1:C10", "B5:B8")] // Deleted area is fully on the right + [TestCase("B5:D8", "B9:C10", "B5:D8")] // Partial deletion is below -> not affected + public void TryDeleteAreaAndShiftUp_without_partial_cover(string leftOperand, string deleted, string expected) + { + var originalArea = XLSheetRange.Parse(leftOperand); + var deletedArea = XLSheetRange.Parse(deleted); + var expectedResult = expected is not null ? XLSheetRange.Parse(expected) : (XLSheetRange?)null; + + var success = originalArea.TryDeleteAreaAndShiftUp(deletedArea, out var result); + + Assert.True(success); + Assert.AreEqual(expectedResult, result); + } + + [TestCase("B5:D8", "A1:B3")] // Partial above + [TestCase("B5:D8", "C6:D8")] // Partial inside + [TestCase("B5:D8", "B1:B6")] // Partial above and inside + public void TryDeleteAreaAndShiftUp_with_partial_cover(string leftOperand, string deleted) + { + var originalArea = XLSheetRange.Parse(leftOperand); + var deletedArea = XLSheetRange.Parse(deleted); + var success = originalArea.TryDeleteAreaAndShiftUp(deletedArea, out var result); + + Assert.False(success); + Assert.Null(result); + } + } +} diff --git a/ClosedXML.Tests/Excel/Cubes/CubeTests.cs b/ClosedXML.Tests/Excel/Cubes/CubeTests.cs new file mode 100644 index 000000000..b613c905e --- /dev/null +++ b/ClosedXML.Tests/Excel/Cubes/CubeTests.cs @@ -0,0 +1,15 @@ +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Cubes +{ + [TestFixture] + public class CubeTests + { + [Test] + public void CalLoadAndSaveCubeFromRange() + { + // Disable validation, because connection type for range is 102 and validator expects at most 8. + TestHelper.LoadSaveAndCompare(@"Other\Cubes\CubeFromRange-Input.xlsx", @"Other\Cubes\CubeFromRange-Output.xlsx", validate: false); + } + } +} diff --git a/ClosedXML.Tests/Excel/DataValidations/DataValidationTests.cs b/ClosedXML.Tests/Excel/DataValidations/DataValidationTests.cs index 99e011e17..8158481ec 100644 --- a/ClosedXML.Tests/Excel/DataValidations/DataValidationTests.cs +++ b/ClosedXML.Tests/Excel/DataValidations/DataValidationTests.cs @@ -9,6 +9,30 @@ namespace ClosedXML.Tests.Excel.DataValidations [TestFixture] public class DataValidationTests { + + [Test] + public void Validation_Reference_List_Values_From_Separate_Sheet() + { + var wb = new XLWorkbook(); + IXLWorksheet valuesSheet = wb.Worksheets.Add("ValuesSheet"); + IXLCell cell = valuesSheet.Cell("E1"); + cell.SetValue("Value 1"); + cell = cell.CellBelow(); + cell.SetValue("Value 2"); + cell = cell.CellBelow(); + cell.SetValue("Value 3"); + cell = cell.CellBelow(); + cell.SetValue("Value 4"); + + IXLWorksheet uiSheet = wb.Worksheets.Add("UI Sheet"); + uiSheet.Cell("A1").SetValue("Cell below has validation with references to the 'ValuesSheet'."); + cell = uiSheet.Cell("A2"); + cell.GetDataValidation().List(valuesSheet.Range("ValuesSheet!$E$1:$E$4")); + + Assert.AreEqual(XLAllowedValues.List, cell.GetDataValidation().AllowedValues); + Assert.AreEqual("ValuesSheet!$E$1:$E$4", cell.GetDataValidation().Value); + } + [Test] public void Validation_1() { diff --git a/ClosedXML.Tests/Excel/Drawings/PictureTests.cs b/ClosedXML.Tests/Excel/Drawings/PictureTests.cs new file mode 100644 index 000000000..c849e045f --- /dev/null +++ b/ClosedXML.Tests/Excel/Drawings/PictureTests.cs @@ -0,0 +1,14 @@ +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Drawings +{ + [TestFixture] + public class PictureTests + { + [TestCase("Other.Drawings.picture-webp.xlsx")] + public void Can_load_and_save_workbook_with_image_type(string resourceWithImageType) + { + TestHelper.LoadSaveAndCompare(resourceWithImageType, resourceWithImageType); + } + } +} diff --git a/ClosedXML.Tests/Excel/Hyperlinks/XLHyperlinksTests.cs b/ClosedXML.Tests/Excel/Hyperlinks/XLHyperlinksTests.cs new file mode 100644 index 000000000..3eceeab3a --- /dev/null +++ b/ClosedXML.Tests/Excel/Hyperlinks/XLHyperlinksTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Hyperlinks; + +[TestFixture] +public class XLHyperlinksTests +{ + [TestCaseSource(nameof(StructuralChangeCases))] + public void Hyperlink_is_moved_on_sheet_structure_change(string hyperlinkPosition, Action structuralChange, string expectedPosition) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var hyperlink = new XLHyperlink("https://example.com"); + ws.Cell(hyperlinkPosition).SetHyperlink(hyperlink); + + structuralChange(ws); + + Assert.False(ws.Cell(hyperlinkPosition).HasHyperlink); + Assert.AreSame(ws.Cell(expectedPosition).GetHyperlink(), hyperlink); + } + + public static IEnumerable StructuralChangeCases + { + get + { + return new List<(string, Action, string)> + { + ("D5", ws => ws.Range("A5:B5").Delete(XLShiftDeletedCells.ShiftCellsLeft), "B5"), + ("D5", ws => ws.Range("B2:D4").Delete(XLShiftDeletedCells.ShiftCellsUp), "D2"), + ("D5", ws => ws.Column("D").InsertColumnsBefore(2), "F5"), // Insert column leftward + ("D5", ws => ws.Row(2).InsertRowsAbove(4), "D9"), // Insert row above + }.Select(x => new object[] { x.Item1, x.Item2, x.Item3 }); + } + } +} diff --git a/ClosedXML.Tests/Excel/IO/PivotCacheRecordsReaderTests.cs b/ClosedXML.Tests/Excel/IO/PivotCacheRecordsReaderTests.cs new file mode 100644 index 000000000..ed102d0c5 --- /dev/null +++ b/ClosedXML.Tests/Excel/IO/PivotCacheRecordsReaderTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using ClosedXML.Excel; +using ClosedXML.Excel.IO; +using ClosedXML.IO; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.IO; + +[TestFixture] +internal class PivotCacheRecordsReaderTests +{ + [Test] + public void Can_read_all_record_item_types() + { + var sharedItems = new XLPivotCacheSharedItems(); + sharedItems.Add("First shared item"); + sharedItems.Add("Second shared item"); + + ReadRecords( + new[] { "Field 1" }, + $""" + + + + + + + + + + + + + + + + + + + + + + + + """, + (cache, reader) => + { + reader.ReadRecordsToCache(); + + var values = cache.GetFieldValues(0); + Assert.That(values.GetCellValues(), Is.EquivalentTo(new XLCellValue[] + { + Blank.Value, + 5.5, + true, + XLError.NumberInvalid, + "Text", + new DateTime(2020, 10, 5), + "Second shared item" + })); + }, sharedItems); + } + + [TestCase("")] + [TestCase("")] + public void All_records_must_have_same_number_of_items_as_there_is_cache_fields(string recordItems) + { + ReadRecords( + new[] { "Field 1", "Field 2" }, + $""" + + {recordItems} + + """, + (_, reader) => + { + Assert.That(reader.ReadRecordsToCache, Throws + .Exception.TypeOf().And + .Message.StartsWith(PartStructureException.IncorrectElementsCount().Message)); + }); + } + + private static void ReadRecords(IReadOnlyList fieldNames, string recordsXml, Action assert, XLPivotCacheSharedItems sharedItems = null) + { + using var wb = new XLWorkbook(); + var cache = wb.PivotCachesInternal.Add(new XLPivotSourceConnection(0)); + sharedItems ??= new XLPivotCacheSharedItems(); + foreach (var fieldName in fieldNames) + { + cache.AddCachedField(fieldName, new XLPivotCacheValues(sharedItems, new XLPivotCacheValuesStats())); + } + + using var stream = new MemoryStream(XLHelper.NoBomUTF8.GetBytes(recordsXml)); + using var xmlTreeReader = new XmlTreeReader(stream, XmlToEnumMapper.Instance, false); + var reader = new PivotCacheRecordsReader(xmlTreeReader, cache); + assert(cache, reader); + } +} diff --git a/ClosedXML.Tests/Excel/ImageHandling/PictureTests.cs b/ClosedXML.Tests/Excel/ImageHandling/PictureTests.cs index 8451763c8..a6681fac6 100644 --- a/ClosedXML.Tests/Excel/ImageHandling/PictureTests.cs +++ b/ClosedXML.Tests/Excel/ImageHandling/PictureTests.cs @@ -470,5 +470,15 @@ public void CanCopyEmfPicture() img2 = ws2.Pictures.First(); Assert.AreEqual(XLPictureFormat.Emf, img2.Format); } + + [Test] + public void KeepOriginalDrawingShapesZOrder() + { + // File contains shapes and a picture in a mixed order. + using var stream = TestHelper.GetStreamFromResource(@"Other.Pictures.ImageShapeZOrder-Input.xlsx"); + TestHelper.CreateAndCompare( + () => new XLWorkbook(stream), + @"Other\Pictures\ImageShapeZOrder-Output.xlsx"); + } } } diff --git a/ClosedXML.Tests/Excel/InsertData/ArrayTypeReaderTests.cs b/ClosedXML.Tests/Excel/InsertData/ArrayTypeReaderTests.cs index 90ee13712..f0e25b418 100644 --- a/ClosedXML.Tests/Excel/InsertData/ArrayTypeReaderTests.cs +++ b/ClosedXML.Tests/Excel/InsertData/ArrayTypeReaderTests.cs @@ -30,14 +30,14 @@ public void CanGetPropertiesCount() public void CanGetRecordsCount() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - Assert.AreEqual(2, reader.GetRecordsCount()); + Assert.AreEqual(2, reader.GetRecords().Count()); } [Test] public void CanReadValues() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - var result = reader.GetData(); + var result = reader.GetRecords(); Assert.AreEqual(1, result.First().First()); Assert.AreEqual(3, result.First().Last()); diff --git a/ClosedXML.Tests/Excel/InsertData/DataRecordReaderTests.cs b/ClosedXML.Tests/Excel/InsertData/DataRecordReaderTests.cs index b7e481850..7c7eb69e5 100644 --- a/ClosedXML.Tests/Excel/InsertData/DataRecordReaderTests.cs +++ b/ClosedXML.Tests/Excel/InsertData/DataRecordReaderTests.cs @@ -61,14 +61,14 @@ public void CanGetPropertiesCount() public void CanGetRecordsCount() { var reader = InsertDataReaderFactory.Instance.CreateReader(GetData()); - Assert.AreEqual(3, reader.GetRecordsCount()); + Assert.AreEqual(3, reader.GetRecords().Count()); } [Test] public void CanGetData() { var reader = InsertDataReaderFactory.Instance.CreateReader(GetData()); - var result = reader.GetData().ToArray(); + var result = reader.GetRecords().ToArray(); Assert.AreEqual("Value 1", result.First().First()); Assert.AreEqual(100, result.First().Last()); diff --git a/ClosedXML.Tests/Excel/InsertData/DataRowReaderTests.cs b/ClosedXML.Tests/Excel/InsertData/DataRowReaderTests.cs index 5742c5f69..4e91a8008 100644 --- a/ClosedXML.Tests/Excel/InsertData/DataRowReaderTests.cs +++ b/ClosedXML.Tests/Excel/InsertData/DataRowReaderTests.cs @@ -40,14 +40,14 @@ public void CanGetPropertiesCount() public void CanGetRecordsCount() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - Assert.AreEqual(2, reader.GetRecordsCount()); + Assert.AreEqual(2, reader.GetRecords().Count()); } [Test] public void CanReadValue() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - var result = reader.GetData(); + var result = reader.GetRecords(); Assert.AreEqual("Smith", result.First().First()); Assert.AreEqual(33, result.First().Last()); diff --git a/ClosedXML.Tests/Excel/InsertData/ObjectReaderTests.cs b/ClosedXML.Tests/Excel/InsertData/ObjectReaderTests.cs index a4ebc8807..47f4fd61f 100644 --- a/ClosedXML.Tests/Excel/InsertData/ObjectReaderTests.cs +++ b/ClosedXML.Tests/Excel/InsertData/ObjectReaderTests.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using ClosedXML.Excel; namespace ClosedXML.Tests.Excel.InsertData { @@ -10,14 +11,14 @@ public class ObjectReaderTests { private static readonly TablesTests.TestObjectWithAttributes[] ObjectWithAttributes = { - new TablesTests.TestObjectWithAttributes + new() { Column1 = "Value 1", Column2 = "Value 2", UnOrderedColumn = 3, MyField = 4, }, - new TablesTests.TestObjectWithAttributes + new() { Column1 = "Value 5", Column2 = "Value 6", @@ -28,12 +29,12 @@ public class ObjectReaderTests private static readonly TablesTests.TestObjectWithoutAttributes[] ObjectWithoutAttributes = { - new TablesTests.TestObjectWithoutAttributes + new() { Column1 = "Value 9", Column2 = "Value 10" }, - new TablesTests.TestObjectWithoutAttributes + new() { Column1 = "Value 11", Column2 = "Value 12" @@ -42,13 +43,13 @@ public class ObjectReaderTests private static readonly TestPoint[] Structs = { - new TestPoint + new() { X = 1, Y = 2, Z = 3 }, - new TestPoint(), + new(), }; private static readonly TestPoint?[] NullableStructs = @@ -125,14 +126,14 @@ private static IEnumerable PropertyCounts public void CanGetRecordsCount() { var reader = InsertDataReaderFactory.Instance.CreateReader(ObjectWithAttributes); - Assert.AreEqual(2, reader.GetRecordsCount()); + Assert.AreEqual(2, reader.GetRecords().Count()); } [Test] public void CanReadValues_FromObject() { var reader = InsertDataReaderFactory.Instance.CreateReader(ObjectWithAttributes); - var result = reader.GetData(); + var result = reader.GetRecords(); var firstRecord = result.First().ToArray(); var lastRecord = result.Last().ToArray(); @@ -152,7 +153,7 @@ public void CanReadValues_FromObject() public void CanReadValues_FromStruct() { var reader = InsertDataReaderFactory.Instance.CreateReader(Structs); - var result = reader.GetData(); + var result = reader.GetRecords(); var firstRecord = result.First().ToArray(); var lastRecord = result.Last().ToArray(); @@ -163,14 +164,14 @@ public void CanReadValues_FromStruct() Assert.AreEqual(0, lastRecord[0]); Assert.AreEqual(0, lastRecord[1]); - Assert.AreEqual(null, lastRecord[2]); + Assert.AreEqual(Blank.Value, lastRecord[2]); } [Test] public void CanReadValues_FromNullableStruct() { var reader = InsertDataReaderFactory.Instance.CreateReader(NullableStructs); - var result = reader.GetData(); + var result = reader.GetRecords(); var firstRecord = result.First().ToArray(); var lastRecord = result.Last().ToArray(); @@ -179,9 +180,25 @@ public void CanReadValues_FromNullableStruct() Assert.AreEqual(2, firstRecord[1]); Assert.AreEqual(3, firstRecord[2]); - Assert.AreEqual(null, lastRecord[0]); - Assert.AreEqual(null, lastRecord[1]); - Assert.AreEqual(null, lastRecord[2]); + Assert.AreEqual(Blank.Value, lastRecord[0]); + Assert.AreEqual(Blank.Value, lastRecord[1]); + Assert.AreEqual(Blank.Value, lastRecord[2]); + } + + [Test] + public void IgnoresIndexers() + { + var data = new[] { new TestClassWithIndexer() }; + var reader = InsertDataReaderFactory.Instance.CreateReader(data); + + Assert.AreEqual(1, reader.GetPropertiesCount()); + Assert.AreEqual(nameof(TestClassWithIndexer.Value), reader.GetPropertyName(0)); + } + + private record TestClassWithIndexer + { + public int Value => 0; + public int this[int i] => 0; } private struct TestPoint diff --git a/ClosedXML.Tests/Excel/InsertData/SimpleNullableTypeReaderTests.cs b/ClosedXML.Tests/Excel/InsertData/SimpleNullableTypeReaderTests.cs index 692cb8e86..7199766ed 100644 --- a/ClosedXML.Tests/Excel/InsertData/SimpleNullableTypeReaderTests.cs +++ b/ClosedXML.Tests/Excel/InsertData/SimpleNullableTypeReaderTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using ClosedXML.Excel; namespace ClosedXML.Tests.Excel.InsertData { @@ -40,17 +41,17 @@ public void CanGetPropertiesCount() public void CanGetRecordsCount() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - Assert.AreEqual(3, reader.GetRecordsCount()); + Assert.AreEqual(3, reader.GetRecords().Count()); } [Test] public void CanReadValues() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - var result = reader.GetData(); + var result = reader.GetRecords(); Assert.AreEqual(1, result.First().Single()); - Assert.AreEqual(null, result.Last().Single()); + Assert.AreEqual(Blank.Value, result.Last().Single()); } } } diff --git a/ClosedXML.Tests/Excel/InsertData/SimpleTypeReaderTests.cs b/ClosedXML.Tests/Excel/InsertData/SimpleTypeReaderTests.cs index e8f6b010e..c39eaec92 100644 --- a/ClosedXML.Tests/Excel/InsertData/SimpleTypeReaderTests.cs +++ b/ClosedXML.Tests/Excel/InsertData/SimpleTypeReaderTests.cs @@ -41,14 +41,14 @@ public void CanGetPropertiesCount() public void CanGetRecordsCount() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - Assert.AreEqual(3, reader.GetRecordsCount()); + Assert.AreEqual(3, reader.GetRecords().Count()); } [Test] public void CanReadValues() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - var result = reader.GetData(); + var result = reader.GetRecords(); Assert.AreEqual(1, result.First().Single()); Assert.AreEqual(3, result.Last().Single()); diff --git a/ClosedXML.Tests/Excel/InsertData/UntypedObjectReaderTests.cs b/ClosedXML.Tests/Excel/InsertData/UntypedObjectReaderTests.cs index 4c6984d29..9640c24fd 100644 --- a/ClosedXML.Tests/Excel/InsertData/UntypedObjectReaderTests.cs +++ b/ClosedXML.Tests/Excel/InsertData/UntypedObjectReaderTests.cs @@ -2,6 +2,7 @@ using NUnit.Framework; using System.Collections; using System.Linq; +using ClosedXML.Excel; namespace ClosedXML.Tests.Excel.InsertData { @@ -53,7 +54,7 @@ public void CanGetPropertiesCount() public void CanGetRecordsCount() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - Assert.AreEqual(9, reader.GetRecordsCount()); + Assert.AreEqual(9, reader.GetRecords().Count()); } [Test] @@ -61,17 +62,17 @@ public void CanGetData() { var reader = InsertDataReaderFactory.Instance.CreateReader(_data); - var result = reader.GetData().ToArray(); + var result = reader.GetRecords().ToArray(); - Assert.AreEqual(new object[] { null }, result[0]); - Assert.AreEqual(new object[] { "Value 2", "Value 1", 4, 3 }, result[1]); - Assert.AreEqual(new object[] { null }, result[2]); - Assert.AreEqual(new object[] { null }, result[3]); - Assert.AreEqual(new object[] { null }, result[4]); - Assert.AreEqual(new object[] { 1, 2, 3 }, result[5]); - Assert.AreEqual(new object[] { 4, 5, 6, 7 }, result[6]); - Assert.AreEqual(new object[] { "Separator" }, result[7]); - Assert.AreEqual(new object[] { "Value 9", "Value 10" }, result[8]); + Assert.AreEqual(new XLCellValue[] { Blank.Value }, result[0]); + Assert.AreEqual(new XLCellValue[] { "Value 2", "Value 1", 4, 3 }, result[1]); + Assert.AreEqual(new XLCellValue[] { Blank.Value }, result[2]); + Assert.AreEqual(new XLCellValue[] { Blank.Value }, result[3]); + Assert.AreEqual(new XLCellValue[] { Blank.Value }, result[4]); + Assert.AreEqual(new XLCellValue[] { 1, 2, 3 }, result[5]); + Assert.AreEqual(new XLCellValue[] { 4, 5, 6, 7 }, result[6]); + Assert.AreEqual(new XLCellValue[] { "Separator" }, result[7]); + Assert.AreEqual(new XLCellValue[] { "Value 9", "Value 10" }, result[8]); } } } diff --git a/ClosedXML.Tests/Excel/Loading/LoadingTests.cs b/ClosedXML.Tests/Excel/Loading/LoadingTests.cs index 16a02aaac..2fdab4da0 100644 --- a/ClosedXML.Tests/Excel/Loading/LoadingTests.cs +++ b/ClosedXML.Tests/Excel/Loading/LoadingTests.cs @@ -4,6 +4,8 @@ using NUnit.Framework; using System; using System.Collections.Generic; +using System.Drawing; +using System.Globalization; using System.IO; using System.Linq; @@ -39,6 +41,7 @@ private static IEnumerable LOFiles // TODO: unpark all files var parkedForLater = new[] { + "TryToLoad.LO.xlsx.column-style-autofilter.xlsx", "TryToLoad.LO.xlsx.formats.xlsx", "TryToLoad.LO.xlsx.pivot_table.shared-group-field.xlsx", "TryToLoad.LO.xlsx.pivot_table.shared-nested-dategroup.xlsx", @@ -64,6 +67,26 @@ private static IEnumerable LOFiles } } + [Test] + public void CorrectlyLoadValidationWithSheetReference() + { + // Arrange + var path = TestHelper.GetResourcePath(@"TryToLoad\ValidationWithSheetReference.xlsx"); + using var stream = TestHelper.GetStreamFromResource(path); + + // Act + using var wb = new XLWorkbook(stream); + + // Assert + var ws = wb.Worksheet("UI Sheet"); + var B2 = ws.Cell("B2"); + Assert.AreEqual(XLAllowedValues.List, B2.GetDataValidation().AllowedValues); + Assert.AreEqual("$E$1:$E$4", B2.GetDataValidation().Value); + var A2 = ws.Cell("A2"); + Assert.AreEqual(XLAllowedValues.List, A2.GetDataValidation().AllowedValues); + Assert.AreEqual("ValuesSheet!$A$1:$A$4", A2.GetDataValidation().Value); + } + [Test] public void CanLoadAndManipulateFileWithEmptyTable() { @@ -166,10 +189,30 @@ public void CanLoadPivotTableSubtotals() var pt = ws.PivotTable("PivotTableSubtotals"); var subtotals = pt.RowLabels.Get("Group").Subtotals.ToArray(); - Assert.AreEqual(3, subtotals.Length); - Assert.AreEqual(XLSubtotalFunction.Average, subtotals[0]); - Assert.AreEqual(XLSubtotalFunction.Count, subtotals[1]); - Assert.AreEqual(XLSubtotalFunction.Sum, subtotals[2]); + + CollectionAssert.AreEquivalent(new[] + { + XLSubtotalFunction.Average, + XLSubtotalFunction.Count, + XLSubtotalFunction.Sum, + }, subtotals); + } + } + + [Test] + [Ignore("PT styles will be fixed in a different PR")] + public void CanLoadPivotTableWithBorder() + { + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"TryToLoad\PivotTableWithBorder.xlsx"))) + using (var wb = new XLWorkbook(stream)) + { + var pt = wb.Worksheet(1).PivotTables.PivotTable("PivotTable1"); + var border = pt.RowLabels.Single().StyleFormats.DataValuesFormat.Style.Border; + + Assert.AreEqual(XLBorderStyleValues.Thin, border.LeftBorder); + Assert.AreEqual(XLBorderStyleValues.Thin, border.TopBorder); + Assert.AreEqual(XLBorderStyleValues.Thin, border.RightBorder); + Assert.AreEqual(XLBorderStyleValues.Thin, border.BottomBorder); } } @@ -368,13 +411,24 @@ public void LoadFormulaCachedValue(string formula, object expectedCachedValue) [Test] public void LoadingOptions() { - using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Other\ExternalLinks\WorkbookWithExternalLink.xlsx"))) + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Examples\Misc\Formulas.xlsx"))) { - Assert.DoesNotThrow(() => new XLWorkbook(stream, new LoadOptions { RecalculateAllFormulas = false })); - Assert.Throws(() => new XLWorkbook(stream, new LoadOptions { RecalculateAllFormulas = true })); + Assert.DoesNotThrow(() => + { + // The value in the file is blank and kept. + using var wb = new XLWorkbook(stream, new LoadOptions { RecalculateAllFormulas = false }); + Assert.AreEqual(Blank.Value, wb.Worksheets.Single().Cell("C2").CachedValue); + }); + + Assert.DoesNotThrow(() => + { + // The value in the file is blank, but recalculation sets it to correct 3. + using var wb = new XLWorkbook(stream, new LoadOptions { RecalculateAllFormulas = true }); + Assert.AreEqual(3, wb.Worksheets.Single().Cell("C2").CachedValue); + }); - Assert.AreEqual(XLEventTracking.Disabled, new XLWorkbook(stream, new LoadOptions { EventTracking = XLEventTracking.Disabled }).EventTracking); - Assert.AreEqual(XLEventTracking.Enabled, new XLWorkbook(stream, new LoadOptions { EventTracking = XLEventTracking.Enabled }).EventTracking); + Assert.AreEqual(30, new XLWorkbook(stream, new LoadOptions { Dpi = new Point(30, 14) }).DpiX); + Assert.AreEqual(14, new XLWorkbook(stream, new LoadOptions { Dpi = new Point(30, 14) }).DpiY); } } @@ -405,6 +459,108 @@ public void CanCorrectLoadWorkbookCellWithStringDataType() } } + [Test] + public void CanCorrectLoadWorkbookCellsWithDateTimeDataTypeOrFormatting() + { + const string expected = "03/14/2012 13:30:55"; + TestHelper.LoadAndAssert(wb => + { + for (int row = 2; row < 18; row++) + { + var cellToCheck = wb.Worksheet(1).Cell(row, 2); + Assert.AreEqual(XLDataType.DateTime, cellToCheck.DataType, $"Cell B{row} has incorrect DataType"); + Assert.AreEqual(expected, cellToCheck.Value.ToString(CultureInfo.InvariantCulture), $"Cell B{row} value differs"); + } + }, @"TryToLoad\CellsWithDateTimeDataTypeOrFormatting.xlsx"); + } + + [Test] + public void CanCorrectLoadWorkbookCellsWithTimeSpanDataTypeOrFormatting() + { + string[] expected = Enumerable.Range(0, 10).Select(_ => "13:30:55.2").Concat(new[] { "0:30:55.2" }).ToArray(); + TestHelper.LoadAndAssert(wb => + { + for (int i = 0, row = 2; i < expected.Length; i++, row++) + { + var cellToCheck = wb.Worksheet(1).Cell(row, 2); + Assert.AreEqual(XLDataType.TimeSpan, cellToCheck.DataType, $"Cell B{row} has incorrect DataType"); + Assert.AreEqual(expected[i], cellToCheck.Value.ToString(CultureInfo.InvariantCulture), $"Cell B{row} value differs"); + } + }, @"TryToLoad\CellsWithTimeSpanDataTypeOrFormatting.xlsx"); + } + + [Test] + public void CanCorrectLoadWorkbookCellsWithDateTimesWithLocalePrefix() + { + TestHelper.LoadAndAssert(wb => + { + var ws = wb.Worksheet(1); + + Assert.AreEqual("21 January 2019", ws.Cell(1, 1).GetFormattedString()); + Assert.AreEqual("21-Jan-19", ws.Cell(2, 1).GetFormattedString()); + Assert.AreEqual("Monday, 21 January 2019", ws.Cell(3, 1).GetFormattedString()); + Assert.AreEqual("21 Jan 2019", ws.Cell(4, 1).GetFormattedString()); + }, @"TryToLoad\CellsWithDateTimeWithLocalePrefix.xlsx"); + } + + [Test] + public void CanCorrectLoadWorkbookDefaultColumnWidth() + { + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Examples\Styles\DefaultStyles.xlsx"))) + using (var wb = new XLWorkbook(stream)) + { + var defaultColumnWidth = wb.ColumnWidth; + var pixelWidth = XLHelper.NoCToPixels(defaultColumnWidth, wb.Style.Font, wb); + Assert.AreEqual(8.43, defaultColumnWidth, XLHelper.Epsilon); + Assert.AreEqual(64, pixelWidth); + } + + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"TryToLoad\DefaultColumnWidth.xlsx"))) + using (var wb = new XLWorkbook(stream)) + { + var defaultColumnWidth = wb.ColumnWidth; + var pixelWidth = XLHelper.NoCToPixels(defaultColumnWidth, wb.Style.Font, wb); + Assert.AreEqual(8.5, defaultColumnWidth, XLHelper.Epsilon); + Assert.AreEqual(56, pixelWidth); + } + } + + [Test] + public void CanCorrectLoadWorksheetBaseColumnWidth() + { + // default calibi font case + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Examples\Styles\DefaultStyles.xlsx"))) + using (var wb = new XLWorkbook(stream)) + { + var ws = wb.Worksheet(1); + Assert.AreEqual(8.43, ws.ColumnWidth, XLHelper.Epsilon); + Assert.AreEqual(8.43, ws.Column(1).Width, XLHelper.Epsilon); + } + + // worksheet has base column width. + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"TryToLoad\BaseColumnWidth.xlsx"))) + using (var wb = new XLWorkbook(stream)) + { + var ws = wb.Worksheet(1); + Assert.AreEqual(11.17, ws.ColumnWidth, XLHelper.Epsilon); + Assert.AreEqual(11.17, ws.Column(1).Width, XLHelper.Epsilon); + } + } + + [Test] + public void CanCorrectLoadWorksheetDefaultColumnWidth() + { + // worksheet has default column width. + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"TryToLoad\SheetDefaultColumnWidth.xlsx"))) + using (var wb = new XLWorkbook(stream)) + { + var ws = wb.Worksheet(1); + double pixelWidth = XLHelper.NoCToPixels(ws.Column(1).Width, ws.Style.Font, wb); + Assert.AreEqual(19.75, ws.ColumnWidth, XLHelper.Epsilon); + Assert.AreEqual(163, pixelWidth, XLHelper.Epsilon); + } + } + [Test] public void CanLoadFileWithInvalidSelectedRanges() { @@ -429,18 +585,43 @@ public void CanLoadCellsWithoutReferencesCorrectly() Assert.AreEqual("Page 1", ws.Name); - var expected = new Dictionary() + var expected = new Dictionary { ["A1"] = "Action Plan.Name", ["B1"] = "Action Plan.Description", ["A2"] = "Jerry", ["B2"] = "This is a longer Text.\nSecond line.\nThird line.", - ["A3"] = "", - ["B3"] = "" + ["A3"] = Blank.Value, + ["B3"] = Blank.Value }; foreach (var pair in expected) - Assert.AreEqual(pair.Value, ws.Cell(pair.Key).GetString(), pair.Key); + Assert.AreEqual(pair.Value, ws.Cell(pair.Key).Value, pair.Key); + } + } + + [Test] + public void CorrectlyLoadThemeColors() + { + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Other\StyleReferenceFiles\ThemeColors\inputfile.xlsx"))) + using (var wb = new XLWorkbook(stream)) + { + var ws = wb.Worksheet(1); + + var c = ws.Cell("A1"); + var themeColor = c.Style.Fill.BackgroundColor.ThemeColor; + Assert.AreEqual(XLThemeColor.Accent2, themeColor); + Assert.AreEqual("FFED7D31", wb.Theme.ResolveThemeColor(themeColor).Color.ToHex()); + + c = ws.Cell("A2"); + themeColor = c.Style.Fill.BackgroundColor.ThemeColor; + Assert.AreEqual(XLThemeColor.Accent4, themeColor); + Assert.AreEqual("FFFFC000", wb.Theme.ResolveThemeColor(themeColor).Color.ToHex()); + + c = ws.Cell("A3"); + themeColor = c.Style.Fill.BackgroundColor.ThemeColor; + Assert.AreEqual(XLThemeColor.Accent6, themeColor); + Assert.AreEqual("FF70AD47", wb.Theme.ResolveThemeColor(themeColor).Color.ToHex()); } } @@ -542,5 +723,77 @@ public void CanLoadProperties() } } } + + [Test] + public void CanLoadEmptyStyles() + { + // Stylesheet part exists, but no style collection elements are present + TestHelper.LoadAndAssert(wb => + { + using var ms = new MemoryStream(); + wb.SaveAs(ms, true); + }, @"TryToLoad\EmptyStyles.xlsx"); + } + + [Test] + public void CanLoadInvalidColors() + { + // The styles.xml contains two invalid colors: '0' and 'FED+'. Both + // should be loaded and no exception thrown. The colors are + // converted using an Excel algorithm. + TestHelper.LoadAndAssert(wb => + { + var ws = wb.Worksheets.Single(); + Assert.AreEqual(XLColor.FromArgb(0xFF000000), ws.Cell("A1").Style.Font.FontColor); + Assert.AreEqual(XLColor.FromArgb(0xFF000FED), ws.Cell("A2").Style.Fill.BackgroundColor); + }, @"TryToLoad\InvalidColors.xlsx"); + } + + [Test] + public void WontCrashOnSheetsWithoutRelId() + { + // Some non-Excel producers create workbooks where workbookPart declares + // sheet with empty r:id, but with name and sheetId. Content of such sheets + // isn't loaded even if relationship part declares implicit relationship to + // the worksheets, because workbook has explicit relationships with worksheet + // part (ISO29500 12.3.23). + // + // If excel finds sheet in workbook without r:id, it adds empty sheet with + // the specified name and so does ClosedXML. + TestHelper.LoadAndAssert(wb => + { + Assert.AreEqual(3, wb.Worksheets.Count); + + // First sheet has r:id, so it keeps content + Assert.AreEqual("Sheet1", wb.Worksheet("Sheet1").Cell("A1").Value); + + // Second sheet doesn't have r:id, so it is empty after load. + Assert.AreEqual(Blank.Value, wb.Worksheet("Sheet without relId").Cell("A1").Value); + + // Third sheet doesn't have r:id and it contains pivot table that is not loaded. + var ptSheet = wb.Worksheet("Pivot Sheet without relId"); + Assert.AreEqual(Blank.Value, ptSheet.Cell("A1").Value); + Assert.False(ptSheet.PivotTables.Any()); + }, @"TryToLoad\SheetsWithoutRelId.xlsx"); + } + + [Test] + public void CanLoadDialogSheet() + { + // Workbook can reference multiple different types of sheet, most common is worksheet, + // but there is also possibility of referencing dialogSheet (basically VBA dialog). + // dialogSheet is basically obsolete (from Excel 5.0), but still supported. Do not + // crash when such sheet is encountered. Test file also contains pivot table, because + // it originally crashed just before pivot table loading. + TestHelper.LoadAndAssert(wb => + { + // Dialog sheet + Assert.AreEqual(1, wb.UnsupportedSheets.Count); + + // Data and pivot sheets + Assert.AreEqual(2, wb.Worksheets.Count); + Assert.NotNull(wb.Worksheet("Pivot").PivotTables.Contains("PivotTable1")); + }, @"TryToLoad\DialogSheet.xlsx"); + } } } diff --git a/ClosedXML.Tests/Excel/Misc/CopyContentsTests.cs b/ClosedXML.Tests/Excel/Misc/CopyContentsTests.cs index 458e19480..e8c3fad26 100644 --- a/ClosedXML.Tests/Excel/Misc/CopyContentsTests.cs +++ b/ClosedXML.Tests/Excel/Misc/CopyContentsTests.cs @@ -1,3 +1,4 @@ +using System; using ClosedXML.Excel; using NUnit.Framework; using System.Linq; @@ -29,7 +30,7 @@ public void CopyConditionalFormatsCount() var wb = new XLWorkbook(); IXLWorksheet ws = wb.AddWorksheet("Sheet1"); ws.FirstCell().AddConditionalFormat().WhenContains("1").Fill.SetBackgroundColor(XLColor.Blue); - ws.Cell("A2").Value = ws.FirstCell(); + ws.Cell("A2").CopyFrom(ws.FirstCell().AsRange()); Assert.AreEqual(2, ws.ConditionalFormats.Count()); } @@ -41,7 +42,7 @@ public void CopyConditionalFormatsFixedNum() ws.Cell("A1").Value = "1"; ws.Cell("B1").Value = "1"; ws.Cell("A1").AddConditionalFormat().WhenEquals(1).Fill.SetBackgroundColor(XLColor.Blue); - ws.Cell("A2").Value = ws.Cell("A1"); + ws.Cell("A2").CopyFrom(ws.Cell("A1").AsRange()); Assert.IsTrue(ws.ConditionalFormats.Any(cf => cf.Values.Any(v => v.Value.Value == "1" && !v.Value.IsFormula))); Assert.IsTrue(ws.ConditionalFormats.Any(cf => cf.Values.Any(v => v.Value.Value == "1" && !v.Value.IsFormula))); } @@ -54,7 +55,7 @@ public void CopyConditionalFormatsFixedString() ws.Cell("A1").Value = "A"; ws.Cell("B1").Value = "B"; ws.Cell("A1").AddConditionalFormat().WhenEquals("A").Fill.SetBackgroundColor(XLColor.Blue); - ws.Cell("A2").Value = ws.Cell("A1"); + ws.Cell("A2").CopyFrom(ws.Cell("A1").AsRange()); Assert.IsTrue(ws.ConditionalFormats.Any(cf => cf.Values.Any(v => v.Value.Value == "A" && !v.Value.IsFormula))); Assert.IsTrue(ws.ConditionalFormats.Any(cf => cf.Values.Any(v => v.Value.Value == "A" && !v.Value.IsFormula))); } @@ -67,7 +68,7 @@ public void CopyConditionalFormatsFixedStringNum() ws.Cell("A1").Value = "1"; ws.Cell("B1").Value = "1"; ws.Cell("A1").AddConditionalFormat().WhenEquals("1").Fill.SetBackgroundColor(XLColor.Blue); - ws.Cell("A2").Value = ws.Cell("A1"); + ws.Cell("A2").CopyFrom(ws.Cell("A1").AsRange()); Assert.IsTrue(ws.ConditionalFormats.Any(cf => cf.Values.Any(v => v.Value.Value == "1" && !v.Value.IsFormula))); Assert.IsTrue(ws.ConditionalFormats.Any(cf => cf.Values.Any(v => v.Value.Value == "1" && !v.Value.IsFormula))); } @@ -80,7 +81,7 @@ public void CopyConditionalFormatsRelative() ws.Cell("A1").Value = "1"; ws.Cell("B1").Value = "1"; ws.Cell("A1").AddConditionalFormat().WhenEquals("=B1").Fill.SetBackgroundColor(XLColor.Blue); - ws.Cell("A2").Value = ws.Cell("A1"); + ws.Cell("A2").CopyFrom(ws.Cell("A1").AsRange()); Assert.IsTrue(ws.ConditionalFormats.Any(cf => cf.Values.Any(v => v.Value.Value == "B1" && v.Value.IsFormula))); Assert.IsTrue(ws.ConditionalFormats.Any(cf => cf.Values.Any(v => v.Value.Value == "B2" && v.Value.IsFormula))); } @@ -127,5 +128,25 @@ public void UpdateCellsWorksheetTest() Assert.AreEqual("Sheet2", ws2.FirstCell().Address.Worksheet.Name); } } + + [Test] + public void CopyHyperlinksAmongSheets() + { + using var wb = new XLWorkbook(); + var source = wb.AddWorksheet(); + var target = wb.AddWorksheet(); + source.Cell("A1") + .SetValue("link") + .CreateHyperlink() + .SetValues("https://example.com", "Test tooltip"); + + source.Cell("A1").AsRange().CopyTo(target.Cell("B7")); + + var cell = target.Cell("B7"); + Assert.True(cell.HasHyperlink); + Assert.True(cell.GetHyperlink().IsExternal); + Assert.AreEqual(new Uri("https://example.com"), cell.GetHyperlink().ExternalAddress); + Assert.AreEqual("Test tooltip", cell.GetHyperlink().Tooltip); + } } } diff --git a/ClosedXML.Tests/Excel/Misc/FormulaTests.cs b/ClosedXML.Tests/Excel/Misc/FormulaTests.cs index cc6d71d89..105e7709d 100644 --- a/ClosedXML.Tests/Excel/Misc/FormulaTests.cs +++ b/ClosedXML.Tests/Excel/Misc/FormulaTests.cs @@ -1,6 +1,4 @@ using ClosedXML.Excel; -using ClosedXML.Excel.CalcEngine; -using ClosedXML.Excel.CalcEngine.Exceptions; using NUnit.Framework; using System; using System.Linq; @@ -113,7 +111,6 @@ public void DateAgainstStringComparison() { var ws = wb.AddWorksheet("Sheet1"); ws.Cell("A1").Value = new DateTime(2016, 1, 1); - ws.Cell("A1").DataType = XLDataType.DateTime; ws.Cell("A2").FormulaA1 = @"=IF(A1 = """", ""A"", ""B"")"; var actual = ws.Cell("A2").Value; @@ -176,16 +173,30 @@ public void FormulaThatStartsWithEqualsAndPlus() Assert.AreEqual("is", actual); } + [Test] + public void UnimplementedStandardFunctionsAreEvaluatedToNameNotFoundError() + { + // RTD will never be implemented + var actual = XLWorkbook.EvaluateExpr("RTD(\"MyRTDServerProdID\",\"MyServer\",\"RaceNum\",\"RunnerID\",\"StatType\")"); + Assert.AreEqual(XLError.NameNotRecognized, actual); + } + [Test] public void FormulasWithErrors() { - Assert.Throws(() => XLWorkbook.EvaluateExpr("YEAR(#REF!)")); - Assert.Throws(() => XLWorkbook.EvaluateExpr("YEAR(#VALUE!)")); - Assert.Throws(() => XLWorkbook.EvaluateExpr("YEAR(#DIV/0!)")); - Assert.Throws(() => XLWorkbook.EvaluateExpr("YEAR(#NAME?)")); - Assert.Throws(() => XLWorkbook.EvaluateExpr("YEAR(#N/A)")); - Assert.Throws(() => XLWorkbook.EvaluateExpr("YEAR(#NULL!)")); - Assert.Throws(() => XLWorkbook.EvaluateExpr("YEAR(#NUM!)")); + Assert.AreEqual(XLError.CellReference, XLWorkbook.EvaluateExpr("YEAR(#REF!)")); + Assert.AreEqual(XLError.IncompatibleValue, XLWorkbook.EvaluateExpr("YEAR(#VALUE!)")); + Assert.AreEqual(XLError.DivisionByZero, XLWorkbook.EvaluateExpr("YEAR(#DIV/0!)")); + Assert.AreEqual(XLError.NameNotRecognized, XLWorkbook.EvaluateExpr("YEAR(#NAME?)")); + Assert.AreEqual(XLError.NoValueAvailable, XLWorkbook.EvaluateExpr("YEAR(#N/A)")); + Assert.AreEqual(XLError.NullValue, XLWorkbook.EvaluateExpr("YEAR(#NULL!)")); + Assert.AreEqual(XLError.NumberInvalid, XLWorkbook.EvaluateExpr("YEAR(#NUM!)")); + } + + [Test] + public void LegacyFunctionPropagateErrorWithoutException() + { + Assert.AreEqual(XLError.NameNotRecognized, XLWorkbook.EvaluateExpr("SIN(YEAR(#NAME?))+1")); } [Test] @@ -208,7 +219,7 @@ public void UnicodeLetterParsing() } } - [Test] + [Test, Ignore("Shifting formulas is done by regexp that breaks array formula.")] public void ShiftFormula() { using (var wb = new XLWorkbook()) @@ -216,13 +227,14 @@ public void ShiftFormula() var ws = wb.AddWorksheet(); ws.Cell("B1").FormulaA1 = "ATAN2(C1,C2)"; ws.Cell("B2").FormulaA1 = "DEC2HEX(C2)"; - ws.Range("B3:B5").FormulaA1 = "{DAYS360(C3:C5, D3:D5)}"; + ws.Range("B3:B5").FormulaArrayA1 = "DAYS360(C3:C5, D3:D5)"; ws.Column(1).Delete(); Assert.AreEqual("ATAN2(B1,B2)", ws.Cell("A1").FormulaA1); Assert.AreEqual("DEC2HEX(B2)", ws.Cell("A2").FormulaA1); - Assert.AreEqual("{DAYS360(B3:B5, C3:C5)}", ws.Cell("A3").FormulaA1); + Assert.True(ws.Cell("A3").HasArrayFormula); + Assert.AreEqual("DAYS360(B3:B5, C3:C5)", ws.Cell("A3").FormulaA1); } } } diff --git a/ClosedXML.Tests/Excel/Misc/SearchTests.cs b/ClosedXML.Tests/Excel/Misc/SearchTests.cs index 661efdde0..4f155c4e4 100644 --- a/ClosedXML.Tests/Excel/Misc/SearchTests.cs +++ b/ClosedXML.Tests/Excel/Misc/SearchTests.cs @@ -21,23 +21,23 @@ public void TestSearch() foundCells = ws.Search("Initial Value"); Assert.AreEqual(1, foundCells.Count()); Assert.AreEqual("B2", foundCells.Single().Address.ToString()); - Assert.AreEqual("Initial Value", foundCells.Single().GetString()); + Assert.AreEqual("Initial Value", foundCells.Single().GetText()); foundCells = ws.Search("Using"); Assert.AreEqual(2, foundCells.Count()); Assert.AreEqual("D2", foundCells.First().Address.ToString()); - Assert.AreEqual("Using Get...()", foundCells.First().GetString()); + Assert.AreEqual("Using Get...()", foundCells.First().GetText()); Assert.AreEqual(2, foundCells.Count()); Assert.AreEqual("E2", foundCells.Last().Address.ToString()); - Assert.AreEqual("Using GetValue()", foundCells.Last().GetString()); + Assert.AreEqual("Using GetValue()", foundCells.Last().GetText()); foundCells = ws.Search("1234"); - Assert.AreEqual(4, foundCells.Count()); - Assert.AreEqual("C5,D5,E5,F5", string.Join(",", foundCells.Select(c => c.Address.ToString()).ToArray())); + Assert.AreEqual(5, foundCells.Count()); + Assert.AreEqual("B5,C5,D5,E5,F5", string.Join(",", foundCells.Select(c => c.Address.ToString()).ToArray())); foundCells = ws.Search("Sep"); - Assert.AreEqual(2, foundCells.Count()); - Assert.AreEqual("B3,G3", string.Join(",", foundCells.Select(c => c.Address.ToString()).ToArray())); + Assert.AreEqual(1, foundCells.Count()); + Assert.AreEqual("G3", string.Join(",", foundCells.Select(c => c.Address.ToString()).ToArray())); foundCells = ws.Search("1234", CompareOptions.Ordinal, true); Assert.AreEqual(5, foundCells.Count()); diff --git a/ClosedXML.Tests/Excel/Misc/XLWorkbookTests.cs b/ClosedXML.Tests/Excel/Misc/XLWorkbookTests.cs index d12febabf..92450259e 100644 --- a/ClosedXML.Tests/Excel/Misc/XLWorkbookTests.cs +++ b/ClosedXML.Tests/Excel/Misc/XLWorkbookTests.cs @@ -25,7 +25,7 @@ public void Cell2() ws.FirstCell().SetValue(1).AddToNamed("Result", XLScope.Worksheet); IXLCell cell = wb.Cell("Sheet1!Result"); Assert.IsNotNull(cell); - Assert.AreEqual(1, cell.GetValue()); + Assert.AreEqual(1, cell.Value); } [Test] @@ -36,7 +36,7 @@ public void Cell3() ws.FirstCell().SetValue(1).AddToNamed("Result"); IXLCell cell = wb.Cell("Sheet1!Result"); Assert.IsNotNull(cell); - Assert.AreEqual(1, cell.GetValue()); + Assert.AreEqual(1, cell.Value); } [Test] @@ -57,7 +57,7 @@ public void Cells2() IXLCells cells = wb.Cells("Sheet1!Result, ABC"); Assert.IsNotNull(cells); Assert.AreEqual(1, cells.Count()); - Assert.AreEqual(1, cells.First().GetValue()); + Assert.AreEqual(1, cells.First().Value); } [Test] @@ -69,7 +69,7 @@ public void Cells3() IXLCells cells = wb.Cells("Sheet1!Result, ABC"); Assert.IsNotNull(cells); Assert.AreEqual(1, cells.Count()); - Assert.AreEqual(1, cells.First().GetValue()); + Assert.AreEqual(1, cells.First().Value); } [Test] @@ -84,8 +84,8 @@ public void GetCellFromFullAddress() var c1_full = wb.Cell("Sheet1!C123"); var c2_full = wb.Cell("'O'Sheet 2'!B7"); - Assert.AreSame(c1, c1_full); - Assert.AreSame(c2, c2_full); + Assert.AreEqual(c1, c1_full); + Assert.AreEqual(c2, c2_full); Assert.NotNull(c1_full); Assert.NotNull(c2_full); } @@ -159,46 +159,46 @@ public void GetRangesFromNonExistingFullAddress(string rangesAddress) } [Test] - public void NamedRange1() + public void Non_existent_defined_name_returns_null() { var wb = new XLWorkbook(); - IXLNamedRange range = wb.NamedRange("ABC"); - Assert.IsNull(range); + var definedName = wb.DefinedName("ABC"); + Assert.IsNull(definedName); } [Test] - public void NamedRange2() + public void Sheet_specified_defined_name_is_retrieved_from_sheet_if_defined_there() { var wb = new XLWorkbook(); - IXLWorksheet ws = wb.AddWorksheet("Sheet1"); + var ws = wb.AddWorksheet("Sheet1"); ws.FirstCell().SetValue(1).AddToNamed("Result", XLScope.Worksheet); - IXLNamedRange range = wb.NamedRange("Sheet1!Result"); - Assert.IsNotNull(range); - Assert.AreEqual(1, range.Ranges.Count); - Assert.AreEqual(1, range.Ranges.Cells().Count()); - Assert.AreEqual(1, range.Ranges.First().FirstCell().GetValue()); + var definedName = wb.DefinedName("Sheet1!Result"); + Assert.IsNotNull(definedName); + Assert.AreEqual(1, definedName.Ranges.Count); + Assert.AreEqual(1, definedName.Ranges.Cells().Count()); + Assert.AreEqual(1, definedName.Ranges.First().FirstCell().Value); } [Test] - public void NamedRange3() + public void Sheet_specified_defined_name_returns_null_if_not_defined_in_sheet_nor_workbook() { var wb = new XLWorkbook(); - IXLWorksheet ws = wb.AddWorksheet("Sheet1"); - IXLNamedRange range = wb.NamedRange("Sheet1!Result"); - Assert.IsNull(range); + var ws = wb.AddWorksheet("Sheet1"); + var definedName = wb.DefinedName("Sheet1!Result"); + Assert.IsNull(definedName); } [Test] - public void NamedRange4() + public void Sheet_specified_defined_name_falls_back_to_workbook_scoped_defined_name_if_not_defined_in_sheet() { var wb = new XLWorkbook(); - IXLWorksheet ws = wb.AddWorksheet("Sheet1"); + var ws = wb.AddWorksheet("Sheet1"); ws.FirstCell().SetValue(1).AddToNamed("Result"); - IXLNamedRange range = wb.NamedRange("Sheet1!Result"); - Assert.IsNotNull(range); - Assert.AreEqual(1, range.Ranges.Count); - Assert.AreEqual(1, range.Ranges.Cells().Count()); - Assert.AreEqual(1, range.Ranges.First().FirstCell().GetValue()); + var definedName = wb.DefinedName("Sheet1!Result"); + Assert.IsNotNull(definedName); + Assert.AreEqual(1, definedName.Ranges.Count); + Assert.AreEqual(1, definedName.Ranges.Cells().Count()); + Assert.AreEqual(1, definedName.Ranges.First().FirstCell().Value); } [Test] @@ -218,7 +218,7 @@ public void Range2() IXLRange range = wb.Range("Sheet1!Result"); Assert.IsNotNull(range); Assert.AreEqual(1, range.Cells().Count()); - Assert.AreEqual(1, range.FirstCell().GetValue()); + Assert.AreEqual(1, range.FirstCell().Value); } [Test] @@ -230,7 +230,7 @@ public void Range3() IXLRange range = wb.Range("Sheet1!Result"); Assert.IsNotNull(range); Assert.AreEqual(1, range.Cells().Count()); - Assert.AreEqual(1, range.FirstCell().GetValue()); + Assert.AreEqual(1, range.FirstCell().Value); } [Test] @@ -251,7 +251,7 @@ public void Ranges2() IXLRanges ranges = wb.Ranges("Sheet1!Result, ABC"); Assert.IsNotNull(ranges); Assert.AreEqual(1, ranges.Cells().Count()); - Assert.AreEqual(1, ranges.First().FirstCell().GetValue()); + Assert.AreEqual(1, ranges.First().FirstCell().Value); } [Test] @@ -263,7 +263,7 @@ public void Ranges3() IXLRanges ranges = wb.Ranges("Sheet1!Result, ABC"); Assert.IsNotNull(ranges); Assert.AreEqual(1, ranges.Cells().Count()); - Assert.AreEqual(1, ranges.First().FirstCell().GetValue()); + Assert.AreEqual(1, ranges.First().FirstCell().Value); } [Test] @@ -272,8 +272,8 @@ public void WbNamedCell() var wb = new XLWorkbook(); IXLWorksheet ws = wb.Worksheets.Add("Sheet1"); ws.Cell(1, 1).SetValue("Test").AddToNamed("TestCell"); - Assert.AreEqual("Test", wb.Cell("TestCell").GetString()); - Assert.AreEqual("Test", ws.Cell("TestCell").GetString()); + Assert.AreEqual("Test", wb.Cell("TestCell").GetText()); + Assert.AreEqual("Test", ws.Cell("TestCell").GetText()); } [Test] @@ -284,12 +284,12 @@ public void WbNamedCells() ws.Cell(1, 1).SetValue("Test").AddToNamed("TestCell"); ws.Cell(2, 1).SetValue("B").AddToNamed("Test2"); IXLCells wbCells = wb.Cells("TestCell, Test2"); - Assert.AreEqual("Test", wbCells.First().GetString()); - Assert.AreEqual("B", wbCells.Last().GetString()); + Assert.AreEqual("Test", wbCells.First().GetText()); + Assert.AreEqual("B", wbCells.Last().GetText()); IXLCells wsCells = ws.Cells("TestCell, Test2"); - Assert.AreEqual("Test", wsCells.First().GetString()); - Assert.AreEqual("B", wsCells.Last().GetString()); + Assert.AreEqual("Test", wsCells.First().GetText()); + Assert.AreEqual("B", wsCells.Last().GetText()); } [Test] @@ -329,7 +329,7 @@ public void WbNamedRangesOneString() { var wb = new XLWorkbook(); IXLWorksheet ws = wb.Worksheets.Add("Sheet1"); - wb.NamedRanges.Add("TestRange", "Sheet1!$A$1,Sheet1!$A$3"); + wb.DefinedNames.Add("TestRange", "Sheet1!$A$1,Sheet1!$A$3"); IXLRanges wbRanges = ws.Ranges("TestRange"); Assert.AreEqual("$A$1:$A$1", wbRanges.First().RangeAddress.ToStringFixed()); @@ -359,7 +359,7 @@ public void WbProtect2() using (var wb = new XLWorkbook()) { var ws = wb.Worksheets.Add("Sheet1"); - wb.Protect(true, false); + wb.Protect(XLWorkbookProtectionElements.Windows); Assert.IsTrue(wb.LockStructure); Assert.IsFalse(wb.LockWindows); Assert.IsFalse(wb.IsPasswordProtected); @@ -406,7 +406,7 @@ public void WbProtect5() using (var wb = new XLWorkbook()) { var ws = wb.Worksheets.Add("Sheet1"); - wb.Protect(true, false, "Abc@123"); + wb.Protect("Abc@123", XLProtectionAlgorithm.DefaultProtectionAlgorithm, XLWorkbookProtectionElements.Windows); Assert.IsTrue(wb.LockStructure); Assert.IsFalse(wb.LockWindows); Assert.IsTrue(wb.IsPasswordProtected); diff --git a/ClosedXML.Tests/Excel/Misc/XlHelperTests.cs b/ClosedXML.Tests/Excel/Misc/XlHelperTests.cs index e963a208a..6e7fa79f3 100644 --- a/ClosedXML.Tests/Excel/Misc/XlHelperTests.cs +++ b/ClosedXML.Tests/Excel/Misc/XlHelperTests.cs @@ -177,7 +177,6 @@ private static int NaiveGetColumnNumberFromLetter(string columnLetter) /// /// The column number to translate into a column letter. /// if set to true the column letter will be restricted to the allowed range. - /// private static string NaiveGetColumnLetterFromNumber(int columnNumber, bool trimToAllowed = false) { if (trimToAllowed) columnNumber = XLHelper.TrimColumnNumber(columnNumber); diff --git a/ClosedXML.Tests/Excel/Misc/XmlEncoderTests.cs b/ClosedXML.Tests/Excel/Misc/XmlEncoderTests.cs index 532d95b08..f168b2e99 100644 --- a/ClosedXML.Tests/Excel/Misc/XmlEncoderTests.cs +++ b/ClosedXML.Tests/Excel/Misc/XmlEncoderTests.cs @@ -1,5 +1,7 @@ using ClosedXML.Utils; using NUnit.Framework; +using System.IO; +using ClosedXML.Excel; namespace ClosedXML.Tests.Excel { @@ -11,13 +13,25 @@ public void TestControlChars() { Assert.AreEqual("_x0001_ _x0002_ _x0003_ _x0004_", XmlEncoder.EncodeString("\u0001 \u0002 \u0003 \u0004")); Assert.AreEqual("_x0005_ _x0006_ _x0007_ _x0008_", XmlEncoder.EncodeString("\u0005 \u0006 \u0007 \u0008")); + } + + [Test] + public void AstralUnicodeCharsAreWrittenWithoutOpenXmlEncoding() + { + using var sr = new StreamReader(TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Other\Unicode\let_it_go_in_emoji.txt"))); + var surrogateEmoji = sr.ReadToEnd(); + + TestHelper.CreateAndCompare(() => + { + var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); - Assert.AreEqual("\u0001 \u0002 \u0003 \u0004", XmlEncoder.DecodeString("_x0001_ _x0002_ _x0003_ _x0004_")); - Assert.AreEqual("\u0005 \u0006 \u0007 \u0008", XmlEncoder.DecodeString("_x0005_ _x0006_ _x0007_ _x0008_")); - Assert.AreEqual("\uAABB \uAABB", XmlEncoder.DecodeString("_xaaBB_ _xAAbb_")); + var cell = ws.FirstCell(); + cell.Value = "This emoji version of Let It Go from Frozen:"; + cell.CellBelow().Value = surrogateEmoji; - // https://github.com/ClosedXML/ClosedXML/issues/1154 - Assert.AreEqual("_Xceed_Something", XmlEncoder.DecodeString("_Xceed_Something")); + return wb; + }, @"Other\Unicode\let_it_go_in_emoji-outputfile.xlsx"); } } } diff --git a/ClosedXML.Tests/Excel/NamedRanges/NamedRangesTests.cs b/ClosedXML.Tests/Excel/NamedRanges/NamedRangesTests.cs index b1e856530..1431fb6bb 100644 --- a/ClosedXML.Tests/Excel/NamedRanges/NamedRangesTests.cs +++ b/ClosedXML.Tests/Excel/NamedRanges/NamedRangesTests.cs @@ -2,14 +2,24 @@ using ClosedXML.Excel; using NUnit.Framework; using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using ClosedXML.Parser; namespace ClosedXML.Tests.Excel { [TestFixture] public class NamedRangesTests { + [Test] + public void Formula_must_be_valid() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + Assert.Throws(() => wb.DefinedNames.Add("Test", "SUM(Sheet7!A4")); + } + [Test] public void CanEvaluateNamedMultiRange() { @@ -18,7 +28,7 @@ public void CanEvaluateNamedMultiRange() var ws1 = wb.AddWorksheet("Sheet1"); ws1.Range("A1:C1").Value = 1; ws1.Range("A3:C3").Value = 3; - wb.NamedRanges.Add("TEST", ws1.Ranges("A1:C1,A3:C3")); + wb.DefinedNames.Add("TEST", ws1.Ranges("A1:C1,A3:C3")); ws1.Cell(2, 1).FormulaA1 = "=SUM(TEST)"; @@ -33,24 +43,24 @@ public void CanGetNamedFromAnother() var ws1 = wb.Worksheets.Add("Sheet1"); ws1.Cell("A1").SetValue(1).AddToNamed("value1"); - Assert.AreEqual(1, wb.Cell("value1").GetValue()); - Assert.AreEqual(1, wb.Range("value1").FirstCell().GetValue()); + Assert.AreEqual(1, wb.Cell("value1").Value); + Assert.AreEqual(1, wb.Range("value1").FirstCell().Value); - Assert.AreEqual(1, ws1.Cell("value1").GetValue()); - Assert.AreEqual(1, ws1.Range("value1").FirstCell().GetValue()); + Assert.AreEqual(1, ws1.Cell("value1").Value); + Assert.AreEqual(1, ws1.Range("value1").FirstCell().Value); var ws2 = wb.Worksheets.Add("Sheet2"); ws2.Cell("A1").SetFormulaA1("=value1").AddToNamed("value2"); - Assert.AreEqual(1, wb.Cell("value2").GetValue()); - Assert.AreEqual(1, wb.Range("value2").FirstCell().GetValue()); + Assert.AreEqual(1, wb.Cell("value2").Value); + Assert.AreEqual(1, wb.Range("value2").FirstCell().Value); - Assert.AreEqual(1, ws2.Cell("value1").GetValue()); - Assert.AreEqual(1, ws2.Range("value1").FirstCell().GetValue()); + Assert.AreEqual(1, ws2.Cell("value1").Value); + Assert.AreEqual(1, ws2.Range("value1").FirstCell().Value); - Assert.AreEqual(1, ws2.Cell("value2").GetValue()); - Assert.AreEqual(1, ws2.Range("value2").FirstCell().GetValue()); + Assert.AreEqual(1, ws2.Cell("value2").Value); + Assert.AreEqual(1, ws2.Range("value2").FirstCell().Value); } [Test] @@ -66,7 +76,7 @@ public void CanGetValidNamedRanges() ws1.Range("A2:D2").AddToNamed("Named range 2", XLScope.Workbook); ws2.Range("A3:D3").AddToNamed("Named range 3", XLScope.Worksheet); ws2.Range("A4:D4").AddToNamed("Named range 4", XLScope.Workbook); - wb.NamedRanges.Add("Named range 5", new XLRanges + wb.DefinedNames.Add("Named range 5", new XLRanges { ws1.Range("A5:D5"), ws3.Range("A5:D5") @@ -75,10 +85,10 @@ public void CanGetValidNamedRanges() ws2.Delete(); ws3.Delete(); - var globalValidRanges = wb.NamedRanges.ValidNamedRanges(); - var globalInvalidRanges = wb.NamedRanges.InvalidNamedRanges(); - var localValidRanges = ws1.NamedRanges.ValidNamedRanges(); - var localInvalidRanges = ws1.NamedRanges.InvalidNamedRanges(); + var globalValidRanges = wb.DefinedNames.ValidNamedRanges(); + var globalInvalidRanges = wb.DefinedNames.InvalidNamedRanges(); + var localValidRanges = ws1.DefinedNames.ValidNamedRanges(); + var localInvalidRanges = ws1.DefinedNames.InvalidNamedRanges(); Assert.AreEqual(1, globalValidRanges.Count()); Assert.AreEqual("Named range 2", globalValidRanges.First().Name); @@ -100,17 +110,17 @@ public void CanRenameNamedRange() using (var wb = new XLWorkbook()) { var ws1 = wb.AddWorksheet("Sheet1"); - var nr1 = wb.NamedRanges.Add("TEST", "=0.1"); + var dn1 = wb.DefinedNames.Add("TEST", "=0.1"); - Assert.IsTrue(wb.NamedRanges.TryGetValue("TEST", out IXLNamedRange _)); - Assert.IsFalse(wb.NamedRanges.TryGetValue("TEST1", out IXLNamedRange _)); + Assert.IsTrue(wb.DefinedNames.TryGetValue("TEST", out _)); + Assert.IsFalse(wb.DefinedNames.TryGetValue("TEST1", out _)); - nr1.Name = "TEST1"; + dn1.Name = "TEST1"; - Assert.IsFalse(wb.NamedRanges.TryGetValue("TEST", out IXLNamedRange _)); - Assert.IsTrue(wb.NamedRanges.TryGetValue("TEST1", out IXLNamedRange _)); + Assert.IsFalse(wb.DefinedNames.TryGetValue("TEST", out _)); + Assert.IsTrue(wb.DefinedNames.TryGetValue("TEST1", out _)); - var nr2 = wb.NamedRanges.Add("TEST2", "=TEST1*2"); + var dn2 = wb.DefinedNames.Add("TEST2", "=TEST1*2"); ws1.Cell(1, 1).FormulaA1 = "TEST1"; ws1.Cell(2, 1).FormulaA1 = "TEST1*10"; @@ -125,7 +135,7 @@ public void CanRenameNamedRange() } [Test] - public void CanSaveAndLoadNamedRanges() + public void Can_save_and_load_defined_names() { using (var ms = new MemoryStream()) { @@ -134,11 +144,11 @@ public void CanSaveAndLoadNamedRanges() var sheet1 = wb.Worksheets.Add("Sheet1"); var sheet2 = wb.Worksheets.Add("Sheet2"); - wb.NamedRanges.Add("wbNamedRange", + wb.DefinedNames.Add("wbNamedRange", "Sheet1!$B$2,Sheet1!$B$3:$C$3,Sheet2!$D$3:$D$4,Sheet1!$6:$7,Sheet1!$F:$G"); - sheet1.NamedRanges.Add("sheet1NamedRange", + sheet1.DefinedNames.Add("sheet1NamedRange", "Sheet1!$B$2,Sheet1!$B$3:$C$3,Sheet2!$D$3:$D$4,Sheet1!$6:$7,Sheet1!$F:$G"); - sheet2.NamedRanges.Add("sheet2NamedRange", "Sheet1!A1,Sheet2!A1"); + sheet2.DefinedNames.Add("sheet2NamedRange", "Sheet1!A1,Sheet2!A1"); wb.SaveAs(ms); } @@ -148,20 +158,20 @@ public void CanSaveAndLoadNamedRanges() var sheet1 = wb.Worksheet("Sheet1"); var sheet2 = wb.Worksheet("Sheet2"); - Assert.AreEqual(1, wb.NamedRanges.Count()); - Assert.AreEqual("wbNamedRange", wb.NamedRanges.Single().Name); - Assert.AreEqual("Sheet1!$B$2,Sheet1!$B$3:$C$3,Sheet2!$D$3:$D$4,Sheet1!$6:$7,Sheet1!$F:$G", wb.NamedRanges.Single().RefersTo); - Assert.AreEqual(5, wb.NamedRanges.Single().Ranges.Count); + Assert.AreEqual(1, wb.DefinedNames.Count()); + Assert.AreEqual("wbNamedRange", wb.DefinedNames.Single().Name); + Assert.AreEqual("Sheet1!$B$2,Sheet1!$B$3:$C$3,Sheet2!$D$3:$D$4,Sheet1!$6:$7,Sheet1!$F:$G", wb.DefinedNames.Single().RefersTo); + Assert.AreEqual(5, wb.DefinedNames.Single().Ranges.Count); - Assert.AreEqual(1, sheet1.NamedRanges.Count()); - Assert.AreEqual("sheet1NamedRange", sheet1.NamedRanges.Single().Name); - Assert.AreEqual("Sheet1!$B$2,Sheet1!$B$3:$C$3,Sheet2!$D$3:$D$4,Sheet1!$6:$7,Sheet1!$F:$G", sheet1.NamedRanges.Single().RefersTo); - Assert.AreEqual(5, sheet1.NamedRanges.Single().Ranges.Count); + Assert.AreEqual(1, sheet1.DefinedNames.Count()); + Assert.AreEqual("sheet1NamedRange", sheet1.DefinedNames.Single().Name); + Assert.AreEqual("Sheet1!$B$2,Sheet1!$B$3:$C$3,Sheet2!$D$3:$D$4,Sheet1!$6:$7,Sheet1!$F:$G", sheet1.DefinedNames.Single().RefersTo); + Assert.AreEqual(5, sheet1.DefinedNames.Single().Ranges.Count); - Assert.AreEqual(1, sheet2.NamedRanges.Count()); - Assert.AreEqual("sheet2NamedRange", sheet2.NamedRanges.Single().Name); - Assert.AreEqual("Sheet1!A1,Sheet2!A1", sheet2.NamedRanges.Single().RefersTo); - Assert.AreEqual(2, sheet2.NamedRanges.Single().Ranges.Count); + Assert.AreEqual(1, sheet2.DefinedNames.Count()); + Assert.AreEqual("sheet2NamedRange", sheet2.DefinedNames.Single().Name); + Assert.AreEqual("Sheet1!A1,Sheet2!A1", sheet2.DefinedNames.Single().RefersTo); + Assert.AreEqual(2, sheet2.DefinedNames.Single().Ranges.Count); } } } @@ -175,12 +185,12 @@ public void CopyNamedRangeDifferentWorksheets() var ranges = new XLRanges(); ranges.Add(ws1.Range("B2:E6")); ranges.Add(ws2.Range("D1:E2")); - var original = ws1.NamedRanges.Add("Named range", ranges); + var original = ws1.DefinedNames.Add("Named range", ranges); var copy = original.CopyTo(ws2); - Assert.AreEqual(1, ws1.NamedRanges.Count()); - Assert.AreEqual(1, ws2.NamedRanges.Count()); + Assert.AreEqual(1, ws1.DefinedNames.Count()); + Assert.AreEqual(1, ws2.DefinedNames.Count()); Assert.AreEqual(2, original.Ranges.Count); Assert.AreEqual(2, copy.Ranges.Count); Assert.AreEqual(original.Name, copy.Name); @@ -192,14 +202,48 @@ public void CopyNamedRangeDifferentWorksheets() } [Test] - public void CopyNamedRangeSameWorksheet() + public void Copy_table_references_to_different_worksheet() + { + // When sheet-scoped name references a table and there is a table with same area in the + // copied sheet, the copied defined name changes table reference to a new table. If + // range differs, table reference is not modified. + using var wb = new XLWorkbook(); + var orgSheet = wb.AddWorksheet(); + orgSheet.Cell("A1").InsertTable(new[] { "Data", "A", "B" }, "OrgTable", true); + orgSheet.Cell("C1").InsertTable(new[] { "Data", "A", "B" }, "MiscTable", true); + var originalName = orgSheet.DefinedNames.Add("TableName", "SUM(OrgTable[Data], MiscTable[Data])"); + + var copySheet = wb.AddWorksheet(); + copySheet.Cell("A1").InsertTable(new[] { "Data", "A", "B" }, "CopyTable", true); + + originalName.CopyTo(copySheet); + + var copyName = copySheet.DefinedNames.Single(); + Assert.AreEqual("TableName", copyName.Name); + Assert.AreEqual("SUM(CopyTable[Data], MiscTable[Data])", copyName.RefersTo); + } + + [Test] + public void Copy_workbook_scoped_defined() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); + var name = wb.DefinedNames.Add("Name", "Sheet!$A$1"); + + var copySheet = wb.AddWorksheet(); + var ex = Assert.Throws(() => name.CopyTo(copySheet))!; + Assert.AreEqual("Cannot copy workbook scoped defined name.", ex.Message); + } + + [Test] + public void Copy_defined_name_to_same_sheet() { var wb = new XLWorkbook(); var ws1 = wb.Worksheets.Add("Sheet1"); ws1.Range("B2:E6").AddToNamed("Named range", XLScope.Worksheet); - var nr = ws1.NamedRange("Named range"); + var dn = ws1.DefinedName("Named range"); - TestDelegate action = () => nr.CopyTo(ws1); + TestDelegate action = () => dn.CopyTo(ws1); Assert.Throws(typeof(InvalidOperationException), action); } @@ -213,16 +257,33 @@ public void DeleteColumnUsedInNamedRange() ws.FirstCell().SetValue("Column1"); ws.FirstCell().CellRight().SetValue("Column2").Style.Font.SetBold(); ws.FirstCell().CellRight(2).SetValue("Column3"); - ws.NamedRanges.Add("MyRange", "A1:C1"); + ws.DefinedNames.Add("MyRange", "A1:C1"); ws.Column(1).Delete(); Assert.IsTrue(ws.Cell("A1").Style.Font.Bold); - Assert.AreEqual("Column3", ws.Cell("B1").GetValue()); - Assert.IsEmpty(ws.Cell("C1").GetValue()); + Assert.AreEqual("Column3", ws.Cell("B1").Value); + Assert.AreEqual(Blank.Value, ws.Cell("C1").Value); } } + [Test] + public void Formula_is_updated_on_sheet_rename() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Old name"); + var bookScopedName = wb.DefinedNames.Add("TEST", "ABS('Old name'!$B$5)"); + var sheetScopedName = ws.DefinedNames.Add("TEST1", "'Old name'!$D$7:$F$14"); + + ws.Name = "Renamed"; + + Assert.AreEqual("ABS(Renamed!$B$5)", bookScopedName.RefersTo); + Assert.AreEqual("Renamed!$B$5:$B$5", bookScopedName.Ranges.ToString()); + + Assert.AreEqual("Renamed!$D$7:$F$14", sheetScopedName.RefersTo); + Assert.AreEqual("Renamed!$D$7:$F$14", sheetScopedName.Ranges.ToString()); + } + [Test] public void MovingRanges() { @@ -231,11 +292,11 @@ public void MovingRanges() IXLWorksheet sheet1 = wb.Worksheets.Add("Sheet1"); IXLWorksheet sheet2 = wb.Worksheets.Add("Sheet2"); - wb.NamedRanges.Add("wbNamedRange", + wb.DefinedNames.Add("wbNamedRange", "Sheet1!$B$2,Sheet1!$B$3:$C$3,Sheet2!$D$3:$D$4,Sheet1!$6:$7,Sheet1!$F:$G"); - sheet1.NamedRanges.Add("sheet1NamedRange", + sheet1.DefinedNames.Add("sheet1NamedRange", "Sheet1!$B$2,Sheet1!$B$3:$C$3,Sheet2!$D$3:$D$4,Sheet1!$6:$7,Sheet1!$F:$G"); - sheet2.NamedRanges.Add("sheet2NamedRange", "Sheet1!A1,Sheet2!A1"); + sheet2.DefinedNames.Add("sheet2NamedRange", "Sheet1!A1,Sheet2!A1"); sheet1.Row(1).InsertRowsAbove(2); sheet1.Row(1).Delete(); @@ -243,14 +304,14 @@ public void MovingRanges() sheet1.Column(1).Delete(); Assert.AreEqual("Sheet1!$C$3,Sheet1!$C$4:$D$4,Sheet2!$D$3:$D$4,Sheet1!$7:$8,Sheet1!$G:$H", - wb.NamedRanges.First().RefersTo); + wb.DefinedNames.First().RefersTo); Assert.AreEqual("Sheet1!$C$3,Sheet1!$C$4:$D$4,Sheet2!$D$3:$D$4,Sheet1!$7:$8,Sheet1!$G:$H", - sheet1.NamedRanges.First().RefersTo); - Assert.AreEqual("Sheet1!B2,Sheet2!A1", sheet2.NamedRanges.First().RefersTo); + sheet1.DefinedNames.First().RefersTo); + Assert.AreEqual("Sheet1!B2,Sheet2!A1", sheet2.DefinedNames.First().RefersTo); - wb.NamedRanges.ForEach(nr => Assert.AreEqual(XLNamedRangeScope.Workbook, nr.Scope)); - sheet1.NamedRanges.ForEach(nr => Assert.AreEqual(XLNamedRangeScope.Worksheet, nr.Scope)); - sheet2.NamedRanges.ForEach(nr => Assert.AreEqual(XLNamedRangeScope.Worksheet, nr.Scope)); + wb.DefinedNames.ForEach(dn => Assert.AreEqual(XLNamedRangeScope.Workbook, dn.Scope)); + sheet1.DefinedNames.ForEach(dn => Assert.AreEqual(XLNamedRangeScope.Worksheet, dn.Scope)); + sheet2.DefinedNames.ForEach(dn => Assert.AreEqual(XLNamedRangeScope.Worksheet, dn.Scope)); } [Test, Ignore("Muted until shifting is fixed (see #880)")] @@ -261,7 +322,7 @@ public void NamedRangeBecomesInvalidOnRangeAndWorksheetDeleting() var ws1 = wb.Worksheets.Add("Sheet 1"); var ws2 = wb.Worksheets.Add("Sheet 2"); ws1.Range("A1:B2").AddToNamed("Simple", XLScope.Workbook); - wb.NamedRanges.Add("Compound", new XLRanges + wb.DefinedNames.Add("Compound", new XLRanges { ws1.Range("C1:D2"), ws2.Range("A10:D15") @@ -270,10 +331,10 @@ public void NamedRangeBecomesInvalidOnRangeAndWorksheetDeleting() ws1.Rows(1, 5).Delete(); ws1.Delete(); - Assert.AreEqual(2, wb.NamedRanges.Count()); - Assert.AreEqual(0, wb.NamedRanges.ValidNamedRanges().Count()); - Assert.AreEqual("#REF!#REF!", wb.NamedRanges.ElementAt(0).RefersTo); - Assert.AreEqual("#REF!#REF!,'Sheet 2'!A10:D15", wb.NamedRanges.ElementAt(0).RefersTo); + Assert.AreEqual(2, wb.DefinedNames.Count()); + Assert.AreEqual(0, wb.DefinedNames.ValidNamedRanges().Count()); + Assert.AreEqual("#REF!#REF!", wb.DefinedNames.ElementAt(0).RefersTo); + Assert.AreEqual("#REF!#REF!,'Sheet 2'!A10:D15", wb.DefinedNames.ElementAt(0).RefersTo); } } @@ -284,7 +345,7 @@ public void NamedRangeBecomesInvalidOnRangeDeleting() { var ws = wb.Worksheets.Add("Sheet 1"); ws.Range("A1:B2").AddToNamed("Simple", XLScope.Workbook); - wb.NamedRanges.Add("Compound", new XLRanges + wb.DefinedNames.Add("Compound", new XLRanges { ws.Range("C1:D2"), ws.Range("A10:D15") @@ -292,10 +353,10 @@ public void NamedRangeBecomesInvalidOnRangeDeleting() ws.Rows(1, 5).Delete(); - Assert.AreEqual(2, wb.NamedRanges.Count()); - Assert.AreEqual(0, wb.NamedRanges.ValidNamedRanges().Count()); - Assert.AreEqual("'Sheet 1'!#REF!", wb.NamedRanges.ElementAt(0).RefersTo); - Assert.AreEqual("'Sheet 1'!#REF!,'Sheet 1'!A5:D10", wb.NamedRanges.ElementAt(0).RefersTo); + Assert.AreEqual(2, wb.DefinedNames.Count()); + Assert.AreEqual(0, wb.DefinedNames.ValidNamedRanges().Count()); + Assert.AreEqual("'Sheet 1'!#REF!", wb.DefinedNames.ElementAt(0).RefersTo); + Assert.AreEqual("'Sheet 1'!#REF!,'Sheet 1'!A5:D10", wb.DefinedNames.ElementAt(0).RefersTo); } } @@ -307,8 +368,8 @@ public void NamedRangeMayReferToExpression() using (var wb = new XLWorkbook()) { var ws1 = wb.AddWorksheet("Sheet1"); - wb.NamedRanges.Add("TEST", "=0.1"); - wb.NamedRanges.Add("TEST2", "=TEST*2"); + wb.DefinedNames.Add("TEST", "=0.1"); + wb.DefinedNames.Add("TEST2", "=TEST*2"); ws1.Cell(1, 1).FormulaA1 = "TEST"; ws1.Cell(2, 1).FormulaA1 = "TEST*10"; @@ -344,7 +405,7 @@ public void NamedRangeReferringToMultipleRangesCanBeSavedAndLoaded() { var ws = wb.Worksheets.Add("Sheet 1"); - wb.NamedRanges.Add("Multirange named range", new XLRanges + wb.DefinedNames.Add("Multirange named range", new XLRanges { ws.Range("A5:D5"), ws.Range("A15:D15") @@ -355,21 +416,21 @@ public void NamedRangeReferringToMultipleRangesCanBeSavedAndLoaded() using (var wb = new XLWorkbook(ms)) { - Assert.AreEqual(1, wb.NamedRanges.Count()); - var nr = wb.NamedRanges.Single() as XLNamedRange; + Assert.AreEqual(1, wb.DefinedNames.Count()); + var nr = (XLDefinedName)wb.DefinedNames.Single(); Assert.AreEqual("'Sheet 1'!$A$5:$D$5,'Sheet 1'!$A$15:$D$15", nr.RefersTo); Assert.AreEqual(2, nr.Ranges.Count); Assert.AreEqual("'Sheet 1'!A5:D5", nr.Ranges.First().RangeAddress.ToString(XLReferenceStyle.A1, true)); Assert.AreEqual("'Sheet 1'!A15:D15", nr.Ranges.Last().RangeAddress.ToString(XLReferenceStyle.A1, true)); - Assert.AreEqual(2, nr.RangeList.Count); - Assert.AreEqual("'Sheet 1'!$A$5:$D$5", nr.RangeList.First()); - Assert.AreEqual("'Sheet 1'!$A$15:$D$15", nr.RangeList.Last()); + Assert.AreEqual(2, nr.SheetReferencesList.Count); + Assert.AreEqual("'Sheet 1'!$A$5:$D$5", nr.SheetReferencesList.First()); + Assert.AreEqual("'Sheet 1'!$A$15:$D$15", nr.SheetReferencesList.Last()); } } } [Test] - public void NamedRangesBecomeInvalidOnWorksheetDeleting() + public void Defined_names_referencing_sheet_range_become_invalid_when_sheet_is_deleted() { using (var wb = new XLWorkbook()) { @@ -381,7 +442,7 @@ public void NamedRangesBecomeInvalidOnWorksheetDeleting() ws1.Range("A2:D2").AddToNamed("Named range 2", XLScope.Workbook); ws2.Range("A3:D3").AddToNamed("Named range 3", XLScope.Worksheet); ws2.Range("A4:D4").AddToNamed("Named range 4", XLScope.Workbook); - wb.NamedRanges.Add("Named range 5", new XLRanges + wb.DefinedNames.Add("Named range 5", new XLRanges { ws1.Range("A5:D5"), ws3.Range("A5:D5") @@ -390,29 +451,29 @@ public void NamedRangesBecomeInvalidOnWorksheetDeleting() ws2.Delete(); ws3.Delete(); - Assert.AreEqual(1, ws1.NamedRanges.Count()); - Assert.AreEqual("Named range 1", ws1.NamedRanges.First().Name); - Assert.AreEqual(XLNamedRangeScope.Worksheet, ws1.NamedRanges.First().Scope); - Assert.AreEqual("'Sheet 1'!$A$1:$D$1", ws1.NamedRanges.First().RefersTo); - Assert.AreEqual("'Sheet 1'!A1:D1", ws1.NamedRanges.First().Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); - - Assert.AreEqual(3, wb.NamedRanges.Count()); - - Assert.AreEqual("Named range 2", wb.NamedRanges.ElementAt(0).Name); - Assert.AreEqual(XLNamedRangeScope.Workbook, wb.NamedRanges.ElementAt(0).Scope); - Assert.AreEqual("'Sheet 1'!$A$2:$D$2", wb.NamedRanges.ElementAt(0).RefersTo); - Assert.AreEqual("'Sheet 1'!A2:D2", wb.NamedRanges.ElementAt(0).Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); - - Assert.AreEqual("Named range 4", wb.NamedRanges.ElementAt(1).Name); - Assert.AreEqual(XLNamedRangeScope.Workbook, wb.NamedRanges.ElementAt(1).Scope); - Assert.AreEqual("#REF!$A$4:$D$4", wb.NamedRanges.ElementAt(1).RefersTo); - Assert.IsFalse(wb.NamedRanges.ElementAt(1).Ranges.Any()); - - Assert.AreEqual("Named range 5", wb.NamedRanges.ElementAt(2).Name); - Assert.AreEqual(XLNamedRangeScope.Workbook, wb.NamedRanges.ElementAt(2).Scope); - Assert.AreEqual("'Sheet 1'!$A$5:$D$5,#REF!$A$5:$D$5", wb.NamedRanges.ElementAt(2).RefersTo); - Assert.AreEqual(1, wb.NamedRanges.ElementAt(2).Ranges.Count); - Assert.AreEqual("'Sheet 1'!A5:D5", wb.NamedRanges.ElementAt(2).Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); + Assert.AreEqual(1, ws1.DefinedNames.Count()); + Assert.AreEqual("Named range 1", ws1.DefinedNames.First().Name); + Assert.AreEqual(XLNamedRangeScope.Worksheet, ws1.DefinedNames.First().Scope); + Assert.AreEqual("'Sheet 1'!$A$1:$D$1", ws1.DefinedNames.First().RefersTo); + Assert.AreEqual("'Sheet 1'!A1:D1", ws1.DefinedNames.First().Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); + + Assert.AreEqual(3, wb.DefinedNames.Count()); + + Assert.AreEqual("Named range 2", wb.DefinedNames.ElementAt(0).Name); + Assert.AreEqual(XLNamedRangeScope.Workbook, wb.DefinedNames.ElementAt(0).Scope); + Assert.AreEqual("'Sheet 1'!$A$2:$D$2", wb.DefinedNames.ElementAt(0).RefersTo); + Assert.AreEqual("'Sheet 1'!A2:D2", wb.DefinedNames.ElementAt(0).Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); + + Assert.AreEqual("Named range 4", wb.DefinedNames.ElementAt(1).Name); + Assert.AreEqual(XLNamedRangeScope.Workbook, wb.DefinedNames.ElementAt(1).Scope); + Assert.AreEqual("#REF!", wb.DefinedNames.ElementAt(1).RefersTo); + Assert.IsFalse(wb.DefinedNames.ElementAt(1).Ranges.Any()); + + Assert.AreEqual("Named range 5", wb.DefinedNames.ElementAt(2).Name); + Assert.AreEqual(XLNamedRangeScope.Workbook, wb.DefinedNames.ElementAt(2).Scope); + Assert.AreEqual("'Sheet 1'!$A$5:$D$5,#REF!", wb.DefinedNames.ElementAt(2).RefersTo); + Assert.AreEqual(1, wb.DefinedNames.ElementAt(2).Ranges.Count); + Assert.AreEqual("'Sheet 1'!A5:D5", wb.DefinedNames.ElementAt(2).Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); } } @@ -436,24 +497,24 @@ public void NamedRangesFromDeletedSheetAreSavedWithoutAddress() using (var wb = new XLWorkbook(ms)) { - Assert.AreEqual("#REF!", wb.NamedRanges.Single().RefersTo); + Assert.AreEqual("#REF!", wb.DefinedNames.Single().RefersTo); } } } [Test] - public void NamedRangesWhenCopyingWorksheets() + public void Only_worksheet_scoped_defined_names_are_copied_when_sheet_is_copied() { using (var wb = new XLWorkbook()) { var ws1 = wb.AddWorksheet("Sheet1"); - ws1.FirstCell().Value = Enumerable.Range(1, 10); - wb.NamedRanges.Add("wbNamedRange", ws1.Range("A1:A10")); - ws1.NamedRanges.Add("wsNamedRange", ws1.Range("A3")); + ws1.FirstCell().InsertData(Enumerable.Range(1, 10)); + wb.DefinedNames.Add("wbNamedRange", ws1.Range("A1:A10")); + ws1.DefinedNames.Add("wsNamedRange", ws1.Range("A3")); var ws2 = wb.AddWorksheet("Sheet2"); - ws2.FirstCell().Value = Enumerable.Range(101, 10); - ws1.NamedRanges.Add("wsNamedRangeAcrossSheets", ws2.Range("A4")); + ws2.FirstCell().InsertData(Enumerable.Range(101, 10)); + ws1.DefinedNames.Add("wsNamedRangeAcrossSheets", ws2.Range("A4")); ws1.Cell("C1").FormulaA1 = "=wbNamedRange"; ws1.Cell("C2").FormulaA1 = "=wsNamedRange"; @@ -469,16 +530,16 @@ public void NamedRangesWhenCopyingWorksheets() Assert.AreEqual(104, wsCopy.Cell("C3").Value); Assert.AreEqual("Sheet1!A1:A10", - wb.NamedRange("wbNamedRange").Ranges.First().RangeAddress.ToStringRelative(true)); + wb.DefinedName("wbNamedRange").Ranges.First().RangeAddress.ToStringRelative(true)); Assert.AreEqual("Copy!A3:A3", - wsCopy.NamedRange("wsNamedRange").Ranges.First().RangeAddress.ToStringRelative(true)); + wsCopy.DefinedName("wsNamedRange").Ranges.First().RangeAddress.ToStringRelative(true)); Assert.AreEqual("Sheet2!A4:A4", - wsCopy.NamedRange("wsNamedRangeAcrossSheets").Ranges.First().RangeAddress.ToStringRelative(true)); + wsCopy.DefinedName("wsNamedRangeAcrossSheets").Ranges.First().RangeAddress.ToStringRelative(true)); } } [Test] - public void SavedNamedRangesBecomeInvalidOnWorksheetDeleting() + public void Saved_defined_names_become_invalid_on_sheet_deleting() { using (var ms = new MemoryStream()) { @@ -492,7 +553,7 @@ public void SavedNamedRangesBecomeInvalidOnWorksheetDeleting() ws1.Range("A2:D2").AddToNamed("Named range 2", XLScope.Workbook); ws2.Range("A3:D3").AddToNamed("Named range 3", XLScope.Worksheet); ws2.Range("A4:D4").AddToNamed("Named range 4", XLScope.Workbook); - wb.NamedRanges.Add("Named range 5", new XLRanges + wb.DefinedNames.Add("Named range 5", new XLRanges { ws1.Range("A5:D5"), ws3.Range("A5:D5") @@ -511,32 +572,32 @@ public void SavedNamedRangesBecomeInvalidOnWorksheetDeleting() using (var wb = new XLWorkbook(ms)) { var ws1 = wb.Worksheet("Sheet 1"); - Assert.AreEqual(1, ws1.NamedRanges.Count()); - Assert.AreEqual("Named range 1", ws1.NamedRanges.First().Name); - Assert.AreEqual(XLNamedRangeScope.Worksheet, ws1.NamedRanges.First().Scope); - Assert.AreEqual("'Sheet 1'!$A$1:$D$1", ws1.NamedRanges.First().RefersTo); + Assert.AreEqual(1, ws1.DefinedNames.Count()); + Assert.AreEqual("Named range 1", ws1.DefinedNames.First().Name); + Assert.AreEqual(XLNamedRangeScope.Worksheet, ws1.DefinedNames.First().Scope); + Assert.AreEqual("'Sheet 1'!$A$1:$D$1", ws1.DefinedNames.First().RefersTo); Assert.AreEqual("'Sheet 1'!A1:D1", - ws1.NamedRanges.First().Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); + ws1.DefinedNames.First().Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); - Assert.AreEqual(3, wb.NamedRanges.Count()); + Assert.AreEqual(3, wb.DefinedNames.Count()); - Assert.AreEqual("Named range 2", wb.NamedRanges.ElementAt(0).Name); - Assert.AreEqual(XLNamedRangeScope.Workbook, wb.NamedRanges.ElementAt(0).Scope); - Assert.AreEqual("'Sheet 1'!$A$2:$D$2", wb.NamedRanges.ElementAt(0).RefersTo); + Assert.AreEqual("Named range 2", wb.DefinedNames.ElementAt(0).Name); + Assert.AreEqual(XLNamedRangeScope.Workbook, wb.DefinedNames.ElementAt(0).Scope); + Assert.AreEqual("'Sheet 1'!$A$2:$D$2", wb.DefinedNames.ElementAt(0).RefersTo); Assert.AreEqual("'Sheet 1'!A2:D2", - wb.NamedRanges.ElementAt(0).Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); + wb.DefinedNames.ElementAt(0).Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); - Assert.AreEqual("Named range 4", wb.NamedRanges.ElementAt(1).Name); - Assert.AreEqual(XLNamedRangeScope.Workbook, wb.NamedRanges.ElementAt(1).Scope); - Assert.AreEqual("#REF!", wb.NamedRanges.ElementAt(1).RefersTo); - Assert.IsFalse(wb.NamedRanges.ElementAt(1).Ranges.Any()); + Assert.AreEqual("Named range 4", wb.DefinedNames.ElementAt(1).Name); + Assert.AreEqual(XLNamedRangeScope.Workbook, wb.DefinedNames.ElementAt(1).Scope); + Assert.AreEqual("#REF!", wb.DefinedNames.ElementAt(1).RefersTo); + Assert.IsFalse(wb.DefinedNames.ElementAt(1).Ranges.Any()); - Assert.AreEqual("Named range 5", wb.NamedRanges.ElementAt(2).Name); - Assert.AreEqual(XLNamedRangeScope.Workbook, wb.NamedRanges.ElementAt(2).Scope); - Assert.AreEqual("'Sheet 1'!$A$5:$D$5,#REF!", wb.NamedRanges.ElementAt(2).RefersTo); - Assert.AreEqual(1, wb.NamedRanges.ElementAt(2).Ranges.Count); + Assert.AreEqual("Named range 5", wb.DefinedNames.ElementAt(2).Name); + Assert.AreEqual(XLNamedRangeScope.Workbook, wb.DefinedNames.ElementAt(2).Scope); + Assert.AreEqual("'Sheet 1'!$A$5:$D$5,#REF!", wb.DefinedNames.ElementAt(2).RefersTo); + Assert.AreEqual(1, wb.DefinedNames.ElementAt(2).Ranges.Count); Assert.AreEqual("'Sheet 1'!A5:D5", - wb.NamedRanges.ElementAt(2).Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); + wb.DefinedNames.ElementAt(2).Ranges.Single().RangeAddress.ToString(XLReferenceStyle.A1, true)); } } } @@ -551,7 +612,7 @@ public void TestInvalidNamedRangeOnWorkbookScope() ws.FirstCell().CellRight().SetValue("Column2").Style.Font.SetBold(); ws.FirstCell().CellRight(2).SetValue("Column3"); - Assert.Throws(() => wb.NamedRanges.Add("MyRange", "A1:C1")); + Assert.Throws(() => wb.DefinedNames.Add("MyRange", "A1:C1")); } } @@ -562,20 +623,20 @@ public void WbContainsWsNamedRange() var ws = wb.AddWorksheet("Sheet1"); ws.FirstCell().AddToNamed("Name", XLScope.Worksheet); - Assert.IsTrue(wb.NamedRanges.Contains("Sheet1!Name")); - Assert.IsFalse(wb.NamedRanges.Contains("Sheet1!NameX")); + Assert.IsTrue(wb.DefinedNames.Contains("Sheet1!Name")); + Assert.IsFalse(wb.DefinedNames.Contains("Sheet1!NameX")); - Assert.IsNotNull(wb.NamedRange("Sheet1!Name")); - Assert.IsNull(wb.NamedRange("Sheet1!NameX")); + Assert.IsNotNull(wb.DefinedName("Sheet1!Name")); + Assert.IsNull(wb.DefinedName("Sheet1!NameX")); - Boolean result1 = wb.NamedRanges.TryGetValue("Sheet1!Name", out IXLNamedRange range1); - Assert.IsTrue(result1); - Assert.IsNotNull(range1); - Assert.AreEqual(XLNamedRangeScope.Worksheet, range1.Scope); + Boolean found1 = wb.DefinedNames.TryGetValue("Sheet1!Name", out var definedName1); + Assert.IsTrue(found1); + Assert.IsNotNull(definedName1); + Assert.AreEqual(XLNamedRangeScope.Worksheet, definedName1.Scope); - Boolean result2 = wb.NamedRanges.TryGetValue("Sheet1!NameX", out IXLNamedRange range2); - Assert.IsFalse(result2); - Assert.IsNull(range2); + Boolean found2 = wb.DefinedNames.TryGetValue("Sheet1!NameX", out var definedName2); + Assert.IsFalse(found2); + Assert.IsNull(definedName2); } [Test] @@ -585,40 +646,41 @@ public void WorkbookContainsNamedRange() var ws = wb.AddWorksheet("Sheet1"); ws.FirstCell().AddToNamed("Name"); - Assert.IsTrue(wb.NamedRanges.Contains("Name")); - Assert.IsFalse(wb.NamedRanges.Contains("NameX")); + Assert.IsTrue(wb.DefinedNames.Contains("Name")); + Assert.IsFalse(wb.DefinedNames.Contains("NameX")); - Assert.IsNotNull(wb.NamedRange("Name")); - Assert.IsNull(wb.NamedRange("NameX")); + Assert.IsNotNull(wb.DefinedName("Name")); + Assert.IsNull(wb.DefinedName("NameX")); - Boolean result1 = wb.NamedRanges.TryGetValue("Name", out IXLNamedRange range1); - Assert.IsTrue(result1); - Assert.IsNotNull(range1); + Boolean found1 = wb.DefinedNames.TryGetValue("Name", out var definedName1); + Assert.IsTrue(found1); + Assert.IsNotNull(definedName1); - Boolean result2 = wb.NamedRanges.TryGetValue("NameX", out IXLNamedRange range2); - Assert.IsFalse(result2); - Assert.IsNull(range2); + Boolean found2 = wb.DefinedNames.TryGetValue("NameX", out var definedName2); + Assert.IsFalse(found2); + Assert.IsNull(definedName2); } [Test] public void WorksheetContainsNamedRange() { - IXLWorksheet ws = new XLWorkbook().AddWorksheet("Sheet1"); + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet1"); ws.FirstCell().AddToNamed("Name", XLScope.Worksheet); - Assert.IsTrue(ws.NamedRanges.Contains("Name")); - Assert.IsFalse(ws.NamedRanges.Contains("NameX")); + Assert.IsTrue(ws.DefinedNames.Contains("Name")); + Assert.IsFalse(ws.DefinedNames.Contains("NameX")); - Assert.IsNotNull(ws.NamedRange("Name")); - Assert.IsNull(ws.NamedRange("NameX")); + Assert.IsNotNull(ws.DefinedName("Name")); + Assert.Throws(() => ws.DefinedName("NameX")); - Boolean result1 = ws.NamedRanges.TryGetValue("Name", out IXLNamedRange range1); - Assert.IsTrue(result1); - Assert.IsNotNull(range1); + Boolean found1 = ws.DefinedNames.TryGetValue("Name", out var definedName1); + Assert.IsTrue(found1); + Assert.IsNotNull(definedName1); - Boolean result2 = ws.NamedRanges.TryGetValue("NameX", out IXLNamedRange range2); - Assert.IsFalse(result2); - Assert.IsNull(range2); + Boolean found2 = ws.DefinedNames.TryGetValue("NameX", out var definedName2); + Assert.IsFalse(found2); + Assert.IsNull(definedName2); } [Test] diff --git a/ClosedXML.Tests/Excel/PageSetup/PageLayoutTests.cs b/ClosedXML.Tests/Excel/PageSetup/PageLayoutTests.cs new file mode 100644 index 000000000..fb4c4c240 --- /dev/null +++ b/ClosedXML.Tests/Excel/PageSetup/PageLayoutTests.cs @@ -0,0 +1,17 @@ +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PageSetup +{ + [TestFixture] + public class PageLayoutTests + { + [Test] + public void FirstPageNumber_can_be_negative() + { + TestHelper.CreateSaveLoadAssert( + (_, ws) => ws.PageSetup.FirstPageNumber = -3, + (_, ws) => Assert.AreEqual(-3, ws.PageSetup.FirstPageNumber), + @"Other\PageSetup\Negative_first_page_number.xlsx"); + } + } +} diff --git a/ClosedXML.Tests/Excel/PageSetup/PrintAreaTests.cs b/ClosedXML.Tests/Excel/PageSetup/PrintAreaTests.cs new file mode 100644 index 000000000..cd491ecff --- /dev/null +++ b/ClosedXML.Tests/Excel/PageSetup/PrintAreaTests.cs @@ -0,0 +1,27 @@ +using NUnit.Framework; +using System.Linq; + +namespace ClosedXML.Tests.Excel +{ + [TestFixture] + public class PrintAreaTests + { + [Test] + [TestCase("A1:B2")] + [TestCase("A1:B2", "D3:D5")] + public void CanLoadWorksheetWithMultiplePrintAreas(params string[] printAreaRangeAddresses) + { + TestHelper.CreateSaveLoadAssert( + (_, ws) => + { + foreach (var printAreaRangeAddress in printAreaRangeAddresses) + ws.PageSetup.PrintAreas.Add(printAreaRangeAddress); + }, + (_, ws) => + { + var actualPrintAddresses = ws.PageSetup.PrintAreas.Select(pa => pa.RangeAddress.ToStringRelative()); + CollectionAssert.AreEqual(printAreaRangeAddresses, actualPrintAddresses); + }); + } + } +} diff --git a/ClosedXML.Tests/Excel/PivotTables/Create/XLPivotTableAddFieldsTests.cs b/ClosedXML.Tests/Excel/PivotTables/Create/XLPivotTableAddFieldsTests.cs new file mode 100644 index 000000000..2d950f800 --- /dev/null +++ b/ClosedXML.Tests/Excel/PivotTables/Create/XLPivotTableAddFieldsTests.cs @@ -0,0 +1,83 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PivotTables.Create +{ + /// + /// Tests that add fields to a new empty table. Doesn't test data. + /// Expected: Make sure Excel can read the stuff we wrote. + /// + [TestFixture] + internal class XLPivotTableAddFieldsTests + { + [Test] + public void Add_empty_table() + { + TestHelper.CreateAndCompare(wb => + { + CreatePivotTableFor2X2(wb); + }, @"Other\PivotTable\Create\Add_empty_table.xlsx"); + } + + [Test] + public void Add_one_column_without_value() + { + TestHelper.CreateAndCompare(wb => + { + var pt = CreatePivotTableFor2X2(wb); + + pt.ColumnLabels.Add("A"); + }, @"Other\PivotTable\Create\Add_one_column_without_value.xlsx"); + } + + [Test] + public void Add_one_row_without_value() + { + TestHelper.CreateAndCompare(wb => + { + var pt = CreatePivotTableFor2X2(wb); + + pt.RowLabels.Add("A"); + }, @"Other\PivotTable\Create\Add_one_row_without_value.xlsx"); + } + + [Test] + public void Add_one_column_and_one_value() + { + TestHelper.CreateAndCompare(wb => + { + var pt = CreatePivotTableFor2X2(wb); + + pt.ColumnLabels.Add("A"); + pt.Values.Add("B"); + }, @"Other\PivotTable\Create\Add_one_column_and_one_value.xlsx"); + } + + [Test] + public void Add_one_column_and_two_values() + { + TestHelper.CreateAndCompare(wb => + { + var pt = CreatePivotTableFor2X2(wb); + + pt.ColumnLabels.Add("A"); + pt.Values.Add("B", "Sum of B").SetSummaryFormula(XLPivotSummary.Sum); + pt.Values.Add("B", "Count of B").SetSummaryFormula(XLPivotSummary.Count); + pt.SetShowGrandTotalsColumns(false); + }, @"Other\PivotTable\Create\Add_one_column_and_two_values.xlsx"); + } + + private static IXLPivotTable CreatePivotTableFor2X2(XLWorkbook wb) + { + var range = wb.AddWorksheet().FirstCell().InsertData(new object[] + { + ("A", "B"), + (1, 2), + }); + + var ws = wb.AddWorksheet().SetTabActive(); + var pt = ws.PivotTables.Add("Test", ws.FirstCell(), range); + return pt; + } + } +} diff --git a/ClosedXML.Tests/Excel/PivotTables/Style/XLPivotFieldStyleFormatsTests.cs b/ClosedXML.Tests/Excel/PivotTables/Style/XLPivotFieldStyleFormatsTests.cs new file mode 100644 index 000000000..c42fa7a1d --- /dev/null +++ b/ClosedXML.Tests/Excel/PivotTables/Style/XLPivotFieldStyleFormatsTests.cs @@ -0,0 +1,209 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PivotTables.Style; + +internal class XLPivotFieldStyleFormatsTests +{ + [Test] + public void Modify_pivot_field_label_style() + { + TestHelper.CreateAndCompare(wb => + { + var dataSheet = wb.AddWorksheet(); + var dataRange = dataSheet.Cell("A1").InsertData(new object[] + { + ("Name", "Month", "Price"), + ("Cake", "Jan", 9), + ("Pie", "Jan", 7), + ("Cake", "Feb", 3), + }); + + var ptSheet = wb.AddWorksheet().SetTabActive(); + ptSheet.Column("A").Width = 15; + var pt = dataRange.CreatePivotTable(ptSheet.Cell("A1"), "pivot table"); + pt.RowLabels.Add("Name"); + var monthField = pt.RowLabels.Add("Month"); + pt.Values.Add("Price"); + + // Modify style in two steps to ensure second modification doesn't override the first one + monthField.StyleFormats.Label.Style + .Fill.SetBackgroundColor(XLColor.LightGray) + .Font.SetStrikethrough(); + monthField.StyleFormats.Label.Style.Font.SetBold(); + }, @"Other\PivotTable\Style\Modify_pivot_field_label_style.xlsx"); + } + + [TestCase(XLPivotLayout.Compact, "Set_pivot_field_header_style-compact.xlsx")] + [TestCase(XLPivotLayout.Tabular, "Set_pivot_field_header_style-tabular.xlsx")] + public void Set_pivot_field_header_style(XLPivotLayout layout, string testFile) + { + // Header in compact is only one cell, whereas tabular has individual header for each field + // on axis. Tested axis contains two fields to check that even when there is only one header, + // it is used for all fields (i.e. the single header cell is colored, not a cell next to it). + TestHelper.CreateAndCompare(wb => + { + var dataSheet = wb.AddWorksheet(); + var dataRange = dataSheet.Cell("A1").InsertData(new object[] + { + ("Name", "Flavor", "Month", "Price"), + ("Cake", "Vanilla", "Jan", 9), + ("Pie", "Peach", "Jan", 7), + ("Cake", "Lemon", "Feb", 3), + }); + + var ptSheet = wb.AddWorksheet().SetTabActive(); + var pt = dataRange.CreatePivotTable(ptSheet.Cell("A1"), "pivot table"); + pt.Layout = layout; + pt.Values.Add("Price"); + pt.RowLabels.Add("Month"); + pt.ColumnLabels.Add("Name"); + var styledHeaderField = pt.ColumnLabels.Add("Flavor"); + + // Set two style in two steps to check that second one doesn't overwrite first one. + styledHeaderField.StyleFormats.Header.Style.Fill.SetBackgroundColor(XLColor.Green); + styledHeaderField.StyleFormats.Header.Style.Font.SetFontColor(XLColor.Red); + }, $@"Other\PivotTable\Style\{testFile}"); + } + + [Test] + public void Set_pivot_field_subtotals_style() + { + // In the test we set two subtotals, one for name and other for month. The month one + // is there to check the subtotal of a last field on an multi-field axis is displayed + // correctly (it needs Outline:0 attribute to be displayed correctly in Excel). + TestHelper.CreateAndCompare(wb => + { + var dataSheet = wb.AddWorksheet(); + var dataRange = dataSheet.Cell("A1").InsertData(new object[] + { + ("Name", "Month", "Price"), + ("Cake", "Jan", 9), + ("Pie", "Jan", 7), + ("Cake", "Feb", 3), + }); + + var ptSheet = wb.AddWorksheet().SetTabActive(); + var pt = dataRange.CreatePivotTable(ptSheet.Cell("A1"), "pivot table"); + pt.Values.Add("Price"); + var nameField = pt.RowLabels.Add("Name") + .AddSubtotal(XLSubtotalFunction.Sum) + .SetSubtotalsAtTop(false); + var monthField = pt.RowLabels.Add("Month") + .AddSubtotal(XLSubtotalFunction.Maximum) + .AddSubtotal(XLSubtotalFunction.Minimum) + .SetSubtotalsAtTop(false); + + // Set two style in two steps to check that second one doesn't overwrite first one + // and also that second access modifies the same pivot area. + nameField.StyleFormats.Subtotal.Style.Font.SetFontColor(XLColor.GoldenBrown); + nameField.StyleFormats.Subtotal.Style.Fill.SetBackgroundColor(XLColor.Yellow); + + monthField.StyleFormats.Subtotal.Style.Font.SetFontColor(XLColor.Green).Font.SetBold(); + monthField.StyleFormats.Subtotal.Style.Fill.SetBackgroundColor(XLColor.Red); + }, @"Other\PivotTable\Style\Set_pivot_field_subtotals_style.xlsx"); + } + + [Test] + public void Style_values_at_intersection_of_row_and_column() + { + // Style cells that belong to a row/column that represent a specific field. + // The question for data cell is this: does the row/column of the cell + // intersects a label that belongs to specified field? + // Therefore, some cells are not styled, because they don't satisfy the + // condition: grand columns, labels, rows that lie on name field row, but + // not month field row. + // You can switch layout to Table and the demo styles will be overlapped. + TestHelper.CreateAndCompare(wb => + { + var dataSheet = wb.AddWorksheet(); + var dataRange = dataSheet.Cell("A1").InsertData(new object[] + { + ("Name", "Flavor", "Month", "Price"), + ("Cake", "Vanilla", "Jan", 9), + ("Pie", "Peach", "Jan", 7), + ("Cake", "Lemon", "Feb", 3), + }); + + var ptSheet = wb.AddWorksheet().SetTabActive(); + var pt = dataRange.CreatePivotTable(ptSheet.Cell("A1"), "pivot table"); + var nameRowField = pt.RowLabels.Add("Name"); + var monthRowField = pt.RowLabels.Add("Month"); + var flavorColumnField = pt.ColumnLabels.Add("Flavor"); + pt.Values.Add("Price"); + + // To demonstrate styling of data values on a row, + // also display subtotals for name field. + nameRowField.AddSubtotal(XLSubtotalFunction.Sum); + nameRowField.SubtotalsAtTop = true; + + nameRowField.StyleFormats.DataValuesFormat + .Style.Font.SetFontColor(XLColor.Red); + monthRowField.StyleFormats.DataValuesFormat + .AndWith(flavorColumnField) + .Style.Fill.BackgroundColor = XLColor.LightBlue; + }, @"Other\PivotTable\Style\Style_values_at_intersection_of_row_and_column.xlsx"); + } + + [Test] + public void Style_data_cells_at_intersection_of_values_field_and_row_or_column_field() + { + // Style all data cells that display 'Max Price' value (i.e. value cells and grand totals) + // and lie on a row that represents the 'flavor' field. 'lie on' means it intersects a row + // that contains field label. + TestHelper.CreateAndCompare(wb => + { + var dataSheet = wb.AddWorksheet(); + var dataRange = dataSheet.Cell("A1").InsertData(new object[] + { + ("Name", "Flavor", "Price"), + ("Cake", "Vanilla", 9), + ("Pie", "Peach", 7), + ("Cake", "Lemon", 3), + }); + + var ptSheet = wb.AddWorksheet().SetTabActive(); + var pt = dataRange.CreatePivotTable(ptSheet.Cell("A1"), "pivot table"); + + pt.RowLabels.Add("Name"); + pt.RowLabels.Add(XLConstants.PivotTable.ValuesSentinalLabel); + var flavorField = pt.ColumnLabels.Add("Flavor"); + pt.Values.Add("Price"); + var maxPrice = pt.Values.Add("Price", "Max Price").SetSummaryFormula(XLPivotSummary.Maximum); + + flavorField.StyleFormats.DataValuesFormat + .ForValueField(maxPrice) + .Style.Fill.BackgroundColor = XLColor.LightBlue; + }, @"Other\PivotTable\Style\Style_data_cells_at_intersection_of_values_field_and_row_or_column_field.xlsx"); + } + + [Test] + public void Style_data_cells_at_intersection_of_value_field_and_axis_field_with_specific_values() + { + TestHelper.CreateAndCompare(wb => + { + var dataSheet = wb.AddWorksheet(); + var dataRange = dataSheet.Cell("A1").InsertData(new object[] + { + ("Name", "Flavor", "Price"), + ("Cake", "Vanilla", 9), + ("Pie", "Peach", 7), + ("Cake", "Lemon", 3), + ("Waffle", "Peach", 5), + ("Waffle", "Chocolate", 7), + }); + + var ptSheet = wb.AddWorksheet().SetTabActive(); + var pt = dataRange.CreatePivotTable(ptSheet.Cell("A1"), "pivot table"); + + var nameField = pt.RowLabels.Add("Name"); + var flavorField = pt.ColumnLabels.Add("Flavor"); + pt.Values.Add("Price"); + + nameField.StyleFormats.DataValuesFormat + .AndWith(nameField, x => x.IsText && x.GetText() == "Waffle") + .AndWith(flavorField, x => x.IsText && x.GetText() == "Peach" || x.GetText() == "Chocolate") + .Style.Fill.BackgroundColor = XLColor.LightBlue; + }, @"Other\PivotTable\Style\Style_data_cells_at_intersection_of_value_field_and_axis_field_with_specific_values.xlsx"); + } +} diff --git a/ClosedXML.Tests/Excel/PivotTables/Style/XLPivotTableStyleFormatsTests.cs b/ClosedXML.Tests/Excel/PivotTables/Style/XLPivotTableStyleFormatsTests.cs new file mode 100644 index 000000000..50f98e489 --- /dev/null +++ b/ClosedXML.Tests/Excel/PivotTables/Style/XLPivotTableStyleFormatsTests.cs @@ -0,0 +1,81 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PivotTables.Style; + +[TestFixture] +internal class XLPivotTableStyleFormatsTests +{ + [Test] + public void Add_grand_row_total_styles() + { + TestHelper.CreateAndCompare(wb => + { + var dataSheet = wb.AddWorksheet(); + var dataRange = dataSheet.Cell("A1").InsertData(new object[] + { + ("Name", "Price"), + ("Cake", 9), + ("Pie", 7), + ("Cake", 3), + }); + + var ptSheet = wb.AddWorksheet().SetTabActive(); + ptSheet.Column("A").Width = 15; + var pt = dataRange.CreatePivotTable(ptSheet.Cell("A1"), "pivot table"); + pt.RowLabels.Add("Name"); + pt.Values.Add("Price", "Avg $").SetSummaryFormula(XLPivotSummary.Average); + pt.Values.Add("Price", "Max $").SetSummaryFormula(XLPivotSummary.Maximum); + + pt.StyleFormats.RowGrandTotalFormats + .ForElement(XLPivotStyleFormatElement.All).Style + .Font.SetFontSize(15) + .Font.SetUnderline(XLFontUnderlineValues.Double); + pt.StyleFormats.RowGrandTotalFormats + .ForElement(XLPivotStyleFormatElement.Label).Style + .Font.SetFontColor(XLColor.Green); + pt.StyleFormats.RowGrandTotalFormats + .ForElement(XLPivotStyleFormatElement.Data).Style + .Font.SetFontColor(XLColor.Red); + }, @"Other\PivotTable\Style\Add_grand_row_total_styles.xlsx"); + } + + [Test] + public void Add_grand_column_total_styles() + { + TestHelper.CreateAndCompare(wb => + { + var dataSheet = wb.AddWorksheet(); + var dataRange = dataSheet.Cell("A1").InsertData(new object[] + { + ("Name", "Month", "Price"), + ("Cake", "Jan", 9), + ("Pie", "Jan", 7), + ("Cake", "Feb", 3), + }); + + var ptSheet = wb.AddWorksheet().SetTabActive(); + ptSheet.Column("A").Width = 15; + var pt = dataRange.CreatePivotTable(ptSheet.Cell("A1"), "pivot table"); + pt.RowLabels.Add("Name"); + pt.RowLabels.Add("Month"); + pt.Values.Add("Price"); + + pt + .SetShowGrandTotalsColumns(true) + .SetShowGrandTotalsRows(false); + + pt.StyleFormats.ColumnGrandTotalFormats + .ForElement(XLPivotStyleFormatElement.All).Style + .Font.SetFontSize(15) + .Font.SetUnderline(XLFontUnderlineValues.Double); + pt.StyleFormats.ColumnGrandTotalFormats + .ForElement(XLPivotStyleFormatElement.Label).Style + .Font.SetFontColor(XLColor.Green); + pt.StyleFormats.ColumnGrandTotalFormats + .ForElement(XLPivotStyleFormatElement.Data).Style + .Font.SetFontColor(XLColor.Red); + }, @"Other\PivotTable\Style\Add_grand_column_total_styles.xlsx"); + } +} + diff --git a/ClosedXML.Tests/Excel/PivotTables/XLPivotCacheTests.cs b/ClosedXML.Tests/Excel/PivotTables/XLPivotCacheTests.cs new file mode 100644 index 000000000..e016bf97b --- /dev/null +++ b/ClosedXML.Tests/Excel/PivotTables/XLPivotCacheTests.cs @@ -0,0 +1,94 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PivotTables +{ + [TestFixture] + public class XLPivotCacheTests + { + [Test] + public void FieldNames_KeepNamesEvenWhenSourceChange() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.FirstCell().InsertData(new[] { "Name", "Pie" }); + + var pivotCache = wb.PivotCaches.Add(range); + ws.Cell("A1").Value = "Pastry"; + + Assert.AreEqual(new[] { "Name" }, pivotCache.FieldNames); + } + + [Test] + public void Refresh_UpdatesFieldNames() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.FirstCell().InsertData(new[] { "Name", "Pie" }); + + var pivotCache = wb.PivotCaches.Add(range); + ws.Cell("A1").Value = "Pastry"; + pivotCache.Refresh(); + + Assert.AreEqual(new[] { "Pastry" }, pivotCache.FieldNames); + } + + [Test] + public void Refresh_RetainsSetOptions() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.FirstCell().InsertData(new[] { "Name", "Pie" }); + + var pivotCache = wb.PivotCaches.Add(range); + + pivotCache.ItemsToRetainPerField = XLItemsToRetain.None; + pivotCache.SaveSourceData = false; + pivotCache.RefreshDataOnOpen = true; + + pivotCache.Refresh(); + + Assert.AreEqual(XLItemsToRetain.None, pivotCache.ItemsToRetainPerField); + Assert.AreEqual(false, pivotCache.SaveSourceData); + Assert.AreEqual(true, pivotCache.RefreshDataOnOpen); + } + + [Test] + public void Refresh_RenamedFieldIsRemovedFromPivotTable() + { + // Pivot table has only field for Pastry, the dough is no longer in the pivot table after refresh + TestHelper.CreateAndCompare(wb => + { + var ws = wb.AddWorksheet(); + var range = ws.FirstCell().InsertData(new object[] + { + ("Pastry", "Dough"), + ("Waffles", "Puff") + }); + + var table = range.CreateTable(); + + var pivotTable = ws.PivotTables.Add("pvt", ws.Cell("D1"), table); + pivotTable.RowLabels.Add("Pastry"); + pivotTable.RowLabels.Add("Dough"); + pivotTable.Values.Add("Pastry").SetSummaryFormula(XLPivotSummary.Count); + + ws.Cell("B1").Value = "Mixture"; + pivotTable.PivotCache.Refresh(); + }, @"Other\PivotTableReferenceFiles\RenamedFieldIsRemovedFromPivotTable-output.xlsx"); + } + + [Test] + public void Preserve_field_statistics_even_without_source_data() + { + // Even though pivot table cache has no records in the workbook, it does contain + // statistics about each field (e.g. types and min/max values). These are preserved + // through load/save. + // The cache fields in the file don't have any shared values or records, only stats, + // and load/save preserves all Contains* flags and Min/Max values. + TestHelper.LoadSaveAndCompare( + @"Other\PivotTableReferenceFiles\PivotCacheWithoutSourceData-input.xlsx", + @"Other\PivotTableReferenceFiles\PivotCacheWithoutSourceData-output.xlsx"); + } + } +} diff --git a/ClosedXML.Tests/Excel/PivotTables/XLPivotDataFieldsTests.cs b/ClosedXML.Tests/Excel/PivotTables/XLPivotDataFieldsTests.cs new file mode 100644 index 000000000..db68ae5aa --- /dev/null +++ b/ClosedXML.Tests/Excel/PivotTables/XLPivotDataFieldsTests.cs @@ -0,0 +1,39 @@ +using System; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PivotTables +{ + /// + /// Test methods of interface implemented through class. + /// + internal class XLPivotDataFieldsTests + { + #region IXLPivotValues methods + + #region Add + + [Test] + public void Add_source_name_must_be_from_pivot_cache_field_names() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("Name", "Price"), + ("Cake", 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + + var ex = Assert.Throws(() => pt.Values.Add("Wrong field name")); + + Assert.NotNull(ex); + StringAssert.StartsWith("Field 'Wrong field name' is not in the fields of a pivot cache. Should be one of 'Name','Price'.", ex.Message); + } + + #endregion + + #endregion + } +} diff --git a/ClosedXML.Tests/Excel/PivotTables/XLPivotSourceTests.cs b/ClosedXML.Tests/Excel/PivotTables/XLPivotSourceTests.cs new file mode 100644 index 000000000..c42fd9d2e --- /dev/null +++ b/ClosedXML.Tests/Excel/PivotTables/XLPivotSourceTests.cs @@ -0,0 +1,29 @@ +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PivotTables; + +/// +/// Tests for classes that implement IXLPivotSource. +/// +[TestFixture] +internal class XLPivotSourceTests +{ + [Test] + public void Can_load_and_save_all_source_types() + { + // The test files contains all possible pivot cache sources. The output is mangled, but + // Excel can open it and use refresh on each pivot table. External workbook is in the same + // directory: PivotTable-AllSources-external-data.xlsx. + // The pivot table that uses connection has a connection to the external workbook + // PivotTable-AllSources-external-data.xlsx. The connection uses an absolute path, so it + // needs to be updated according to real directory. Doesn't affect CI, because connection + // is not actually used to get data. + // Scenario doesn't throw on refresh, but it incomplete. The cache source is correct though. + // + // Open the workbook and click Pivot Table Analyze - Refresh - Refresh All. It shouldn't + // report an error. + TestHelper.LoadSaveAndCompare( + @"Other\PivotTable\Sources\PivotTable-AllSources-input.xlsx", + @"Other\PivotTable\Sources\PivotTable-AllSources-output.xlsx"); + } +} diff --git a/ClosedXML.Tests/Excel/PivotTables/XLPivotTableAxisFieldTests.cs b/ClosedXML.Tests/Excel/PivotTables/XLPivotTableAxisFieldTests.cs new file mode 100644 index 000000000..91c801911 --- /dev/null +++ b/ClosedXML.Tests/Excel/PivotTables/XLPivotTableAxisFieldTests.cs @@ -0,0 +1,51 @@ +using System; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PivotTables +{ + /// + /// Tests methods of interface implemented through . + /// + [TestFixture] + internal class XLPivotTableAxisFieldTests + { + [Test] + public void CustomName_can_be_changed() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.Cell("A1").InsertData(new object[] + { + ("ID", "Color", "Count"), + (1, "Blue", 10), + }); + var pt = ws.PivotTables.Add("pt", ws.Cell("E1"), range); + var colorField = pt.RowLabels.Add("Color"); + + colorField.SetCustomName("Changed color"); + + Assert.AreEqual("Changed color", pt.RowLabels.Get(0).CustomName); + } + + [Test] + public void CustomName_throws_exception_when_name_is_already_used() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.Cell("A1").InsertData(new object[] + { + ("ID", "Color", "Count"), + (1, "Blue", 10), + }); + var pt = ws.PivotTables.Add("pt", ws.Cell("E1"), range); + var idField = pt.RowLabels.Add("ID", "Custom ID"); + var colorField = pt.RowLabels.Add("Color"); + + var ex1 = Assert.Throws(() => idField.SetCustomName("Color"))!; + Assert.AreEqual("Custom name 'Color' is already used by another field.", ex1.Message); + var ex2 = Assert.Throws(() => colorField.SetCustomName("Custom ID")); + Assert.AreEqual("Custom name 'Custom ID' is already used by another field.", ex2.Message); + } + } +} diff --git a/ClosedXML.Tests/Excel/PivotTables/XLPivotTableFieldsTests.cs b/ClosedXML.Tests/Excel/PivotTables/XLPivotTableFieldsTests.cs new file mode 100644 index 000000000..673443bff --- /dev/null +++ b/ClosedXML.Tests/Excel/PivotTables/XLPivotTableFieldsTests.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PivotTables +{ + /// + /// Test methods of interface implemented through . + /// + [TestFixture] + internal class XLPivotTableAxisTests + { + #region IXLPivotFields methods + + #region Add + + [Test] + public void Add_field_not_yet_in_table_adds_field_and_shared_items() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("ID", "Count"), + (1, 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + var internalPt = (XLPivotTable)pt; + Assert.IsEmpty(internalPt.PivotFields[0].Items); + + var idField = pt.RowLabels.Add("ID", "Item ID").AddSubtotal(XLSubtotalFunction.Automatic); + + Assert.AreEqual("ID", idField.SourceName); + Assert.AreEqual("Item ID", idField.CustomName); + Assert.AreEqual("Item ID", pt.RowLabels.Single().CustomName); + + // Adds values and default aggregation func to items of the field + var fieldItems = internalPt.PivotFields[0].Items; + Assert.AreEqual(2, fieldItems.Count); + Assert.AreEqual(XLPivotItemType.Data, fieldItems[0].ItemType); + Assert.AreEqual(0, fieldItems[0].ItemIndex); + Assert.AreEqual(XLPivotItemType.Default, fieldItems[1].ItemType); + } + + [Test] + public void Same_field_cant_be_added_twice_to_same_axis() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("ID", "Count"), + (1, 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + pt.RowLabels.Add("ID", "Item ID"); + + var ex = Assert.Throws(() => pt.RowLabels.Add("ID", "Item ID"))!; + Assert.AreEqual("Custom name 'Item ID' is already used.", ex.Message); + } + + [Test] + public void Add_field_must_exist_in_cache() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("ID", "Count"), + (1, 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + Assert.DoesNotThrow(() => pt.RowLabels.Add("ID", "Item ID")); + + var ex = Assert.Throws(() => pt.RowLabels.Add("nonexistent"))!; + Assert.AreEqual("Field 'nonexistent' not found in pivot cache.", ex.Message); + } + + #endregion + + #region Clear + + [Test] + public void Clear_removes_all_fields_from_axis() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("ID", "Color", "Count"), + (1, "Blue", 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + pt.RowLabels.Add("ID", "Item ID"); + pt.RowLabels.Add("Color", "Custom color"); + + pt.RowLabels.Clear(); + + Assert.IsEmpty(pt.RowLabels); + + // Clear should also remove custom names and axis, otherwise there are problems loading + // file with such remains in Excel. + var internalPt = (XLPivotTable)pt; + Assert.Null(internalPt.PivotFields[0].Name); + Assert.Null(internalPt.PivotFields[0].Axis); + Assert.Null(internalPt.PivotFields[1].Name); + Assert.Null(internalPt.PivotFields[1].Axis); + } + + #endregion + + #region Contains + + [Test] + public void Contains_checks_whether_field_is_present() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("ID", "Color", "Count"), + (1, "Blue", 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + var idField = pt.RowLabels.Add("ID", "Item ID"); + pt.ColumnLabels.Add("Color"); + + Assert.True(pt.RowLabels.Contains("id")); + Assert.True(pt.RowLabels.Contains(idField)); + Assert.False(pt.RowLabels.Contains("color")); + Assert.False(pt.RowLabels.Contains("nonexistent")); + } + + #endregion + + #region Get(string sourceName) + + [Test] + public void Get_field_by_source_name() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("ID", "Color", "Count"), + (1, "Blue", 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + pt.RowLabels.Add("ID", "Item ID"); + pt.ColumnLabels.Add("Color"); + + Assert.AreEqual("ID", pt.RowLabels.Get("id").SourceName); + var ex = Assert.Throws(() => pt.RowLabels.Get("color"))!; + Assert.AreEqual("Field with source name 'color' not found in AxisRow.", ex.Message); + } + + #endregion + + #region Get(int) + + [Test] + public void Get_field_by_index() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("ID", "Color", "Count"), + (1, "Blue", 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + pt.RowLabels.Add("ID", "Item ID"); + pt.ColumnLabels.Add("Color"); + + Assert.AreEqual("ID", pt.RowLabels.Get(0).SourceName); + Assert.Throws(() => pt.RowLabels.Get(-2)); + Assert.Throws(() => pt.RowLabels.Get(1)); + } + + #endregion + + #region IndexOf + + [Test] + public void IndexOf_finds_field_in_axis_by_source_name() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("ID", "Color", "Count"), + (1, "Blue", 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + var idField = pt.RowLabels.Add("ID", "Item ID"); + pt.ColumnLabels.Add("Color"); + + Assert.AreEqual(0, pt.RowLabels.IndexOf("ID")); + Assert.AreEqual(0, pt.RowLabels.IndexOf(idField)); + Assert.AreEqual(-1, pt.RowLabels.IndexOf("item id")); + Assert.AreEqual(-1, pt.RowLabels.IndexOf("Color")); + } + + #endregion + + #region Remove + + [Test] + public void Remove_removes_field() + { + using var wb = new XLWorkbook(); + var data = wb.AddWorksheet(); + var range = data.Cell("A1").InsertData(new object[] + { + ("ID", "Color", "Count"), + (1, "Blue", 10), + }); + var ptSheet = wb.AddWorksheet(); + var pt = ptSheet.PivotTables.Add("pt", ptSheet.Cell("A1"), range); + pt.RowLabels.Add("ID"); + + pt.RowLabels.Remove("id"); + pt.RowLabels.Remove("ID"); // Doesnt throw on already removed. + + Assert.IsEmpty(pt.RowLabels); + } + + #endregion + + #endregion + } +} diff --git a/ClosedXML.Tests/Excel/PivotTables/XLPivotTableFiltersTests.cs b/ClosedXML.Tests/Excel/PivotTables/XLPivotTableFiltersTests.cs new file mode 100644 index 000000000..6e87a9007 --- /dev/null +++ b/ClosedXML.Tests/Excel/PivotTables/XLPivotTableFiltersTests.cs @@ -0,0 +1,42 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.PivotTables +{ + [TestFixture] + public class XLPivotTableFiltersTests + { + [Test] + public void Adding_and_removing_filters_shifts_pivot_table_area() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var data = ws.Cell("A1").InsertData(new object[] + { + ("Name", "City", "Flavor", "Value"), + ("Cake", "Tokyo", "Vanilla", 7), + }); + + var pt = ws.PivotTables.Add("pt", ws.Cell("E2"), data); + + // No filter, the table is at the original cell + Assert.AreEqual("E2", ((XLPivotTable)pt).Area.ToString()); + + pt.ReportFilters.Add("City"); + + // First filter also adds divider row between filter and the table. + Assert.AreEqual("E4", ((XLPivotTable)pt).Area.ToString()); + + pt.ReportFilters.Add("Flavor"); + + // When second filter is added, there is no need to add second divider row. + Assert.AreEqual("E5", ((XLPivotTable)pt).Area.ToString()); + + pt.ReportFilters.Remove("City"); + Assert.AreEqual("E4", ((XLPivotTable)pt).Area.ToString()); + + pt.ReportFilters.Remove("Flavor"); + Assert.AreEqual("E2", ((XLPivotTable)pt).Area.ToString()); + } + } +} diff --git a/ClosedXML.Tests/Excel/PivotTables/XLPivotTableTests.cs b/ClosedXML.Tests/Excel/PivotTables/XLPivotTableTests.cs index dd57e2e21..645118e4f 100644 --- a/ClosedXML.Tests/Excel/PivotTables/XLPivotTableTests.cs +++ b/ClosedXML.Tests/Excel/PivotTables/XLPivotTableTests.cs @@ -34,7 +34,7 @@ public void PivotTables() [Test] public void TestPivotTableVersioningAttributes() { - // Pivot table definitions in input file has created and refreshed versioning attributes = 3 + // Pivot cache definitions in input file has created and refreshed version attributes = 3 using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Other\PivotTableReferenceFiles\VersioningAttributes\inputfile.xlsx"))) { TestHelper.CreateAndCompare(() => @@ -50,7 +50,7 @@ public void TestPivotTableVersioningAttributes() pt.Values.Add("Id", "Count of Id").SetSummaryFormula(XLPivotSummary.Count); return wb; - // Pivot table definitions in output file has created and refreshed versioning attributes = 5 + // Pivot cache definitions in output file has created and refreshed version attributes = 5 }, @"Other\PivotTableReferenceFiles\VersioningAttributes\outputfile.xlsx"); } } @@ -78,7 +78,7 @@ public void PivotTableOptionsSaveTest() pt.ShowContextualTooltips = false; pt.DisplayCaptionsAndDropdowns = false; pt.RepeatRowLabels = true; - pt.SaveSourceData = false; + pt.PivotCache.SaveSourceData = false; pt.EnableShowDetails = false; pt.ShowColumnHeaders = false; pt.ShowRowHeaders = false; @@ -103,8 +103,8 @@ public void PivotTableOptionsSaveTest() pt.PrintExpandCollapsedButtons = true; pt.PrintTitles = true; - // TODO pt.RefreshDataOnOpen = false; - pt.ItemsToRetainPerField = XLItemsToRetain.Max; + pt.PivotCache.RefreshDataOnOpen = false; + pt.PivotCache.ItemsToRetainPerField = XLItemsToRetain.Max; pt.EnableCellEditing = true; pt.ShowValuesRow = true; pt.ShowRowStripes = true; @@ -149,10 +149,10 @@ public void PivotTableOptionsSaveTest() Assert.AreEqual(true, ptassert.PrintExpandCollapsedButtons, "PrintExpandCollapsedButtons save failure"); Assert.AreEqual(true, ptassert.RepeatRowLabels, "RepeatRowLabels save failure"); Assert.AreEqual(true, ptassert.PrintTitles, "PrintTitles save failure"); - Assert.AreEqual(false, ptassert.SaveSourceData, "SaveSourceData save failure"); + Assert.AreEqual(false, ptassert.PivotCache.SaveSourceData, "SaveSourceData save failure"); Assert.AreEqual(false, ptassert.EnableShowDetails, "EnableShowDetails save failure"); - // TODO Assert.AreEqual(false, ptassert.RefreshDataOnOpen, "RefreshDataOnOpen save failure"); - Assert.AreEqual(XLItemsToRetain.Max, ptassert.ItemsToRetainPerField, "ItemsToRetainPerField save failure"); + Assert.AreEqual(false, ptassert.PivotCache.RefreshDataOnOpen, "RefreshDataOnOpen save failure"); + Assert.AreEqual(XLItemsToRetain.Max, ptassert.PivotCache.ItemsToRetainPerField, "ItemsToRetainPerField save failure"); Assert.AreEqual(true, ptassert.EnableCellEditing, "EnableCellEditing save failure"); Assert.AreEqual(XLPivotTableTheme.PivotStyleDark13, ptassert.Theme, "Theme save failure"); Assert.AreEqual(true, ptassert.ShowValuesRow, "ShowValuesRow save failure"); @@ -207,8 +207,10 @@ public void PivotFieldOptionsSaveTest(bool withDefaults) } [Test] + [Ignore("PT styles will be fixed in a different PR")] public void PivotTableStyleFormatsTest() { +/* using (var ms = new MemoryStream()) { using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Examples\PivotTables\PivotTables.xlsx"))) @@ -246,7 +248,7 @@ public void PivotTableStyleFormatsTest() monthPivotField.StyleFormats.Label.Style.Fill.BackgroundColor = XLColor.Amber; monthPivotField.StyleFormats.Header.Style.Font.FontColor = XLColor.Yellow; namePivotField.StyleFormats.DataValuesFormat - .AndWith(monthPivotField, v => v.ToString() == "May") + .AndWith(monthPivotField, v => v.IsText && v.GetText() == "May") .ForValueField(numberOfOrdersPivotValue) .Style.Font.FontColor = XLColor.Green; @@ -288,6 +290,7 @@ public void PivotTableStyleFormatsTest() wb.Save(); } } +*/ } [Test] @@ -320,7 +323,6 @@ public void CopyPivotTableTests() private void AssertPivotTablesAreEqual(XLPivotTable original, XLPivotTable copy, Boolean compareName) { - Assert.AreNotEqual(original.Guid, copy.Guid); Assert.AreEqual(compareName, original.Name.Equals(copy.Name)); var comparer = new PivotTableComparer(compareName: compareName, compareRelId: false, compareTargetCellAddress: false); @@ -347,6 +349,17 @@ public Pastry(string name, int? code, int numberOfOrders, double quality, string public DateTime? BakeDate { get; set; } } + [Test] + public void SharedItemsWithVariousDataTypesInTableColumn() + { + // Load an excel that contains a table which has various combinations of types in columns. + // The pivot cache definition contain various flags in shared items for each field and the + // test checks the flags in cache are set correctly (they are determined in cache writer). + TestHelper.LoadSaveAndCompare( + @"Other\PivotTableReferenceFiles\VariousDataTypesInTableColumns\input.xlsx", + @"Other\PivotTableReferenceFiles\VariousDataTypesInTableColumns\output.xlsx"); + } + [Test] public void BlankPivotTableField() { @@ -428,66 +441,30 @@ public void BlankPivotTableField() [Test] public void SourceSheetWithWhitespace() { - using (var ms = new MemoryStream()) + // Check that pivot source reference for a sheet name with whitespaces + // is not saved to the file with escaped quotes, issue #955. + TestHelper.CreateAndCompare(() => { - TestHelper.CreateAndCompare(() => - { - // Based on .\ClosedXML\ClosedXML.Examples\PivotTables\PivotTables.cs - // But with empty column for Month - var pastries = new List - { - new Pastry("Croissant", 101, 150, 60.2, "", new DateTime(2016, 04, 21)), - new Pastry("Croissant", 101, 250, 50.42, "", new DateTime(2016, 05, 03)), - new Pastry("Croissant", 101, 134, 22.12, "", new DateTime(2016, 06, 24)), - new Pastry("Doughnut", 102, 250, 89.99, "", new DateTime(2017, 04, 23)), - new Pastry("Doughnut", 102, 225, 70, "", new DateTime(2016, 05, 24)), - new Pastry("Doughnut", 102, 210, 75.33, "", new DateTime(2016, 06, 02)), - new Pastry("Bearclaw", 103, 134, 10.24, "", new DateTime(2016, 04, 27)), - new Pastry("Bearclaw", 103, 184, 33.33, "", new DateTime(2016, 05, 20)), - new Pastry("Bearclaw", 103, 124, 25, "", new DateTime(2017, 06, 05)), - new Pastry("Danish", 104, 394, -20.24, "", null), - new Pastry("Danish", 104, 190, 60, "", new DateTime(2017, 05, 08)), - new Pastry("Danish", 104, 221, 24.76, "", new DateTime(2016, 06, 21)), - - // Deliberately add different casings of same string to ensure pivot table doesn't duplicate it. - new Pastry("Scone", 105, 135, 0, "", new DateTime(2017, 04, 22)), - new Pastry("SconE", 105, 122, 5.19, "", new DateTime(2017, 05, 03)), - new Pastry("SCONE", 105, 243, 44.2, "", new DateTime(2017, 06, 14)), - - // For ContainsBlank and integer rows/columns test - new Pastry("Scone", null, 255, 18.4, "", null), - }; - - var wb = new XLWorkbook(); - - var sheet = wb.Worksheets.Add("Pastry Sales Data"); - // Insert our list of pastry data into the "PastrySalesData" sheet at cell 1,1 - var table = sheet.Cell(1, 1).InsertTable(pastries, "PastrySalesData", true); - sheet.Cell("F11").Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; - sheet.Columns().AdjustToContents(); + var wb = new XLWorkbook(); - IXLWorksheet ptSheet; - IXLPivotTable pt; - - // Add a new sheet for our pivot table - ptSheet = wb.Worksheets.Add("pvt"); - - // Create the pivot table, using the data from the "PastrySalesData" table - pt = ptSheet.PivotTables.Add("pvt", ptSheet.Cell(1, 1), table.AsRange()); - pt.ColumnLabels.Add("Name"); - pt.RowLabels.Add("Month"); - - // The values in our table will come from the "NumberOfOrders" field - // The default calculation setting is a total of each row/column - pt.Values.Add("NumberOfOrders", "NumberOfOrdersPercentageOfBearclaw") - .ShowAsPercentageFrom("Name").And("Bearclaw") - .NumberFormat.Format = "0%"; - - ptSheet.Columns().AdjustToContents(); - - return wb; - }, @"Other\PivotTableReferenceFiles\SourceSheetWithWhitespace\outputfile.xlsx"); - } + // Worksheet name contains whitespaces that shouldn't be quoted in the file. + var sheet = wb.Worksheets.Add("Pastry Sales Data"); + var range = sheet.Cell(1, 1).InsertData(new object[] + { + ("Name", "Sold count"), + ("Pie", 7), + ("Cake", 10), + ("Pie", 2), + }); + + // Add a new sheet for our pivot table + var ptSheet = wb.Worksheets.Add("pvt"); + var pt = ptSheet.PivotTables.Add("pvt", ptSheet.Cell(1, 1), range); + pt.RowLabels.Add("Name"); + pt.Values.Add("Sold count"); + + return wb; + }, @"Other\PivotTableReferenceFiles\SourceSheetWithWhitespace\outputfile.xlsx"); } [Test] @@ -728,9 +705,11 @@ public void TwoPivotWithOneSourceTest() var wb = new XLWorkbook(stream); var srcRange = wb.Range("Sheet1!$B$2:$H$207"); + var pivotSource = wb.PivotCaches.Add(srcRange); + foreach (var pt in wb.Worksheets.SelectMany(ws => ws.PivotTables)) { - pt.SourceRange = srcRange; + pt.PivotCache = pivotSource; } return wb; @@ -740,16 +719,15 @@ public void TwoPivotWithOneSourceTest() [Test] public void PivotSubtotalsLoadingTest() { - using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Other\PivotTableReferenceFiles\PivotSubtotalsSource\input.xlsx"))) - TestHelper.CreateAndCompare(() => - { - var wb = new XLWorkbook(stream); - return wb; - }, @"Other\PivotTableReferenceFiles\PivotSubtotalsSource\input.xlsx"); + // Make sure that if the original file has *subtotals*, the subtotals are + // turned on even after loading into ClosedXML and then saving the document. + TestHelper.LoadSaveAndCompare( + @"Other\PivotTableReferenceFiles\PivotSubtotalsSource\input.xlsx", + @"Other\PivotTableReferenceFiles\PivotSubtotalsSource\output.xlsx"); } [Test] - public void ClearPivotTableTenderedTange() + public void ClearPivotTableRenderedRange() { // https://github.com/ClosedXML/ClosedXML/pull/856 using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Other\PivotTableReferenceFiles\ClearPivotTableRenderedRangeWhenLoading\inputfile.xlsx"))) @@ -776,6 +754,198 @@ public void ClearPivotTableTenderedTange() } } + [Test] + public void Add_all_pivot_tables_for_same_range_use_same_pivot_cache() + { + // Two different pivot tables created from same range use same pivot cache + // and don't create a separate pivot cache for each pivot table. + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var range = ws.FirstCell().InsertData(new object[] + { + ("Name", "Count"), + ("Pie", 14), + }); + + var rangePivot1 = ws.PivotTables.Add("rangePivot1", ws.Cell("D1"), range); + var rangePivot2 = ws.PivotTables.Add("rangePivot2", ws.Cell("D20"), range); + + Assert.AreNotSame(rangePivot1, rangePivot2); + Assert.AreSame(rangePivot1.PivotCache, rangePivot2.PivotCache); + } + + [Test] + public void Add_all_pivot_tables_for_same_table_use_same_pivot_cache() + { + // Two different pivot tables created from same table use same pivot cache + // and don't create a separate pivot cache for each pivot table. + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var table = ws.FirstCell().InsertTable(new object[] + { + ("Name", "Count"), + ("Pie", 14), + }); + + var tablePivot1 = ws.PivotTables.Add("tablePivot1", ws.Cell("J1"), table); + var tablePivot2 = ws.PivotTables.Add("tablePivot2", ws.Cell("J20"), table); + + Assert.AreNotSame(tablePivot1, tablePivot2); + Assert.AreSame(tablePivot1.PivotCache, tablePivot2.PivotCache); + } + + [Test] + public void Add_pivot_tables_will_use_table_as_source_if_range_matches_table_area() + { + // When a pivot table is created, the `Add` method tries to first + // find a table with same area as the requested range. If it finds one, + // the cache will be created from the table and not a range. That is the + // Excel behavior and generally makes sense. + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.FirstCell().InsertTable(new object[] + { + ("Name", "Count"), + ("Pie", 14), + }, "Test table"); + + // A range that matches the size of an area + var matchingRange = ws.Range("A1:B3"); + + var tablePivot1 = ws.PivotTables.Add("tablePivot1", ws.Cell("J1"), matchingRange); + + var cacheSource = (XLPivotSourceReference)((XLPivotCache)tablePivot1.PivotCache).Source; + Assert.True(cacheSource.UsesName); + Assert.AreEqual("Test table", cacheSource.Name); + } + + [Test] + public void Load_and_save_pivot_table_with_cache_records_but_missing_source_data() + { + // Test file contains a pivot table created from a normal table in + // a sheet that was already deleted. The file contains cache records, + // but the table and original sheet are gone. It's possible to load + // and save such a pivot table. + // Opening the saved file in Excel throws an error 'Reference isn't valid' + // on load, because of `RefreshOnLoad` flag. That flag is always enabled because + // ClosedXML relies on Excel to rebuild the table and fix it. + // At this time, there is no content, only shape, because we don't have an engine + // to determine correct layout and values. Change RefreshDataOnOpen to 0 and change + // PT in Excel to see the values (aka gimp on Excel PT engine). + TestHelper.LoadSaveAndCompare( + @"Other\PivotTableReferenceFiles\PivotTableWithoutSourceData-input.xlsx", + @"Other\PivotTableReferenceFiles\PivotTableWithoutSourceData-output.xlsx"); + } + + [Test] + public void Skips_chartsheets_during_pivot_table_loading() + { + // Pivot table loading code looks for pivot tables on each sheet, but it shouldn't + // crash when sheet is a chartsheet or other type of sheet. The referenced test file + // contains chartsheet and a pivot table to ensure that loading code won't crash. + TestHelper.LoadAndAssert(wb => + { + // Check that existing pivot table is loaded. + Assert.True(wb.Worksheet("pivot").PivotTables.Contains("Pastries")); + }, @"Other\PivotTableReferenceFiles\ChartsheetAndPivotTable.xlsx"); + } + + #region IXLPivotTable properties + + #region TargetCell + + [Test] + public void Property_TargetCell_sets_value_of_the_top_left_corner_of_pivot_table() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var data = ws.Cell("A1").InsertData(new object[] + { + ("Name", "City", "Flavor", "Sales"), + ("Cake", "Tokyo", "Vanilla", 7), + }); + var pt = ws.PivotTables.Add("pt", ws.Cell("E1"), data); + pt.ReportFilters.Add("City"); + + // Even when we added filter and a gap row, the target cell is still E1 + Assert.AreEqual("E1", pt.TargetCell.Address.ToString()); + Assert.AreEqual("E3", ((XLPivotTable)pt).Area.FirstPoint.ToString()); + + pt.TargetCell = ws.Cell("E2"); + Assert.AreEqual("E2", pt.TargetCell.Address.ToString()); + Assert.AreEqual("E4", ((XLPivotTable)pt).Area.FirstPoint.ToString()); + } + + #endregion + + #region FilterAreaOrder + + [TestCase(XLFilterAreaOrder.DownThenOver, "E5")] + [TestCase(XLFilterAreaOrder.OverThenDown, "E3")] + public void Property_FilterAreaOrder_determines_direction_in_which_are_filter_fields_laid_out(XLFilterAreaOrder order, string tableAddress) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var data = ws.Cell("A1").InsertData(new object[] + { + ("Name", "City", "Flavor", "Sales"), + ("Cake", "Tokyo", "Vanilla", 7), + }); + + var pt = ws.PivotTables.Add("pt", ws.Cell("E1"), data); + pt.FilterAreaOrder = order; + + pt.ReportFilters.Add("Name"); + pt.ReportFilters.Add("City"); + pt.ReportFilters.Add("Flavor"); + + // Indirect detection of filter fields layout: The address of pivot table are is + // determined by filter area order. + Assert.AreEqual(tableAddress, ((XLPivotTable)pt).Area.ToString()); + } + + #endregion + + #region Layout + + [TestCase(XLPivotLayout.Outline, "Property_layout_sets_layout_of_pivot_table_and_all_fields-outline.xlsx")] + [TestCase(XLPivotLayout.Tabular, "Property_layout_sets_layout_of_pivot_table_and_all_fields-tabular.xlsx")] + [TestCase(XLPivotLayout.Compact, "Property_layout_sets_layout_of_pivot_table_and_all_fields-compact.xlsx")] + public void Property_layout_sets_layout_of_pivot_table_and_all_fields(XLPivotLayout layout, string testFile) + { + // The pivot table also contains unused field Currency. It is there, because tabular + // layout doesn't display header fields properly (i.e. one header per axis field), + // unless all (even fields that are not on any axis) have the same field layout. + TestHelper.CreateAndCompare(wb => + { + var dataSheet = wb.AddWorksheet(); + var dataRange = dataSheet.Cell("A1").InsertData(new object[] + { + ("Name", "Size", "Month", "Season", "Price", "Currency"), + ("Cake", "Small", "Jan", "Winter", 9, "EUR"), + ("Pie", "Small", "Jan", "Winter", 7, "EUR"), + ("Cake", "Large", "Feb", "Summer", 3, "CZK"), + }); + + var ptSheet = wb.AddWorksheet().SetTabActive(); + ptSheet.Column("A").Width = 15; + var pt = dataRange.CreatePivotTable(ptSheet.Cell("A1"), "pivot table"); + + // Add at least two fields to each axis to make each layout distinctive. + pt.RowLabels.Add("Name"); + pt.RowLabels.Add("Size"); + pt.ColumnLabels.Add("Month"); + pt.ColumnLabels.Add("Season"); + pt.Values.Add("Price"); + + pt.Layout = layout; + }, $@"Other\PivotTable\TableProps\{testFile}"); + } + + #endregion + + #endregion + private static void SetFieldOptions(IXLPivotField field, bool withDefaults) { field.SubtotalsAtTop = !withDefaults; diff --git a/ClosedXML.Tests/Excel/Ranges/CopyingRangesTests.cs b/ClosedXML.Tests/Excel/Ranges/CopyingRangesTests.cs index 5f26edb8c..7cb3613bf 100644 --- a/ClosedXML.Tests/Excel/Ranges/CopyingRangesTests.cs +++ b/ClosedXML.Tests/Excel/Ranges/CopyingRangesTests.cs @@ -23,8 +23,8 @@ public void CopyingColumns() column1.Cell(6).Style.Fill.SetBackgroundColor(XLColor.FromName("Blue")); column1.Cell(7).Style.Fill.SetBackgroundColor(XLColor.FromTheme(XLThemeColor.Accent3)); - ws.Cell(1, 2).Value = column1; - ws.Cell(1, 3).Value = column1.Column(1, 7); + ws.Cell(1, 2).CopyFrom(column1); + ws.Cell(1, 3).CopyFrom(column1.Column(1, 7)); IXLColumn column2 = ws.Column(2); Assert.AreEqual(XLColor.Red, column2.Cell(1).Style.Fill.BackgroundColor); @@ -56,8 +56,8 @@ public void CopyingRows() IXLRow row1 = ws.Row(1); FillRow(row1); - ws.Cell(2, 1).Value = row1; - ws.Cell(3, 1).Value = row1.Row(1, 7); + ws.Cell(2, 1).CopyFrom(row1); + ws.Cell(3, 1).CopyFrom(row1.Row(1, 7)); IXLRow row2 = ws.Row(2); Assert.AreEqual(XLColor.Red, row2.Cell(1).Style.Fill.BackgroundColor); @@ -95,7 +95,7 @@ public void CopyingConditionalFormats() ((XLConditionalFormats)ws.ConditionalFormats).Consolidate(); - ws.Cell(5, 2).Value = ws.Row(2).Row(1, 7); + ws.Cell(5, 2).CopyFrom(ws.Row(2).Row(1, 7)); Assert.AreEqual(2, ws.ConditionalFormats.Count()); Assert.IsTrue(ws.ConditionalFormats.Single(x => x.Range.RangeAddress.ToStringRelative() == "B1:B3").Values.Any(v => v.Value.Value == "G1" && v.Value.IsFormula)); @@ -122,7 +122,7 @@ public void CopyingConditionalFormatsDifferentWorksheets() var ws2 = wb.Worksheets.Add("Sheet2"); - ws2.FirstCell().Value = ws1.Range("B1:B4"); + ws2.FirstCell().CopyFrom(ws1.Range("B1:B4")); Assert.AreEqual(1, ws2.ConditionalFormats.Count()); Assert.IsTrue(ws2.ConditionalFormats.All(x => x.Ranges.All(s => s.Worksheet == ws2)), "A conditional format was created for another worksheet."); diff --git a/ClosedXML.Tests/Excel/Ranges/InsertingRangesTests.cs b/ClosedXML.Tests/Excel/Ranges/InsertingRangesTests.cs index 159b1bf53..6083d5af0 100644 --- a/ClosedXML.Tests/Excel/Ranges/InsertingRangesTests.cs +++ b/ClosedXML.Tests/Excel/Ranges/InsertingRangesTests.cs @@ -49,9 +49,9 @@ public void InsertingRowsAbove() IXLRangeRow r = ws.Range("B4").InsertRowsAbove(1).First(); r.Cell(1).SetValue("A"); - Assert.AreEqual("X", ws.Cell("B3").GetString()); - Assert.AreEqual("A", ws.Cell("B4").GetString()); - Assert.AreEqual("B", ws.Cell("B5").GetString()); + Assert.AreEqual("X", ws.Cell("B3").GetText()); + Assert.AreEqual("A", ws.Cell("B4").GetText()); + Assert.AreEqual("B", ws.Cell("B5").GetText()); } [Test] diff --git a/ClosedXML.Tests/Excel/Ranges/MergedRangesTests.cs b/ClosedXML.Tests/Excel/Ranges/MergedRangesTests.cs index 81fc3346c..a14d6b109 100644 --- a/ClosedXML.Tests/Excel/Ranges/MergedRangesTests.cs +++ b/ClosedXML.Tests/Excel/Ranges/MergedRangesTests.cs @@ -244,8 +244,8 @@ public void MergedCellsLooseData() ws.Range("A1:A3").Merge(); Assert.AreEqual(100, ws.Cell("A1").Value); - Assert.AreEqual("", ws.Cell("A2").Value); - Assert.AreEqual("", ws.Cell("A3").Value); + Assert.AreEqual(Blank.Value, ws.Cell("A2").Value); + Assert.AreEqual(Blank.Value, ws.Cell("A3").Value); } } @@ -306,7 +306,7 @@ public void UnmergedCellsPreserveStyle() range.Unmerge(); Assert.IsTrue(range.Cells().All(c => c.Style.Fill.BackgroundColor == XLColor.Red)); - Assert.IsTrue(range.Cells().Where(c => c != firstCell).All(c => c.GetString().Length == 0)); + Assert.IsTrue(range.Cells().Where(c => !c.Equals(firstCell)).All(c => c.Value.Equals(Blank.Value))); Assert.AreEqual("B2", firstCell.Value); Assert.AreEqual(XLBorderStyleValues.Thick, ws.Cell("B2").Style.Border.TopBorder); @@ -363,9 +363,9 @@ public void MergedRangesCellValuesShouldNotBeSet() { var ws = workbook.AddWorksheet(); ws.Range("A2:A4").Merge(); - ws.Cell("A2").Value = "1"; - ws.Cell("A3").Value = "1"; - ws.Cell("A4").Value = "1"; + ws.Cell("A2").Value = 1; + ws.Cell("A3").Value = 1; + ws.Cell("A4").Value = 1; ws.Cell("B1").FormulaA1 = "SUM(A:A)"; Assert.AreEqual(1, ws.Cell("B1").Value); } diff --git a/ClosedXML.Tests/Excel/Ranges/RangeShiftingTests.cs b/ClosedXML.Tests/Excel/Ranges/RangeShiftingTests.cs index 86507dbdf..11c303c3c 100644 --- a/ClosedXML.Tests/Excel/Ranges/RangeShiftingTests.cs +++ b/ClosedXML.Tests/Excel/Ranges/RangeShiftingTests.cs @@ -6,76 +6,139 @@ namespace ClosedXML.Tests.Excel.Ranges public class RangeShiftingTests { [Test] - public void CellReferenceRemainAfterColumnDeleted() + public void CellsContentShiftedAfterColumnDeleted() { using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var d4 = ws.Cell("D4"); + SetContent(ws.Cell("D4")); ws.Column("C").Delete(); - Assert.AreSame(d4, ws.Cell("C4")); + AssertContent(ws.Cell("C4"), "D4"); } } [Test] - public void CellReferenceRemainAfterRowDeleted() + public void CellsContentShiftedAfterRowDeleted() { using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var d4 = ws.Cell("D4"); + SetContent(ws.Cell("D4")); ws.Row(3).Delete(); - Assert.AreSame(d4, ws.Cell("D3")); + AssertContent(ws.Cell("D3"), "D4"); } } [Test] - public void CellReferenceRemainAfterColumnInserted() + public void CellsContentShiftedAfterColumnInserted() { using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var d4 = ws.Cell("D4"); + SetContent(ws.Cell("D4")); ws.Column("C").InsertColumnsBefore(1); - Assert.AreSame(d4, ws.Cell("E4")); + AssertContent(ws.Cell("E4"), "D4"); } } [Test] - public void CellReferenceRemainAfterRowInserted() + public void CellsContentShiftedAfterRowInserted() { using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var d4 = ws.Cell("D4"); + SetContent(ws.Cell("D4")); ws.Row(3).InsertRowsAbove(1); - Assert.AreSame(d4, ws.Cell("D5")); + AssertContent(ws.Cell("D5"), "D4"); } } [Test] - public void CellReferenceRemainAfterRangeDeleted() + public void CellsContentShiftAfterRangeDeleted() { using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var d4 = ws.Cell("D4"); - var f8 = ws.Cell("F8"); + SetContent(ws.Cell("D4")); + SetContent(ws.Cell("F8")); ws.Range("B2:C5").Delete(XLShiftDeletedCells.ShiftCellsLeft); ws.Range("E5:F7").Delete(XLShiftDeletedCells.ShiftCellsUp); - Assert.AreSame(d4, ws.Cell("B4")); - Assert.AreSame(f8, ws.Cell("F5")); + AssertContent(ws.Cell("B4"), "D4"); + AssertContent(ws.Cell("F5"), "F8"); } } + + [Theory] + [TestCase("A5:F5")] + [TestCase("A5:F6")] + public void RangesBelowStayMergedAfterRangeDeleted(string deletedRangeAddress) + { + //There is an edge case when a merged range of same size as the deleted range got unmerged (see #2358) + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var deletedRange = ws.Range(deletedRangeAddress); + var rangeHeight = deletedRange.LastRow().RowNumber() - deletedRange.FirstRow().RowNumber() + 1; + var mergedRange = ws.Range( + deletedRange.LastRow().RowNumber() + 1, + deletedRange.FirstColumn().ColumnNumber(), + deletedRange.LastRow().RowNumber() + rangeHeight, + deletedRange.LastColumn().ColumnNumber() + ); + mergedRange.Merge(); + + deletedRange.Delete(XLShiftDeletedCells.ShiftCellsUp); + + Assert.IsTrue(mergedRange.IsMerged()); + Assert.AreEqual(deletedRangeAddress, mergedRange.RangeAddress.ToString()); + } + + [Theory] + [TestCase("A5:A8")] + [TestCase("A5:B8")] + public void RangesToTheRightStayMergedAfterRangeDeleted(string deletedRangeAddress) + { + //There is an edge case when a merged range of same size as the deleted range got unmerged (see #2358) + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var deletedRange = ws.Range(deletedRangeAddress); + var rangeWidth = deletedRange.LastColumn().ColumnNumber() - deletedRange.FirstColumn().ColumnNumber() + 1; + var mergedRange = ws.Range( + deletedRange.FirstRow().RowNumber(), + deletedRange.LastColumn().ColumnNumber() + 1, + deletedRange.LastRow().RowNumber(), + deletedRange.LastColumn().ColumnNumber() + rangeWidth + ); + mergedRange.Merge(); + + deletedRange.Delete(XLShiftDeletedCells.ShiftCellsLeft); + + Assert.IsTrue(mergedRange.IsMerged()); + Assert.AreEqual(deletedRangeAddress, mergedRange.RangeAddress.ToString()); + } + + private void SetContent(IXLCell cell) + { + cell.FormulaA1 = $"\"Formula \" & \"{cell.Address}\""; + cell.Style.Fill.SetBackgroundColor(XLColor.Green); + cell.CreateComment().AddText("Some comment " + cell.Address); + } + + private void AssertContent(IXLCell cell, string originalAddress) + { + Assert.AreEqual($"\"Formula \" & \"{originalAddress}\"", cell.FormulaA1); + Assert.AreEqual(XLColor.Green, cell.Style.Fill.BackgroundColor); + Assert.True(cell.HasComment); + Assert.AreEqual($"Some comment {originalAddress}", cell.GetComment().Text); + } } } diff --git a/ClosedXML.Tests/Excel/Ranges/SortTests.cs b/ClosedXML.Tests/Excel/Ranges/SortTests.cs new file mode 100644 index 000000000..b96e3b8f0 --- /dev/null +++ b/ClosedXML.Tests/Excel/Ranges/SortTests.cs @@ -0,0 +1,143 @@ +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Ranges +{ + [TestFixture] + public class SortTests + { + [Test] + public void Values_are_sorted_by_type_first() + { + // The values in asc order are number, text, logical, error, blanks. + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var values = new XLCellValue[] + { + 1, + "", + "#VALUE!", + "1", + "Text", + "TRUE", + true, + XLError.IncompatibleValue, + Blank.Value, + }; + + // Assign in reverse order + for (var row = 1; row <= values.Length; ++row) + ws.Cell(row, 1).Value = values[^row]; + + ws.Range(1, 1, values.Length, 1).Sort("1 ASC"); + + for (var row = 1; row <= values.Length; ++row) + { + var sortedValue = ws.Cell(row, 1).Value; + Assert.AreEqual(values[row - 1], sortedValue); + } + } + + [TestCase(XLSortOrder.Ascending)] + [TestCase(XLSortOrder.Descending)] + public void Blanks_are_always_last(XLSortOrder sortOrder) + { + // When range contains blank, it is always last, no matter + // if the sort order is ascending or descending + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var values = new XLCellValue[] + { + 1, + Blank.Value, + 2, + }; + for (var row = 1; row <= values.Length; ++row) + ws.Cell(row, 1).Value = values[row - 1]; + + ws.Range(1, 1, values.Length, 1).Sort("1", sortOrder); + + Assert.AreEqual(Blank.Value, ws.Cell(3, 1).Value); + } + + [Test] + public void IgnoreBlanks_set_to_false_treats_blanks_as_empty_strings() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + ws.Cell("A1").Value = "Text"; + ws.Cell("A2").Value = Blank.Value; + ws.Cell("A3").Value = string.Empty; + + ws.Range("A1:A3").Sort(1, ignoreBlanks: false); + + // Since blank is treated as empty string, it is not shuffled to the end. + Assert.AreEqual(Blank.Value, ws.Cell("A1").Value); + Assert.AreEqual(string.Empty, ws.Cell("A2").Value); + Assert.AreEqual("Text", ws.Cell("A3").Value); + } + + [TestCase(true, "a", "A")] + [TestCase(false, "A", "a")] + [Culture("en-US")] + public void MatchCase_flag_determines_if_texts_are_compared_case_sensitive(bool matchCase, string expectedFirst, string expectedSecond) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + // In US locale, lower-case is before upper case. + ws.Cell("A1").Value = "A"; + ws.Cell("A2").Value = "a"; + + ws.Range("A1:A2").Sort(1, matchCase: matchCase); + + Assert.AreEqual(expectedFirst, ws.Cell("A1").Value); + Assert.AreEqual(expectedSecond, ws.Cell("A2").Value); + } + + [Test] + public void Sort_can_use_multiple_columns() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.FirstCell().InsertData(new object[] + { + new [] { 1, 2 }, + new [] { 2, 2 }, + new [] { 1, 1 }, + }); + + ws.Range("A1:B4").Sort("2 ASC, 1 DESC"); + + Assert.AreEqual(1, ws.Cell("A1").Value); + Assert.AreEqual(1, ws.Cell("B1").Value); + Assert.AreEqual(2, ws.Cell("A2").Value); + Assert.AreEqual(2, ws.Cell("B2").Value); + Assert.AreEqual(1, ws.Cell("A3").Value); + Assert.AreEqual(2, ws.Cell("B3").Value); + } + + [Test] + public void Sort_columns_in_range_by_rows() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.FirstCell().InsertData(new object[] + { + new [] { 2, 2, 1 }, + new [] { 1, 2, 1 }, + }); + + // Doesn't have parameters, so it is first rows ASC, second row ASC. + ws.Range("A1:C2").SortLeftToRight(); + + Assert.AreEqual(1, ws.Cell("A1").Value); + Assert.AreEqual(1, ws.Cell("A2").Value); + Assert.AreEqual(2, ws.Cell("B1").Value); + Assert.AreEqual(1, ws.Cell("B2").Value); + Assert.AreEqual(2, ws.Cell("C1").Value); + Assert.AreEqual(2, ws.Cell("C2").Value); + } + } +} diff --git a/ClosedXML.Tests/Excel/Ranges/UsedAndUnusedCellsTests.cs b/ClosedXML.Tests/Excel/Ranges/UsedAndUnusedCellsTests.cs index 2931ce845..3fbdcf14e 100644 --- a/ClosedXML.Tests/Excel/Ranges/UsedAndUnusedCellsTests.cs +++ b/ClosedXML.Tests/Excel/Ranges/UsedAndUnusedCellsTests.cs @@ -318,7 +318,7 @@ public void FirstCellUsedNotHangingOnLargeCFRules() var firstCell = ws.FirstCellUsed(XLCellsUsedOptions.All); - Assert.AreEqual(1, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(0, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); Assert.AreEqual("A1", firstCell.Address.ToString()); } } @@ -333,7 +333,7 @@ public void LastCellUsedNotHangingOnLargeCFRules() var lastCell = ws.LastCellUsed(XLCellsUsedOptions.All); - Assert.AreEqual(1, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(0, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); Assert.AreEqual(XLHelper.LastCell, lastCell.Address.ToString()); } } @@ -348,7 +348,7 @@ public void FirstCellUsedNotHangingOnLargeDVRules() var firstCell = ws.FirstCellUsed(XLCellsUsedOptions.All); - Assert.AreEqual(1, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(0, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); Assert.AreEqual("A1", firstCell.Address.ToString()); } } @@ -363,7 +363,7 @@ public void LastCellUsedNotHangingOnLargeDVRules() var lastCell = ws.LastCellUsed(XLCellsUsedOptions.All); - Assert.AreEqual(1, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(0, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); Assert.AreEqual(XLHelper.LastCell, lastCell.Address.ToString()); } } @@ -378,7 +378,7 @@ public void FirstCellUsedNotHangingOnLargeMergedRanges() var firstCell = ws.FirstCellUsed(XLCellsUsedOptions.All); - Assert.AreEqual(1, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(0, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); Assert.AreEqual("A1", firstCell.Address.ToString()); } } @@ -393,7 +393,7 @@ public void LastCellUsedNotHangingOnLargeMergedRanges() var lastCell = ws.LastCellUsed(XLCellsUsedOptions.All); - Assert.AreEqual(2, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(0, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); Assert.AreEqual(XLHelper.LastCell, lastCell.Address.ToString()); } } diff --git a/ClosedXML.Tests/Excel/Ranges/XLRangeBaseTests.cs b/ClosedXML.Tests/Excel/Ranges/XLRangeBaseTests.cs index 38ccbc53d..ed075b087 100644 --- a/ClosedXML.Tests/Excel/Ranges/XLRangeBaseTests.cs +++ b/ClosedXML.Tests/Excel/Ranges/XLRangeBaseTests.cs @@ -85,10 +85,10 @@ public void SingleCell() var wb = new XLWorkbook(); IXLWorksheet ws = wb.Worksheets.Add("Sheet1"); ws.Cell(1, 1).Value = "Hello World!"; - wb.NamedRanges.Add("SingleCell", "Sheet1!$A$1"); + wb.DefinedNames.Add("SingleCell", "Sheet1!$A$1"); IXLRange range = wb.Range("SingleCell"); Assert.AreEqual(1, range.CellsUsed().Count()); - Assert.AreEqual("Hello World!", range.CellsUsed().Single().GetString()); + Assert.AreEqual("Hello World!", range.CellsUsed().Single().GetText()); } [Test] @@ -102,12 +102,12 @@ public void TableRange() rangeColumn.Cell(3).Value = "Hank"; rangeColumn.Cell(4).Value = "Dagny"; IXLTable table = rangeColumn.CreateTable(); - wb.NamedRanges.Add("FNameColumn", String.Format("{0}[{1}]", table.Name, "FName")); + wb.DefinedNames.Add("FNameColumn", String.Format("{0}[{1}]", table.Name, "FName")); IXLRange namedRange = wb.Range("FNameColumn"); Assert.AreEqual(3, namedRange.Cells().Count()); Assert.IsTrue( - namedRange.CellsUsed().Select(cell => cell.GetString()).SequenceEqual(new[] { "John", "Hank", "Dagny" })); + namedRange.CellsUsed().Select(cell => cell.GetText()).SequenceEqual(new[] { "John", "Hank", "Dagny" })); } [Test] @@ -116,7 +116,7 @@ public void WsNamedCell() var wb = new XLWorkbook(); IXLWorksheet ws = wb.Worksheets.Add("Sheet1"); ws.Cell(1, 1).SetValue("Test").AddToNamed("TestCell", XLScope.Worksheet); - Assert.AreEqual("Test", ws.Cell("TestCell").GetString()); + Assert.AreEqual("Test", ws.Cell("TestCell").GetText()); } [Test] @@ -127,8 +127,8 @@ public void WsNamedCells() ws.Cell(1, 1).SetValue("Test").AddToNamed("TestCell", XLScope.Worksheet); ws.Cell(2, 1).SetValue("B"); IXLCells cells = ws.Cells("TestCell, A2"); - Assert.AreEqual("Test", cells.First().GetString()); - Assert.AreEqual("B", cells.Last().GetString()); + Assert.AreEqual("Test", cells.First().GetText()); + Assert.AreEqual("B", cells.Last().GetText()); } [Test] @@ -164,7 +164,7 @@ public void WsNamedRangesOneString() { var wb = new XLWorkbook(); IXLWorksheet ws = wb.Worksheets.Add("Sheet1"); - ws.NamedRanges.Add("TestRange", "Sheet1!$A$1,Sheet1!$A$3"); + ws.DefinedNames.Add("TestRange", "Sheet1!$A$1,Sheet1!$A$3"); IXLRanges namedRanges = ws.Ranges("TestRange"); Assert.AreEqual("$A$1:$A$1", namedRanges.First().RangeAddress.ToStringFixed()); @@ -513,5 +513,58 @@ public void CanSplitRange(string rangeAddress, string splitBy, bool includeInter Assert.AreEqual(expectedResult, actualAddresses); } + + [Test] + public void Sorting_moves_values_and_fixes_formula_references() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + var range = ws.Cell("A1").InsertData(new object[] + { + ("Price", "Amount", "Sales"), + (7, 5, Blank.Value), + (2, 14, Blank.Value), + (32, 2, Blank.Value), + (6, 9, Blank.Value) + }); + ws.Cell("C2").FormulaA1 = "A2*B2 & \"(Cake)\""; // 35 + ws.Cell("C3").FormulaA1 = "A3*B3 & \"(Pie)\""; // 28 + ws.Cell("C4").FormulaA1 = "A4*B4 & \"(Waffle)\""; // 64 + ws.Cell("C5").FormulaA1 = "A5*B5 & \"(Shortcake)\""; // 54 + + // Sort uses cached values - update them + ws.RecalculateAllFormulas(); + + range.Sort("3 DESC"); + + Assert.AreEqual(32, ws.Cell("A2").Value); + Assert.AreEqual( 6, ws.Cell("A3").Value); + Assert.AreEqual( 7, ws.Cell("A4").Value); + Assert.AreEqual( 2, ws.Cell("A5").Value); + + Assert.AreEqual( 2, ws.Cell("B2").Value); + Assert.AreEqual( 9, ws.Cell("B3").Value); + Assert.AreEqual( 5, ws.Cell("B4").Value); + Assert.AreEqual(14, ws.Cell("B5").Value); + + // Formulas has been moved around and their coordinates fixed after move + Assert.AreEqual("A2*B2 & \"(Waffle)\"", ws.Cell("C2").FormulaA1); + Assert.AreEqual("A3*B3 & \"(Shortcake)\"", ws.Cell("C3").FormulaA1); + Assert.AreEqual("A4*B4 & \"(Cake)\"", ws.Cell("C4").FormulaA1); + Assert.AreEqual("A5*B5 & \"(Pie)\"", ws.Cell("C5").FormulaA1); + } + + [TestCase("PY(4)", "_xlfn._xlws.PY(4)")] + [TestCase("2 + CHISQ.INV(0.6,2)", "2 + _xlfn.CHISQ.INV(0.6,2)")] + [TestCase("2 + _xlfn.CHISQ.INV(0.6,2)", "2 + _xlfn.CHISQ.INV(0.6,2)")] + public void FormulaArrayA1_adds_prefix_to_future_functions(string formula, string expected) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Range("A1:B2").FormulaArrayA1 = formula; + var masterCellFormula = ws.Cell("A1").FormulaA1; + Assert.AreEqual(expected, masterCellFormula); + } } } diff --git a/ClosedXML.Tests/Excel/RichText/XLImmutableRichTextTests.cs b/ClosedXML.Tests/Excel/RichText/XLImmutableRichTextTests.cs new file mode 100644 index 000000000..b83bd1410 --- /dev/null +++ b/ClosedXML.Tests/Excel/RichText/XLImmutableRichTextTests.cs @@ -0,0 +1,47 @@ +using System.Linq; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.RichText +{ + [TestFixture] + public class XLImmutableRichTextTests + { + [Test] + public void Equals_compares_text_runs_phonetic_runs_and_properties() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var richText = (XLRichText)ws.Cell("A1").CreateRichText(); + richText + .AddText("こんにち").SetBold(true) // Hello in hiragana + .AddText("は,").SetBold(false) // object marker + .AddText("世界").SetFontSize(15); // world in kanji + richText.Phonetics + .SetAlignment(XLPhoneticAlignment.Distributed) + .Add(@"konnichi wa", 0, 6); // world in hiragana + + // Assert equal + var immutableRichText = XLImmutableRichText.Create(richText); + var equalImmutableRichText = XLImmutableRichText.Create(richText); + Assert.AreEqual(immutableRichText, equalImmutableRichText); + + // Different font of a first run + richText.ElementAt(0).SetBold(false); + var withDifferentTextRunFont = XLImmutableRichText.Create(richText); + Assert.AreNotEqual(immutableRichText, withDifferentTextRunFont); + richText.ElementAt(0).SetBold(true); + + // Different phonetic properties + richText.Phonetics.SetAlignment(XLPhoneticAlignment.Left); + var withDifferentPhoneticsProps = XLImmutableRichText.Create(richText); + Assert.AreNotEqual(immutableRichText, withDifferentPhoneticsProps); + richText.Phonetics.SetAlignment(XLPhoneticAlignment.Distributed); + + // Different phonetic runs + richText.Phonetics.Add("せかい", 6, 8); + var withDifferentTextPhonetics = XLImmutableRichText.Create(richText); + Assert.AreNotEqual(immutableRichText, withDifferentTextPhonetics); + } + } +} diff --git a/ClosedXML.Tests/Excel/RichText/XLRichStringTests.cs b/ClosedXML.Tests/Excel/RichText/XLRichStringTests.cs index e58725624..1ed164d79 100644 --- a/ClosedXML.Tests/Excel/RichText/XLRichStringTests.cs +++ b/ClosedXML.Tests/Excel/RichText/XLRichStringTests.cs @@ -1,4 +1,4 @@ -using ClosedXML.Excel; +using ClosedXML.Excel; using NUnit.Framework; using System; using System.IO; @@ -19,9 +19,6 @@ public void AccessRichTextTest1() IXLWorksheet ws = new XLWorkbook().Worksheets.Add("Sheet1"); IXLCell cell = ws.Cell(1, 1); cell.CreateRichText().AddText("12"); - cell.DataType = XLDataType.Number; - - Assert.AreEqual(12.0, cell.GetDouble()); IXLRichText richText = cell.GetRichText(); @@ -29,11 +26,7 @@ public void AccessRichTextTest1() richText.AddText("34"); - Assert.AreEqual("1234", cell.GetString()); - - Assert.AreEqual(XLDataType.Number, cell.DataType); - - Assert.AreEqual(1234.0, cell.GetDouble()); + Assert.AreEqual("1234", cell.GetText()); } /// @@ -49,7 +42,7 @@ public void AddTextTest1() string text = "Hello"; richString.AddText(text).SetBold().SetFontColor(XLColor.Red); - Assert.AreEqual(cell.GetString(), text); + Assert.AreEqual(cell.GetText(), text); Assert.AreEqual(cell.GetRichText().First().Bold, true); Assert.AreEqual(cell.GetRichText().First().FontColor, XLColor.Red); @@ -148,11 +141,7 @@ public void HasRichTextTest1() Assert.AreEqual(true, cell.HasRichText); - cell.DataType = XLDataType.Text; - - Assert.AreEqual(true, cell.HasRichText); - - cell.DataType = XLDataType.Number; + cell.Value = "123"; Assert.AreEqual(false, cell.HasRichText); @@ -643,6 +632,26 @@ public void Substring_IndexOutsideRange4() Assert.That(() => richString.Substring(5, 20), Throws.TypeOf()); } + [Test] + public void CopyFrom_DoesCopy() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var original = ws.Cell(1, 1).GetRichText(); + original + .AddText("Hello").SetFontSize(15).SetFontColor(XLColor.Red) + .AddText("World").SetFontSize(7).SetFontColor(XLColor.Blue); + + var otherCell = ws.Cell(1, 2); + var otherRichText = otherCell.GetRichText(); + otherRichText.CopyFrom(original); + + Assert.AreEqual("HelloWorld", otherCell.Value); + Assert.AreEqual(2, otherRichText.Count); + Assert.AreEqual(XLColor.Red, otherRichText.First().FontColor); + Assert.AreEqual(XLColor.Blue, otherRichText.Last().FontColor); + } + /// /// A test for ToString /// @@ -692,7 +701,7 @@ public void CanClearInlinedRichText() [Test] public void CanChangeInlinedRichText() { - void testRichText(IXLRichText richText) + static void AssertRichText(IXLRichText richText) { Assert.IsNotNull(richText); Assert.IsTrue(richText.Any()); @@ -706,7 +715,7 @@ void testRichText(IXLRichText richText) using (var workbook = new XLWorkbook(inputStream)) { var richText = workbook.Worksheets.First().Cell("A1").GetRichText(); - testRichText(richText); + AssertRichText(richText); richText.AddText(" - changed"); workbook.SaveAs(outputStream); } @@ -718,7 +727,7 @@ void testRichText(IXLRichText richText) Assert.IsTrue(cell.HasRichText); var rt = cell.GetRichText(); Assert.AreEqual("Year (range: 3 yrs) - changed", rt.ToString()); - testRichText(rt); + AssertRichText(rt); } } } @@ -758,5 +767,87 @@ public void ClearInlineRichTextWhenRelevant() }, @"Other\InlinedRichText\ChangeRichTextToFormula\output.xlsx"); } } + + [Test] + public void RichTextChangesContentOfItsCell() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var cell = ws.Cell(1, 1); + var richText = cell.GetRichText(); + + Assert.AreEqual(cell.Value, richText.Text); + + richText.AddText("Hello"); + Assert.AreEqual(cell.Value, "Hello"); + + var world = richText.AddText(" World"); + Assert.AreEqual(cell.Value, "Hello World"); + + world.Text = " World!"; + Assert.AreEqual(cell.Value, "Hello World!"); + Assert.AreEqual(cell.GetRichText().Text, "Hello World!"); + + richText.ClearText(); + Assert.AreEqual(cell.Value, string.Empty); + } + + [Test] + public void RemovedRichTextFromCellCantBeChanged() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var cell = ws.Cell(1, 1); + var richText = cell.GetRichText(); + cell.Value = 4; + + Assert.Throws(() => richText.AddText("Hello"), "The rich text isn't a content of a cell."); + } + + [Test] + public void MaintainWhitespaces() + { + const string textWithSpaces = " 元 気 "; + const string phoneticsWithSpace = " げ ん "; + using var ms = new MemoryStream(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet(); + var richTextCell = ws.Cell(1, 1); + var richText = richTextCell.GetRichText(); + richText.AddText(textWithSpaces); + richText.Phonetics.Add(phoneticsWithSpace, 2, 3); + + wb.SaveAs(ms); + } + + ms.Position = 0; + + using (var wb = new XLWorkbook(ms)) + { + var ws = wb.Worksheets.First(); + var richText = ws.Cell(1, 1).GetRichText(); + Assert.AreEqual(textWithSpaces, richText.First().Text); + Assert.AreEqual(phoneticsWithSpace, richText.Phonetics.First().Text); + } + } + + [Test] + public void Preserve_end_of_line_in_xml() + { + // When text run in a rich text contains end of line (regardless if CR, LF or CRLF), + // the written element must be marked with xml:space="preserve". Excel would process + // text differently (trim ect, see XML spec) and that means there would be a data + // loss (trimmed ends of line). Another problem would be phonetic runs. They use indexes + // to the text run, but if text would be trimmed, they might suddenly have out-of-bounds + // values and Excel would try to repair the workbook. + // The source files contains a text run with end of line at the start and end. It also + // contains phonetic run for the kanji in the text that would be out-of-bounds if space + // attribute there. The input is from Excel, output is by ClosedXML. Output must contain + // the space attribute. + TestHelper.LoadSaveAndCompare( + @"Other\RichText\kanji-with-new-line-input.xlsx", + @"Other\RichText\kanji-with-new-line-output.xlsx"); + } } } diff --git a/ClosedXML.Tests/Excel/Rows/RowTests.cs b/ClosedXML.Tests/Excel/Rows/RowTests.cs index ce524d15a..f9d6b3abe 100644 --- a/ClosedXML.Tests/Excel/Rows/RowTests.cs +++ b/ClosedXML.Tests/Excel/Rows/RowTests.cs @@ -61,7 +61,7 @@ public void InsertingRowsAbove1() Assert.AreEqual(XLColor.Red, ws.Row(4).Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, ws.Row(4).Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", ws.Row(3).Cell(2).GetString()); + Assert.AreEqual("X", ws.Row(3).Cell(2).GetText()); Assert.AreEqual(ws.Style.Fill.BackgroundColor, rowIns.Cell(1).Style.Fill.BackgroundColor); Assert.AreEqual(ws.Style.Fill.BackgroundColor, rowIns.Cell(2).Style.Fill.BackgroundColor); @@ -79,7 +79,7 @@ public void InsertingRowsAbove1() Assert.AreEqual(XLColor.Red, row3.Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, row3.Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", row2.Cell(2).GetString()); + Assert.AreEqual("X", row2.Cell(2).GetText()); } [Test] @@ -114,7 +114,7 @@ public void InsertingRowsAbove2() Assert.AreEqual(XLColor.Red, ws.Row(4).Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, ws.Row(4).Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", ws.Row(3).Cell(2).GetString()); + Assert.AreEqual("X", ws.Row(3).Cell(2).GetText()); Assert.AreEqual(XLColor.Red, rowIns.Cell(1).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, rowIns.Cell(2).Style.Fill.BackgroundColor); @@ -132,7 +132,7 @@ public void InsertingRowsAbove2() Assert.AreEqual(XLColor.Red, row3.Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, row3.Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", row2.Cell(2).GetString()); + Assert.AreEqual("X", row2.Cell(2).GetText()); } [Test] @@ -167,7 +167,7 @@ public void InsertingRowsAbove3() Assert.AreEqual(XLColor.Red, ws.Row(4).Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, ws.Row(4).Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", ws.Row(2).Cell(2).GetString()); + Assert.AreEqual("X", ws.Row(2).Cell(2).GetText()); Assert.AreEqual(XLColor.Yellow, rowIns.Cell(1).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Green, rowIns.Cell(2).Style.Fill.BackgroundColor); @@ -185,7 +185,7 @@ public void InsertingRowsAbove3() Assert.AreEqual(XLColor.Red, row3.Cell(2).Style.Fill.BackgroundColor); Assert.AreEqual(XLColor.Red, row3.Cell(3).Style.Fill.BackgroundColor); - Assert.AreEqual("X", row2.Cell(2).GetString()); + Assert.AreEqual("X", row2.Cell(2).GetText()); } [Test] diff --git a/ClosedXML.Tests/Excel/Saving/SavingTests.cs b/ClosedXML.Tests/Excel/Saving/SavingTests.cs index 88f9207b9..3461ea4f9 100644 --- a/ClosedXML.Tests/Excel/Saving/SavingTests.cs +++ b/ClosedXML.Tests/Excel/Saving/SavingTests.cs @@ -5,7 +5,6 @@ using DocumentFormat.OpenXml.Packaging; using NUnit.Framework; using System; -using System.Drawing; using System.Globalization; using System.IO; using System.Linq; @@ -18,7 +17,7 @@ namespace ClosedXML.Tests.Excel.Saving public class SavingTests { [Test] - public void BooleanValueSavesAsLowerCase() + public void BooleanValueSavesAsZeroOrOne() { // When a cell evaluates to a boolean value, the text in the XML has to be true/false (lowercase only) or 0/1 TestHelper.CreateAndCompare(() => @@ -151,7 +150,7 @@ public void NotSaveCachedValueWhenFlagIsFalse() { var ws = book2.Worksheet(1); - Assert.IsNull(ws.Cell("A2").CachedValue); + Assert.AreEqual(Blank.Value, ws.Cell("A2").CachedValue); } } } @@ -427,7 +426,8 @@ public void SaveAsWithUnsupportedExtensionFails() [Test] public void SaveCellValueWithLeadingQuotationMarkCorrectly() { - var quotedFormulaValue = "'=IF(TRUE, 1, 0)"; + var formulaValue = "=IF(TRUE, 1, 0)"; + var quotedFormulaValue = '\'' + formulaValue; using (var ms = new MemoryStream()) { using (var wb = new XLWorkbook()) @@ -436,7 +436,9 @@ public void SaveCellValueWithLeadingQuotationMarkCorrectly() var cell = ws.FirstCell(); cell.SetValue(quotedFormulaValue); Assert.IsFalse(cell.HasFormula); - Assert.AreEqual(quotedFormulaValue, cell.Value); + Assert.AreEqual(formulaValue, cell.Value); + Assert.AreEqual(XLDataType.Text, cell.DataType); + Assert.True(cell.Style.IncludeQuotePrefix); wb.SaveAs(ms); } @@ -448,7 +450,10 @@ public void SaveCellValueWithLeadingQuotationMarkCorrectly() var ws = wb.Worksheets.First(); var cell = ws.FirstCell(); Assert.IsFalse(cell.HasFormula); - Assert.AreEqual(quotedFormulaValue, cell.Value); + Assert.IsFalse(cell.HasFormula); + Assert.AreEqual(formulaValue, cell.Value); + Assert.AreEqual(XLDataType.Text, cell.DataType); + Assert.True(cell.Style.IncludeQuotePrefix); } } } @@ -593,7 +598,8 @@ public void RemoveExistingInlineStringsIfRequired() foreach (var cell in numericCells) { cell.Clear(XLClearOptions.AllFormats); - cell.SetDataType(XLDataType.Number); + Assert.True(cell.Value.TryConvert(out double val, CultureInfo.CurrentCulture)); + cell.Value = val; } foreach (var cell in textCells) @@ -768,5 +774,85 @@ public void CanSaveFileToDefaultDirectory() File.Delete(filename); } } + + [Test] + public void CanAddNewPartsInWorkbookWithDuplicateRelIds() + { + // Both Sheet1 and drawing have same relIds: rId2 + // We can add a new worksheet even when there are parts with same relId + TestHelper.LoadModifyAndCompare( + @"Other\Parts\MultiplePartsHaveNonUniqueRelId-input.xlsx", + wb => wb.AddWorksheet(), + @"Other\Parts\MultiplePartsHaveNonUniqueRelId-output.xlsx"); + } + + [Test] + public void WorksheetWithDrawingCanBeModified() + { + // Issue 2080: Drawing was loading the workbook DOM from the worksheet part and + // the OpenXML SDK was ignoring worksheet changes saved through streaming, but used + // the eager loaded DOM instead. + // Saved file doesn't contain shape because it's not yet supported (#1252) + TestHelper.LoadModifyAndCompare( + @"Other\Parts\WorksheetWithDrawingCanBeModified-input.xlsx", + wb => + { + var ws = wb.Worksheets.Single(); + ws.Cell("A1").Value = "B"; + }, + @"Other\Parts\WorksheetWithDrawingCanBeModified-output.xlsx"); + } + + [Test] + public void CorrectlySaveValidationWithSheetReference() + { + // When validation with sheet reference loading was first implemented, there was a + // disconnect between where those validations were being loaded from and where they + // were being saved to. This led to exceptions being thrown when these validations + // were loaded/saved multiple times, so this test makes sure that the fix for that + // issue continues to work by forcing multiple load/save cycles. + + var filename1 = $"test-{Guid.NewGuid()}.xlsx"; + var filename2 = $"test-{Guid.NewGuid()}.xlsx"; + try + { + var path = TestHelper.GetResourcePath(@"TryToLoad\ValidationWithSheetReference.xlsx"); + using var stream = TestHelper.GetStreamFromResource(path); + + using var originalWorkbook = new XLWorkbook(stream); + Assert.DoesNotThrow(() => originalWorkbook.SaveAs(filename1)); + + using var workbook1 = new XLWorkbook(filename1); + Assert.DoesNotThrow(() => workbook1.SaveAs(filename2)); + + using var workbook2 = new XLWorkbook(filename2); + var ws = workbook2.Worksheet("UI Sheet"); + var B2 = ws.Cell("B2"); + Assert.AreEqual(XLAllowedValues.List, B2.GetDataValidation().AllowedValues); + Assert.AreEqual("$E$1:$E$4", B2.GetDataValidation().Value); + var A2 = ws.Cell("A2"); + Assert.AreEqual(XLAllowedValues.List, A2.GetDataValidation().AllowedValues); + Assert.AreEqual("ValuesSheet!$A$1:$A$4", A2.GetDataValidation().Value); + } + finally + { + File.Delete(filename1); + File.Delete(filename2); + } + } + + [Test] + public void FormControlsArePreserved() + { + // The sheet contains three form controls: two radio buttons and group box. + // Form controls are rather complex and this test ensures that the saved + // file still has VML part (that is the source of truth), drawing part + // (likely a replacement in a decade or two) and three control parts. + // + // Also check that custom text of the form controls is preserved (stored in VML). + TestHelper.LoadSaveAndCompare( + @"Other\Shapes\sheet-with-form-controls-input.xlsx", + @"Other\Shapes\sheet-with-form-controls-output.xlsx"); + } } } diff --git a/ClosedXML.Tests/Excel/Sparklines/SparklineShiftTests.cs b/ClosedXML.Tests/Excel/Sparklines/SparklineShiftTests.cs new file mode 100644 index 000000000..b9f2de456 --- /dev/null +++ b/ClosedXML.Tests/Excel/Sparklines/SparklineShiftTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Sparklines +{ + [TestFixture] + public class SparklineShiftTests + { + [Test] + public void SparklineAreShiftedOnColumnInsert() + { + AssertSparklinePosition("D2", ws => ws.Column("C").InsertColumnsAfter(2), "F2"); + } + + [Test] + public void SparklineAreShiftedOnColumnDelete() + { + AssertSparklinePosition("F2", ws => ws.Column("C").Delete(), "E2"); + } + + [Test] + public void SparklineColumnShiftedOutOfSheetAreRemoved() + { + AssertSparklinePosition("XFD1", ws => ws.Column("C").InsertColumnsAfter(1), null); + } + + [Test] + public void SparklineAreShiftedOnRowInsert() + { + AssertSparklinePosition("B3", ws => ws.Row(2).InsertRowsBelow(3), "B6"); + } + + [Test] + public void SparklineAreShiftedOnRowDelete() + { + AssertSparklinePosition("F8", ws => ws.Rows(4, 6).Delete(), "F5"); + } + + [Test] + public void SparklineRowShiftedOutOfSheetAreRemoved() + { + AssertSparklinePosition($"A{XLHelper.MaxRowNumber}", ws => ws.Row(2).InsertRowsBelow(1), null); + } + + private static void AssertSparklinePosition(string sparklineAddress, Action insertAction, string expectedAddress) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell("B2").Value = 1; + ws.Cell("C2").Value = 2; + var sparklineGroup = ws.SparklineGroups.Add(sparklineAddress, "B2:C2"); + insertAction(ws); + Assert.AreEqual(expectedAddress, sparklineGroup.SingleOrDefault()?.Location.Address.ToString()); + if (expectedAddress is null) + Assert.IsEmpty(sparklineGroup); + } + } +} diff --git a/ClosedXML.Tests/Excel/Styles/AlignmentTests.cs b/ClosedXML.Tests/Excel/Styles/AlignmentTests.cs new file mode 100644 index 000000000..b7eccc66b --- /dev/null +++ b/ClosedXML.Tests/Excel/Styles/AlignmentTests.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.Styles +{ + [TestFixture] + public class AlignmentTests + { + [Test] + public void TextRotationCanBeFromMinus90To90DegreesAnd255ForVerticalLayout() + { + TestHelper.CreateAndCompare(wb => + { + var ws = wb.AddWorksheet(); + ws.ColumnWidth = 10; + ws.Cell(1, 1) + .SetValue("Vertical: 255") + .Style.Alignment.SetTextRotation(255); + + for (var angle = -90; angle <= +90; angle += 10) + { + var column = (angle + 90) / 10 + 2; + var cell = ws.Cell(1, column); + cell.Value = $"Rotation: {angle}"; + cell.Style.Alignment.TextRotation = angle; + } + }, @"Other\Styles\Alignment\TextRotation.xlsx"); + } + + [Test] + public void TextRotationIsConvertedOnLoadToMinus90To90Degrees() + { + TestHelper.LoadAndAssert(wb => + { + var ws = wb.Worksheets.Single(); + Assert.AreEqual(255, ws.Cell(1,1).Style.Alignment.TextRotation); + for (var column = 2; column < 21; ++column) + { + var expectedAngle = (column - 2) * 10 - 90; + Assert.AreEqual(expectedAngle, ws.Cell(1, column).Style.Alignment.TextRotation); + } + }, @"Other\Styles\Alignment\TextRotation.xlsx"); + } + + [TestCase(91)] + [TestCase(-91)] + [TestCase(254)] + [TestCase(256)] + public void TextRotationOutsideBoundsThrowsException(int textRotation) + { + Assert.Throws(() => + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.FirstCell().Style.Alignment.TextRotation = textRotation; + }); + } + } +} diff --git a/ClosedXML.Tests/Excel/Styles/FontTests.cs b/ClosedXML.Tests/Excel/Styles/FontTests.cs index 1a7689eb6..fbfedd72b 100644 --- a/ClosedXML.Tests/Excel/Styles/FontTests.cs +++ b/ClosedXML.Tests/Excel/Styles/FontTests.cs @@ -5,12 +5,14 @@ namespace ClosedXML.Tests.Excel.Styles { public class FontTests { + private readonly XLFontKey _defaultKey = XLFontValue.Default.Key; + [Test] public void XLFontKey_GetHashCode_IsCaseInsensitive() { - var fontKey1 = new XLFontKey { FontName = "Arial" }; - var fontKey2 = new XLFontKey { FontName = "Times New Roman" }; - var fontKey3 = new XLFontKey { FontName = "TIMES NEW ROMAN" }; + var fontKey1 = _defaultKey with { FontName = "Arial" }; + var fontKey2 = _defaultKey with { FontName = "Times New Roman" }; + var fontKey3 = _defaultKey with { FontName = "TIMES NEW ROMAN" }; Assert.AreNotEqual(fontKey1.GetHashCode(), fontKey2.GetHashCode()); Assert.AreEqual(fontKey2.GetHashCode(), fontKey3.GetHashCode()); @@ -19,9 +21,9 @@ public void XLFontKey_GetHashCode_IsCaseInsensitive() [Test] public void XLFontKey_Equals_IsCaseInsensitive() { - var fontKey1 = new XLFontKey { FontName = "Arial" }; - var fontKey2 = new XLFontKey { FontName = "Times New Roman" }; - var fontKey3 = new XLFontKey { FontName = "TIMES NEW ROMAN" }; + var fontKey1 = _defaultKey with { FontName = "Arial" }; + var fontKey2 = _defaultKey with { FontName = "Times New Roman" }; + var fontKey3 = _defaultKey with { FontName = "TIMES NEW ROMAN" }; Assert.IsFalse(fontKey1.Equals(fontKey2)); Assert.IsTrue(fontKey2.Equals(fontKey3)); diff --git a/ClosedXML.Tests/Excel/Styles/NumberFormatTests.cs b/ClosedXML.Tests/Excel/Styles/NumberFormatTests.cs index 2d4e44346..5836b03e5 100644 --- a/ClosedXML.Tests/Excel/Styles/NumberFormatTests.cs +++ b/ClosedXML.Tests/Excel/Styles/NumberFormatTests.cs @@ -2,6 +2,7 @@ using NUnit.Framework; using System; using System.Data; +using System.Globalization; using System.IO; using System.Linq; @@ -41,8 +42,7 @@ public void TestExcelNumberFormats() { var ws = wb.AddWorksheet("Sheet1"); var c = ws.FirstCell() - .SetValue(41573.875) - .SetDataType(XLDataType.DateTime); + .SetValue((41573.875)); c.Style.NumberFormat.SetFormat("m/d/yy\\ h:mm;@"); @@ -50,6 +50,21 @@ public void TestExcelNumberFormats() } } + [Test] + [SetCulture("en-US")] + public void Cell_value_is_formatted_by_current_culture_unless_specified_otherwise() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var cell = ws.Cell("A1").SetValue(10000.5); + + var currentCultureFormat = cell.GetFormattedString(); + Assert.AreEqual("10000.5", currentCultureFormat); + + var czechCultureFormat = cell.GetFormattedString(CultureInfo.GetCultureInfo("cs-CZ")); + Assert.AreEqual("10000,5", czechCultureFormat); + } + [Test] public void ReadAndWriteColumnNumberFormat() { @@ -76,8 +91,8 @@ public void ReadAndWriteColumnNumberFormat() [Test] public void XLNumberFormatKey_GetHashCode_IsCaseSensitive() { - var numberFormatKey1 = new XLNumberFormatKey { Format = "MM" }; - var numberFormatKey2 = new XLNumberFormatKey { Format = "mm" }; + var numberFormatKey1 = XLNumberFormatKey.ForFormat("MM"); + var numberFormatKey2 = XLNumberFormatKey.ForFormat("mm"); Assert.AreNotEqual(numberFormatKey1.GetHashCode(), numberFormatKey2.GetHashCode()); } @@ -85,10 +100,32 @@ public void XLNumberFormatKey_GetHashCode_IsCaseSensitive() [Test] public void XLNumberFormatKey_Equals_IsCaseSensitive() { - var numberFormatKey1 = new XLNumberFormatKey { Format = "MM" }; - var numberFormatKey2 = new XLNumberFormatKey { Format = "mm" }; + var numberFormatKey1 = XLNumberFormatKey.ForFormat("MM"); + var numberFormatKey2 = XLNumberFormatKey.ForFormat("mm"); Assert.IsFalse(numberFormatKey1.Equals(numberFormatKey2)); } + + [Test] + public void AddCustomNumberFormatsToFileWithNonSequentialNumberFormatIds() + { + using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Other\NumberFormats\NonSequentialNumberFormatsIds-Input.xlsx"))) + { + TestHelper.CreateAndCompare(() => + { + var wb = new XLWorkbook(stream); + + var ws = wb.Worksheet("Sheet1"); + + var format = "\"P\" #,##0.00; \"N\" #,##0.00;0;@"; + ws.Cell(5, 1).Value = 1.2; + ws.Cell(5, 1).Style.NumberFormat.Format = format; + ws.Cell(5, 2).Value = -1.2; + ws.Cell(5, 2).Style.NumberFormat.Format = format; + + return wb; + }, @"Other\NumberFormats\NonSequentialNumberFormatsIds-Output.xlsx"); + } + } } } diff --git a/ClosedXML.Tests/Excel/Styles/StyleTests.cs b/ClosedXML.Tests/Excel/Styles/StyleTests.cs index 69092f9dd..eb2ed406a 100644 --- a/ClosedXML.Tests/Excel/Styles/StyleTests.cs +++ b/ClosedXML.Tests/Excel/Styles/StyleTests.cs @@ -22,6 +22,7 @@ public void EmptyCellWithQuotePrefixNotTreatedAsEmpty() var cell = ws.FirstCell().CellRight() as XLCell; Assert.IsTrue(cell.IsEmpty()); + cell.Value = String.Empty; cell.Style.IncludeQuotePrefix = true; Assert.IsTrue(cell.IsEmpty()); @@ -35,8 +36,8 @@ public void EmptyCellWithQuotePrefixNotTreatedAsEmpty() using (var wb = new XLWorkbook(ms)) { var ws = wb.Worksheets.First(); - var cell = ws.FirstCell().CellRight() as XLCell; - Assert.AreEqual(1, cell.SharedStringId); + var cell = (XLCell)ws.Cell("B1"); + Assert.AreEqual(1, cell.MemorySstId); Assert.IsTrue(cell.IsEmpty()); Assert.IsFalse(cell.IsEmpty(XLCellsUsedOptions.All)); @@ -130,6 +131,46 @@ public void RowColors() }, @"Other\StyleReferenceFiles\RowColors\output.xlsx"); } + [Test] + public void Style_for_cells_without_explicitly_set_style_uses_combination_of_row_and_columns_styles() + { + // If a style for a cell hasn't been explicitly set (e.g. though `cell.Style.Font + // .SetBold(true)`), it is not yet instantiated to save memory and the actual value + // is determined by the column style and row style. Generally speaking, the axis that + // had its value set explicitly has a precedence, but because we can't detect that with + // current structure, use difference from worksheet as an indication of explicitly set + // value instead. + // If row and column style components differ, the cells at the cross are pinged, thus test + // sets different components for each axis. + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + var rowStyle = ws.Row(4).Style + .Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center) + .Fill.SetBackgroundColor(XLColor.Blue) + .SetIncludeQuotePrefix() + .Protection.SetLocked(true); + + var colStyle = ws.Column(2).Style + .Border.SetBottomBorder(XLBorderStyleValues.Double) + .Font.SetFontName("Arial") + .NumberFormat.SetNumberFormatId((int)XLPredefinedFormat.Number.Precision2); + + var crossCellStyle = ws.Cell(4, 2).Style; + Assert.AreEqual(XLAlignmentHorizontalValues.Center, crossCellStyle.Alignment.Horizontal); + Assert.AreEqual(XLBorderStyleValues.Double, crossCellStyle.Border.BottomBorder); + Assert.AreEqual(XLColor.Blue, crossCellStyle.Fill.BackgroundColor); + Assert.AreEqual(true, crossCellStyle.IncludeQuotePrefix); + Assert.AreEqual((int)XLPredefinedFormat.Number.Precision2, crossCellStyle.NumberFormat.NumberFormatId); + Assert.AreEqual(true, crossCellStyle.Protection.Locked); + + var rowCellStyle = ws.Cell(4, 3).Style; + Assert.AreEqual(rowStyle, rowCellStyle); + + var colCellStyle = ws.Cell(5, 2).Style; + Assert.AreEqual(colStyle, colCellStyle); + } + private static IEnumerable StylizedEntities { get diff --git a/ClosedXML.Tests/Excel/Styles/XLColorTests.cs b/ClosedXML.Tests/Excel/Styles/XLColorTests.cs new file mode 100644 index 000000000..dae7b3416 --- /dev/null +++ b/ClosedXML.Tests/Excel/Styles/XLColorTests.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Drawing; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel; + +public class XLColorTests +{ + public static IEnumerable VmlColors + { + get + { + // Hexadecimal color + yield return new object[] { "#F0E0D0", Color.FromArgb(0xF0, 0xE0, 0xD0) }; + + // Named color + yield return new object[] { "red", Color.Red }; + + // Palette color + yield return new object[] { "Menu [30]", Color.FromArgb(0xF0, 0xF0, 0xF0) }; + yield return new object[] { "Menu", Color.FromArgb(0xF0, 0xF0, 0xF0) }; + + // Unknown/malformed color + yield return new object[] { "#NFOBACKGROUND", Color.FromName("#NFOBACKGROUND") }; + } + } + + [TestCaseSource(nameof(VmlColors))] + public void FromVmlColor_converts_hexadecimal_colors(string colorText, Color expectedColor) + { + var color = XLColor.FromVmlColor(colorText); + + Assert.That(color, Is.EqualTo(XLColor.FromColor(expectedColor))); + } +} diff --git a/ClosedXML.Tests/Excel/Styles/XLFillTests.cs b/ClosedXML.Tests/Excel/Styles/XLFillTests.cs index cbf7d39fc..6a4cf0e2d 100644 --- a/ClosedXML.Tests/Excel/Styles/XLFillTests.cs +++ b/ClosedXML.Tests/Excel/Styles/XLFillTests.cs @@ -113,5 +113,25 @@ public void LoadAndSaveTransparentBackgroundFill() }, @"Other\StyleReferenceFiles\TransparentBackgroundFill\TransparentBackgroundFill.xlsx"); } } + + [Test] + public void ReservedFills_ReplaceWithPredefinedValues() + { + // If attribute or whole predefined fill is missing from the file, save predefined values + TestHelper.LoadSaveAndCompare( + @"Other\StyleReferenceFiles\FillAtReservedPosition-SavePredefinedValues-Input.xlsx", + @"Other\StyleReferenceFiles\FillAtReservedPosition-SavePredefinedValues-Output.xlsx"); + } + + [Test] + public void ReservedFills_MoveFillsFromReservedPositions() + { + // If the input doesn't have expected fill values at the reserved position s0 and 1 (can only happen + // for non-excel sources, excel always has correct values), put expected fill at 0 and 1, but save original + // fills to different positions if they are used. + TestHelper.LoadSaveAndCompare( + @"Other\StyleReferenceFiles\FillAtReservedPosition-MoveFill-Input.xlsx", + @"Other\StyleReferenceFiles\FillAtReservedPosition-MoveFill-Output.xlsx"); + } } } diff --git a/ClosedXML.Tests/Excel/Tables/AddingAndReplacingTableDataTests.cs b/ClosedXML.Tests/Excel/Tables/AddingAndReplacingTableDataTests.cs index c5c94e408..3212b726c 100644 --- a/ClosedXML.Tests/Excel/Tables/AddingAndReplacingTableDataTests.cs +++ b/ClosedXML.Tests/Excel/Tables/AddingAndReplacingTableDataTests.cs @@ -568,6 +568,42 @@ public void CanReplaceWithTypedEnumerableAndPropagateExtraColumns() } } + [TestCase("ListOfPeople[Age]")] // Defined name formula without a A1 reference + [TestCase("ListOfPeople!A1")] // Defined name formula with an A1 reference + public void CanReplaceTableDataWhenWorksheetHasDefinedNames(string nameFormula) + { + // When table data are replaced, the size of a table is modified. That + // means rows below it are shifted up/down and defined names should be + // adjusted. + // TODO: add assert for name shift when formulas are properly shifted. Originally, it threw even on defined name with A1 reference + using (var ms = new MemoryStream()) + { + using (var wb = PrepareWorkbook()) + { + var ws = wb.Worksheets.First(); + + ws.DefinedNames.Add("ListOfPeople_Age", nameFormula); + + var table = ws.Tables.First(); + + IEnumerable personEnumerable = NewData; + var replacedRange = table.ReplaceData(personEnumerable); + + Assert.AreEqual("B3:G4", replacedRange.RangeAddress.ToString()); + + wb.SaveAs(ms); + } + + using (var wb = new XLWorkbook(ms)) + { + var table = wb.Worksheets.SelectMany(ws => ws.Tables).First(); + + Assert.AreEqual(2, table.DataRange.RowCount()); + Assert.AreEqual(6, table.DataRange.ColumnCount()); + } + } + } + [Test] public void CanAppendWithUntypedEnumerableAndPropagateExtraColumns() { diff --git a/ClosedXML.Tests/Excel/Tables/TablesTests.cs b/ClosedXML.Tests/Excel/Tables/TablesTests.cs index 2c762fb98..dfb305dc5 100644 --- a/ClosedXML.Tests/Excel/Tables/TablesTests.cs +++ b/ClosedXML.Tests/Excel/Tables/TablesTests.cs @@ -87,8 +87,8 @@ public void CreatingATableFromHeadersPushCellsBelow() .CellBelow().SetValue("X"); ws.Range("A1").CreateTable(); - Assert.AreEqual(String.Empty, ws.Cell("A2").GetString()); - Assert.AreEqual("X", ws.Cell("A3").GetString()); + Assert.AreEqual(Blank.Value, ws.Cell("A2").Value); + Assert.AreEqual("X", ws.Cell("A3").GetText()); } } @@ -105,7 +105,7 @@ public void Inserting_Column_Sets_Header() IXLTable table = ws.RangeUsed().CreateTable(); table.InsertColumnsAfter(1); - Assert.AreEqual("Column2", table.HeadersRow().LastCell().GetString()); + Assert.AreEqual("Column2", table.HeadersRow().LastCell().GetText()); } } @@ -382,15 +382,15 @@ public void TableShowHeader() Assert.IsTrue(ws.Cell(1, 1).IsEmpty(XLCellsUsedOptions.All)); Assert.AreEqual(null, table.HeadersRow()); - Assert.AreEqual("A", table.DataRange.FirstRow().Field("Categories").GetString()); - Assert.AreEqual("C", table.DataRange.LastRow().Field("Categories").GetString()); - Assert.AreEqual("A", table.DataRange.FirstCell().GetString()); - Assert.AreEqual("C", table.DataRange.LastCell().GetString()); + Assert.AreEqual("A", table.DataRange.FirstRow().Field("Categories").GetText()); + Assert.AreEqual("C", table.DataRange.LastRow().Field("Categories").GetText()); + Assert.AreEqual("A", table.DataRange.FirstCell().GetText()); + Assert.AreEqual("C", table.DataRange.LastCell().GetText()); table.SetShowHeaderRow(); IXLRangeRow headerRow = table.HeadersRow(); Assert.AreNotEqual(null, headerRow); - Assert.AreEqual("Categories", headerRow.Cell(1).GetString()); + Assert.AreEqual("Categories", headerRow.Cell(1).GetText()); table.SetShowHeaderRow(false); @@ -398,16 +398,29 @@ public void TableShowHeader() table.SetShowHeaderRow(); - Assert.AreEqual("x", ws.FirstCell().GetString()); - Assert.AreEqual("Categories", ws.Cell("A2").GetString()); + Assert.AreEqual("x", ws.FirstCell().GetText()); + Assert.AreEqual("Categories", ws.Cell("A2").GetText()); Assert.AreNotEqual(null, headerRow); - Assert.AreEqual("A", table.DataRange.FirstRow().Field("Categories").GetString()); - Assert.AreEqual("C", table.DataRange.LastRow().Field("Categories").GetString()); - Assert.AreEqual("A", table.DataRange.FirstCell().GetString()); - Assert.AreEqual("C", table.DataRange.LastCell().GetString()); + Assert.AreEqual("A", table.DataRange.FirstRow().Field("Categories").GetText()); + Assert.AreEqual("C", table.DataRange.LastRow().Field("Categories").GetText()); + Assert.AreEqual("A", table.DataRange.FirstCell().GetText()); + Assert.AreEqual("C", table.DataRange.LastCell().GetText()); } } + [TestCase("Amount")] + [TestCase("AMOUNT")] + [TestCase("amount")] + public void FieldNames_of_XLTable_are_case_insensitive(string fieldName) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var table = ws.Cell("A1").InsertTable(new[] { new { Amount = 1 } }); + + var expectedField = table.Field(0); + Assert.AreSame(expectedField, table.Field(fieldName)); + } + [Test] public void ChangeFieldName() { @@ -425,7 +438,7 @@ public void ChangeFieldName() tbl.Field(tbl.Fields.Last().Index).Name = "LastName"; var nameAfter = tbl.Field(tbl.Fields.Last().Index).Name; - var cellValue = ws.Cell("B1").GetString(); + var cellValue = ws.Cell("B1").GetText(); Assert.AreEqual("LName", nameBefore); Assert.AreEqual("LastName", nameAfter); @@ -437,7 +450,7 @@ public void ChangeFieldName() Assert.AreEqual("LastNameChanged", nameAfter); tbl.SetShowHeaderRow(true); - nameAfter = tbl.Cell("B1").Value.ToString(); + nameAfter = (String)tbl.Cell("B1").Value; Assert.AreEqual("LastNameChanged", nameAfter); var field = tbl.Field("LastNameChanged"); @@ -566,6 +579,30 @@ public void TableNameCannotBeValidCellName() } } + [Test] + public void TableNameSetWhenAddingWorksheetWithDataTable() + { + var dt = new DataTable("sheet1"); + dt.Columns.Add("Patient", typeof(string)); + dt.Rows.Add("David"); + + using (var wb = new XLWorkbook()) + { + // Generated table name is used and should not be an issue + Assert.DoesNotThrow(() => wb.AddWorksheet(dt, "t1")); + } + + using (var wb = new XLWorkbook()) + { + // Should pass because t1 is a valid sheet name, and is not used for the tableName + Assert.DoesNotThrow(() => wb.AddWorksheet(dt, "t1", "table1")); + + Assert.AreEqual(1, wb.Worksheets.Count); + Assert.AreEqual(1, wb.Worksheet(1).Tables.Count()); + } + + } + [Test] public void CanDeleteTableField() { @@ -640,6 +677,30 @@ public void OverlappingTablesThrowsException() } } + [Test] + public void OverwritingTableHeaders() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var table = ws.Cell("A1").InsertTable(new object[] + { + ("Header 1", "Header 2"), + (1, 2) + }, true); + + // Overwrite the headers of the table with non-string values + ws.Cell("A1").InsertData(new object[] + { + (XLError.IncompatibleValue, 7) + }); + + // The non-string data inserted to headers were converted to strings and used as a field names. + Assert.AreEqual("#VALUE!", table.Field(0).Name); + Assert.AreEqual("#VALUE!", ws.Cell("A1").Value); + Assert.AreEqual("7", table.Field(1).Name); + Assert.AreEqual("7", ws.Cell("B1").Value); + } + [Test] public void OverwritingTableTotalsRow() { @@ -1094,9 +1155,9 @@ public void CopyTableWithoutData() Assert.AreEqual("Custom column 1", ws2.Cell("A1").Value); Assert.AreEqual("Custom column 2", ws2.Cell("B1").Value); Assert.AreEqual("Custom column 3", ws2.Cell("C1").Value); - Assert.AreEqual("", ws2.Cell("A2").Value); - Assert.AreEqual("", ws2.Cell("B2").Value); - Assert.AreEqual("", ws2.Cell("C2").Value); + Assert.AreEqual(Blank.Value, ws2.Cell("A2").Value); + Assert.AreEqual(Blank.Value, ws2.Cell("B2").Value); + Assert.AreEqual(Blank.Value, ws2.Cell("C2").Value); } [Test] @@ -1130,6 +1191,27 @@ public void SavingTableWithNullDataRangeThrowsException() } } + [Test] + public void Save_totals_row_label_cell_with_sst_id_matching_the_label() + { + // Issue #2602 test. The totals row wasn't saved with compact SST ID from file, but with a memory SST that has holes. + TestHelper.CreateAndCompare(wb => + { + var ws = wb.AddWorksheet(); + ws.Cell("A1").Value = "Dummy1"; // First inserted text - index=0, reference count = 1 + ws.Cell("A2").Value = "Dummy2"; // Second inserted text - index=1, reference count = 1 + ws.Cell("A3").Value = "Dummy3"; // Third inserted text - index=2, reference count = 1 + ws.Cell("A4").Value = "Text"; // Fourth inserted text - index=3, reference count = 1 + var table = ws.Cell("A5").InsertTable(new [] { ("Text", 17) }); // Also inserts header Item1 and Item2. + table.ShowTotalsRow = true; + table.Field(0).TotalsRowLabel = "Text"; // reference count = 3 + + // Remove "Dummy*" text. That way, the "Text", "Item1" and "Item2" will be in index 0..2 that were occupied by Dummy* + // Ensure that cell in total row label A7 references "Text" SST ID + ws.Range("A1:A3").Value = Blank.Value; + }, @"Other\Tables\TotalRowSstId.xlsx"); + } + [Test] public void CanCreateTableWithWhiteSpaceColumnHeaders() { diff --git a/ClosedXML.Tests/Excel/Worksheets/XLWorksheetTests.cs b/ClosedXML.Tests/Excel/Worksheets/XLWorksheetTests.cs index 9ca2c08b6..911bd2581 100644 --- a/ClosedXML.Tests/Excel/Worksheets/XLWorksheetTests.cs +++ b/ClosedXML.Tests/Excel/Worksheets/XLWorksheetTests.cs @@ -2,7 +2,6 @@ using ClosedXML.Excel.Drawings; using NUnit.Framework; using System; -using System.Drawing; using System.IO; using System.Linq; using System.Reflection; @@ -132,6 +131,23 @@ public void InsertingSheets4() Assert.AreEqual("Sheet7", wb.Worksheet(1).Name); } + [Test] + public void SheetIdIsNotReused() + { + using var wb = new XLWorkbook(); + var ws1 = (XLWorksheet)wb.AddWorksheet(); + var ws2 = (XLWorksheet)wb.AddWorksheet(); + var ws3 = (XLWorksheet)wb.AddWorksheet(); + + Assert.AreEqual(1, ws1.SheetId); + Assert.AreEqual(2, ws2.SheetId); + Assert.AreEqual(3, ws3.SheetId); + + ws3.Delete(); + var ws4 = (XLWorksheet)wb.AddWorksheet(); + Assert.AreEqual(4, ws4.SheetId); + } + [Test] public void AddingDuplicateSheetNameThrowsException() { @@ -478,7 +494,7 @@ public void CopyWorksheetPreservesMergedCells() } [Test] - public void CopyWorksheetAcrossWorkbooksPreservesNamedRanges() + public void Copy_sheet_across_workbooks_preserves_defined_names() { using (var wb1 = new XLWorkbook()) using (var wb2 = new XLWorkbook()) @@ -490,11 +506,11 @@ public void CopyWorksheetAcrossWorkbooksPreservesNamedRanges() var ws2 = ws1.CopyTo(wb2, "Copy"); - Assert.AreEqual(ws1.NamedRanges.Count(), ws2.NamedRanges.Count()); - for (int i = 0; i < ws1.NamedRanges.Count(); i++) + Assert.AreEqual(ws1.DefinedNames.Count(), ws2.DefinedNames.Count()); + for (int i = 0; i < ws1.DefinedNames.Count(); i++) { - var nr1 = ws1.NamedRanges.ElementAt(i); - var nr2 = ws2.NamedRanges.ElementAt(i); + var nr1 = ws1.DefinedNames.ElementAt(i); + var nr2 = ws2.DefinedNames.ElementAt(i); Assert.AreEqual(nr1.Ranges.ToString(), nr2.Ranges.ToString()); Assert.AreEqual(nr1.Scope, nr2.Scope); Assert.AreEqual(nr1.Name, nr2.Name); @@ -505,7 +521,7 @@ public void CopyWorksheetAcrossWorkbooksPreservesNamedRanges() } [Test] - public void CopyWorksheeInsideWorkbookMakesNamedRangesLocal() + public void Copying_sheet_inside_workbook_makes_copies_of_sheet_scoped_defined_names() { using (var wb1 = new XLWorkbook()) { @@ -516,11 +532,11 @@ public void CopyWorksheeInsideWorkbookMakesNamedRangesLocal() var ws2 = ws1.CopyTo("Copy"); - Assert.AreEqual(ws1.NamedRanges.Count(), ws2.NamedRanges.Count()); - for (int i = 0; i < ws1.NamedRanges.Count(); i++) + Assert.AreEqual(ws1.DefinedNames.Count(), ws2.DefinedNames.Count()); + for (int i = 0; i < ws1.DefinedNames.Count(); i++) { - var nr1 = ws1.NamedRanges.ElementAt(i); - var nr2 = ws2.NamedRanges.ElementAt(i); + var nr1 = ws1.DefinedNames.ElementAt(i); + var nr2 = ws2.DefinedNames.ElementAt(i); Assert.AreEqual(XLScope.Worksheet, nr2.Scope); @@ -714,7 +730,7 @@ public void CopyWorksheetPreservesPictures() { using (var ms = new MemoryStream()) using (var imageStream = Assembly.GetAssembly(typeof(ClosedXML.Examples.BasicTable)) - .GetManifestResourceStream("ClosedXML.Examples.Resources.SampleImage.jpg")) + .GetManifestResourceStream("ClosedXML.Examples.Resources.SampleImage.jpg")) using (var wb1 = new XLWorkbook()) { var ws1 = wb1.Worksheets.Add("Original"); @@ -802,7 +818,6 @@ void AssertPivotTablesAreEqual(IXLWorksheet ws1, IXLWorksheet ws2) var copy = ws2.PivotTables.ElementAt(i).CastTo(); Assert.AreEqual(ws2, copy.Worksheet); - Assert.AreNotEqual(original.Guid, copy.Guid); Assert.IsTrue(comparer.Equals(original, copy)); } @@ -981,20 +996,21 @@ public void CopyWorksheetChangesAbsoluteReferencesInFormulae() } } - [Test, Ignore("Muted until #836 is fixed")] - public void RenameWorksheetChangesAbsoluteReferencesInFormulae() + [Test] + public void Rename_sheets_changes_sheet_references_in_formulas() { - using (var wb1 = new XLWorkbook()) - { - var ws1 = wb1.Worksheets.Add("Original"); + using var wb = new XLWorkbook(); + var ws = wb.Worksheets.Add("Original"); - ws1.Cell("A1").FormulaA1 = "10*10"; - ws1.Cell("A2").FormulaA1 = "Original!A1 * 3"; + ws.Cell("A1").FormulaA1 = "10*10"; + ws.Cell("A2").FormulaA1 = "Original!A1 * 3"; + _ = ws.Cell("A2").Value; - ws1.Name = "Renamed"; + ws.Name = "Renamed"; - Assert.AreEqual("Renamed!A1 * 3", ws1.Cell("A2").FormulaA1); - } + Assert.AreEqual("Renamed!A1 * 3", ws.Cell("A2").FormulaA1); + Assert.True(ws.Cell("A2").NeedsRecalculation); + Assert.AreEqual(300, ws.Cell("A2").Value); } [Test] @@ -1052,13 +1068,13 @@ public void InsertColumnsDoesNotIncreaseCellsCount() using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var cell1 = ws.Cell("A1"); - var cell2 = ws.Cell("AAA50"); - var originalCount = (ws as XLWorksheet).Internals.CellsCollection.Count; + ws.Cell("A1").SetValue(1); + ws.Cell("AAA50").SetValue(1); + var originalCount = ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count(); ws.Column(1).InsertColumnsBefore(1); - Assert.AreEqual(originalCount, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(originalCount, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); } } @@ -1068,13 +1084,13 @@ public void InsertRowsDoesNotIncreaseCellsCount() using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var cell1 = ws.Cell("A1"); - var cell2 = ws.Cell("AAA500"); - var originalCount = (ws as XLWorksheet).Internals.CellsCollection.Count; + ws.Cell("A1").SetValue(1); + ws.Cell("AAA500").SetValue(1); + var originalCount = ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count(); ws.Row(1).InsertRowsAbove(1); - Assert.AreEqual(originalCount, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(originalCount, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); } } @@ -1084,13 +1100,13 @@ public void InsertCellsBeforeDoesNotIncreaseCellsCount() using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var cell1 = ws.Cell("A1"); - var cell2 = ws.Cell("AAA50"); - var originalCount = (ws as XLWorksheet).Internals.CellsCollection.Count; + var a1 = ws.Cell("A1").SetValue(1); + ws.Cell("AAA50").SetValue(1); + var originalCount = ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count(); - cell1.InsertCellsBefore(1); + a1.InsertCellsBefore(1); - Assert.AreEqual(originalCount, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(originalCount, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); } } @@ -1100,13 +1116,13 @@ public void InsertCellsAboveDoesNotIncreaseCellsCount() using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var cell1 = ws.Cell("A1"); - var cell2 = ws.Cell("AAA500"); - var originalCount = (ws as XLWorksheet).Internals.CellsCollection.Count; + var a1 = ws.Cell("A1").SetValue(1); + ws.Cell("AAA500").SetValue(1); + var originalCount = ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count(); - cell1.InsertCellsAbove(1); + a1.InsertCellsAbove(1); - Assert.AreEqual(originalCount, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(originalCount, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); } } @@ -1116,14 +1132,15 @@ public void CellsShiftedTooFarRightArePurged() using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var cell1 = ws.Cell("A1"); - var cell2 = ws.Cell(1, XLHelper.MaxColumnNumber); - var cell3 = ws.Cell(2, XLHelper.MaxColumnNumber); + var a1 = ws.Cell("A1").SetValue(1); + ws.Cell(1, XLHelper.MaxColumnNumber).SetValue(1); + ws.Cell(2, XLHelper.MaxColumnNumber).SetValue(1); + + a1.InsertCellsBefore(1); - cell1.InsertCellsBefore(1); - Assert.AreEqual(2, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(2, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); ws.Column(1).InsertColumnsBefore(1); - Assert.AreEqual(1, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(1, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); } } @@ -1133,14 +1150,15 @@ public void CellsShiftedTooFarDownArePurged() using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var cell1 = ws.Cell("A1"); - var cell2 = ws.Cell(XLHelper.MaxRowNumber, 1); - var cell3 = ws.Cell(XLHelper.MaxRowNumber, 2); + var a1 = ws.Cell("A1").SetValue(1); + ws.Cell(XLHelper.MaxRowNumber, 1).SetValue(1); + ws.Cell(XLHelper.MaxRowNumber, 2).SetValue(1); - cell1.InsertCellsAbove(1); - Assert.AreEqual(2, (ws as XLWorksheet).Internals.CellsCollection.Count); + a1.InsertCellsAbove(1); + + Assert.AreEqual(2, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); ws.Row(1).InsertRowsAbove(1); - Assert.AreEqual(1, (ws as XLWorksheet).Internals.CellsCollection.Count); + Assert.AreEqual(1, ((XLWorksheet)ws).Internals.CellsCollection.GetCells().Count()); } } @@ -1150,12 +1168,12 @@ public void MaxColumnUsedUpdatedWhenColumnDeleted() using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var cell1 = ws.Cell("C1"); - var cell2 = ws.Cell(1, XLHelper.MaxColumnNumber); + ws.Cell("C1").SetValue(1); + ws.Cell(1, XLHelper.MaxColumnNumber).SetValue(1); ws.Column(XLHelper.MaxColumnNumber).Delete(); - Assert.AreEqual(3, (ws as XLWorksheet).Internals.CellsCollection.MaxColumnUsed); + Assert.AreEqual(3, ((XLWorksheet)ws).Internals.CellsCollection.MaxColumnUsed); } } @@ -1165,12 +1183,12 @@ public void MaxRowUsedUpdatedWhenRowDeleted() using (var wb = new XLWorkbook()) { var ws = wb.AddWorksheet(); - var cell1 = ws.Cell("A3"); - var cell2 = ws.Cell(XLHelper.MaxRowNumber, 1); + ws.Cell("A3").SetValue(1); + ws.Cell(XLHelper.MaxRowNumber, 1).SetValue(1); ws.Row(XLHelper.MaxRowNumber).Delete(); - Assert.AreEqual(3, (ws as XLWorksheet).Internals.CellsCollection.MaxRowUsed); + Assert.AreEqual(3, ((XLWorksheet)ws).Internals.CellsCollection.MaxRowUsed); } } @@ -1232,6 +1250,7 @@ public void SelectedTabIsActive_WhenInsertBefore() [TestCase("noactive_noselected.xlsx")] [TestCase("noactive_twoselected.xlsx")] + [TestCase("noactive_negativeId.xlsx")] public void FirstSheetIsActive_WhenNotSpecified(string fileName) { using (var stream = TestHelper.GetStreamFromResource(TestHelper.GetResourcePath(@"Other\NoActiveSheet\" + fileName))) @@ -1241,5 +1260,111 @@ public void FirstSheetIsActive_WhenNotSpecified(string fileName) Assert.AreEqual(XLWorksheetVisibility.Visible, wb.Worksheets.First().Visibility); } } + + [TestCase(XLCellsUsedOptions.NormalFormats, 42)] + [TestCase(XLCellsUsedOptions.Contents, 100)] + public void FirstColumnUsed_ReturnsFirstColumnWithUsedCell(XLCellsUsedOptions options, int expectedColumn) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell(1, 42).Style.Fill.SetBackgroundColor(XLColor.Green); + ws.Cell(1, 100).SetValue(5); + + var column = ws.FirstColumnUsed(options); + Assert.AreEqual(expectedColumn, column.ColumnNumber()); + } + + [Test] + public void RecalculateAllFormulas_recalculates_all_formulas_in_sheet_and_leaves_rest_dirty() + { + using var wb = new XLWorkbook(); + var sut = wb.AddWorksheet("sut"); + var other = wb.AddWorksheet("other"); + + other.Cell("A1").Value = 7; + other.Cell("A2").FormulaA1 = "A1+3"; + Assert.AreEqual(10.0, other.Cell("A2").Value); + + // Change the supporting value, but without recalculation of dependent + // formula, thus the value stays the same. + other.Cell("A1").Value = 5; + + Assert.True(other.Cell("A2").NeedsRecalculation); + Assert.AreEqual(10.0, other.Cell("A2").CachedValue); + + // Tested formula depends on a dirty formula from other sheet. + sut.Cell("A1").FormulaA1 = "other!A2+5"; + sut.Cell("A2").FormulaA1 = "1+2"; + + Assert.AreEqual(Blank.Value, sut.Cell("A1").CachedValue); + Assert.AreEqual(Blank.Value, sut.Cell("A2").CachedValue); + + sut.RecalculateAllFormulas(); + + // Formulas in other sheets kept the value - not affected by recalculation of a sut sheet. + Assert.True(other.Cell("A2").NeedsRecalculation); + Assert.AreEqual(10.0, other.Cell("A2").CachedValue); + + // Formulas in test sheet were recalculated - they are affected by recalculation of a sut sheet. + Assert.False(sut.Cell("A1").NeedsRecalculation); + Assert.AreEqual(15.0, sut.Cell("A1").CachedValue); + + Assert.False(sut.Cell("A2").NeedsRecalculation); + Assert.AreEqual(3.0, sut.Cell("A2").CachedValue); + } + + [Test] + public void Cell_returns_cell_at_address_or_workbook_scoped_named_range() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + wb.DefinedNames.Add("test_range", ws.Range(2, 3, 5, 7)); // C2:G5 + + var cellB4 = ws.Cell("B4"); + var firstCellOfRange = ws.Cell("test_range"); + + Assert.AreEqual("B4", cellB4.Address.ToString()); + Assert.AreEqual("C2", firstCellOfRange.Address.ToString()); + } + + [Test] + public void Cell_throws_exception_when_address_is_not_A1_address_or_workbook_scoped_range() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + Assert.Throws(() => _ = ws.Cell("XFF1")); + Assert.Throws(() => _ = ws.Cell("nonexistent_range")); + } + + [Test] + public void Range_returns_range_from_a1_address_or_named_range() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + wb.DefinedNames.Add("book_range", ws.Range(2, 3, 5, 7)); // C2:G5 + ws.DefinedNames.Add("sheet_range", ws.Range(1, 2, 3, 4)); // B1:D3 + + var singleCellRange = ws.Range("B4"); + var areaCellRange = ws.Range("B4:D7"); + var bookNamedRange = ws.Range("book_range"); + var sheetNamedRange = ws.Range("sheet_range"); + + Assert.AreEqual("B4:B4", singleCellRange.RangeAddress.ToString()); + Assert.AreEqual("B4:D7", areaCellRange.RangeAddress.ToString()); + Assert.AreEqual("$C$2:$G$5", bookNamedRange.RangeAddress.ToString()); + Assert.AreEqual("$B$1:$D$3", sheetNamedRange.RangeAddress.ToString()); + } + + [Test] + public void Range_throws_exception_when_address_is_not_A1_address_or_named_range() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + + Assert.Throws(() => _ = ws.Range("DEAD1")); + Assert.Throws(() => _ = ws.Range("DEAD4:BEEF10")); + Assert.Throws(() => _ = ws.Range("nonexistent_range")); + } } } diff --git a/ClosedXML.Tests/Extensions/EnumerableExtensionsTests.cs b/ClosedXML.Tests/Extensions/EnumerableExtensionsTests.cs index 5e92e5e53..a890c0fdb 100644 --- a/ClosedXML.Tests/Extensions/EnumerableExtensionsTests.cs +++ b/ClosedXML.Tests/Extensions/EnumerableExtensionsTests.cs @@ -1,6 +1,7 @@ using ClosedXML.Excel; using ClosedXML.Tests.Excel; using NUnit.Framework; +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -45,5 +46,28 @@ public void CanGetItemType() Assert.True(actualType.StartsWith(expectedTypeStart)); Assert.True(actualType.EndsWith(expectedTypeEnd)); } + + [Test] + public void SkipLast_skips_last_element_of_enumerable() + { + var empty = Array.Empty().SkipLast(); + CollectionAssert.IsEmpty(empty); + + var oneElement = new[] { 1 }.SkipLast(); + CollectionAssert.IsEmpty(oneElement); + + var twoElements = new[] { 1, 2 }.SkipLast(); + CollectionAssert.AreEqual(new[] { 1 }, twoElements); + } + + [Test] + public void WhereNotNull_removes_null_elements() + { + var source = new int?[] { 1, null, 2 }; + + var result = source.WhereNotNull(x => x); + + CollectionAssert.AreEqual(new[] { 1, 2 }, result); + } } } diff --git a/ClosedXML.Tests/Extensions/ReferenceAreaExtensionsTests.cs b/ClosedXML.Tests/Extensions/ReferenceAreaExtensionsTests.cs new file mode 100644 index 000000000..0bdebe842 --- /dev/null +++ b/ClosedXML.Tests/Extensions/ReferenceAreaExtensionsTests.cs @@ -0,0 +1,193 @@ +using System.Collections.Generic; +using ClosedXML.Excel; +using ClosedXML.Extensions; +using ClosedXML.Parser; +using NUnit.Framework; +using static ClosedXML.Parser.ReferenceAxisType; +using static ClosedXML.Parser.ReferenceStyle; + +namespace ClosedXML.Tests.Extensions +{ + [TestFixture] + internal class ReferenceAreaExtensionsTests + { + [Test] + [TestCaseSource(nameof(A1TestCases))] + public void ToSheetPoint_converts_a1_reference_to_sheet_range(ReferenceArea tokenArea, XLSheetRange expectedRange) + { + Assert.AreEqual(expectedRange, tokenArea.ToSheetRange(default)); + } + + [Test] + [TestCaseSource(nameof(R1C1TestCases))] + public void ToSheetPoint_converts_r1c1_reference_to_sheet_range(XLSheetPoint anchor, ReferenceArea tokenArea, XLSheetRange expectedRange) + { + Assert.AreEqual(expectedRange, tokenArea.ToSheetRange(anchor)); + } + + public static IEnumerable A1TestCases() + { + // C5 + yield return new object[] + { + new ReferenceArea(Relative, 5, Relative, 3, A1), + new XLSheetRange(5, 3, 5, 3) + }; + + // C5:E14 + yield return new object[] + { + new ReferenceArea(new RowCol(Relative, 5, Relative, 3, A1), new RowCol(Relative, 14, Relative, 5, A1)), + new XLSheetRange(5, 3, 14, 5) + }; + + // $B3:E$10 + yield return new object[] + { + new ReferenceArea(new RowCol(Relative, 3, Absolute, 2, A1), new RowCol(Absolute, 10, Relative, 5, A1)), + new XLSheetRange(3, 2, 10, 5) + }; + + // $B$3:$E$10 + yield return new object[] + { + new ReferenceArea(new RowCol(Absolute, 3, Absolute, 2, A1), new RowCol(Absolute, 10, Absolute, 5, A1)), + new XLSheetRange(3, 2, 10, 5) + }; + + // B10:E3 points are not in left top corner and bottom right corner + yield return new object[] + { + new ReferenceArea(new RowCol(Relative, 10, Relative, 2, A1), new RowCol(Absolute, 3, Absolute, 5, A1)), + new XLSheetRange(3, 2, 10, 5) + }; + + // C:E + yield return new object[] + { + new ReferenceArea(new RowCol(None, 0, Relative, 3, A1), new RowCol(None, 0, Relative, 5, A1)), + new XLSheetRange(XLHelper.MinRowNumber, 3, XLHelper.MaxRowNumber, 5) + }; + + // E:C + yield return new object[] + { + new ReferenceArea(new RowCol(None, 0, Relative, 5, A1), new RowCol(None, 0, Relative, 3, A1)), + new XLSheetRange(XLHelper.MinRowNumber, 3, XLHelper.MaxRowNumber, 5) + }; + + // 14:30 + yield return new object[] + { + new ReferenceArea(new RowCol(Relative, 14, None, 0, A1), new RowCol(Relative, 30, None, 0, A1)), + new XLSheetRange(14, XLHelper.MinColumnNumber, 30, XLHelper.MaxColumnNumber) + }; + + // 30:14 + yield return new object[] + { + new ReferenceArea(new RowCol(Relative, 30, None, 0, A1), new RowCol(Relative, 14, None, 0, A1)), + new XLSheetRange(14, XLHelper.MinColumnNumber, 30, XLHelper.MaxColumnNumber) + }; + } + + public static IEnumerable R1C1TestCases() + { + // R2C4 + yield return new object[] + { + new XLSheetPoint(1, 1), + new ReferenceArea(Absolute, 2, Absolute, 4, R1C1), + new XLSheetRange(2, 4, 2, 4) + }; + + // R[2]C[4] + yield return new object[] + { + new XLSheetPoint(3, 2), // R3C2 + new ReferenceArea(Relative, 2, Relative, 4, R1C1), // R[2]C[4] + new XLSheetRange(5, 6, 5, 6) + }; + + // R[0]C[0] is the identical address + yield return new object[] + { + new XLSheetPoint(3, 2), // R3C2 + new ReferenceArea(Relative, 0, Relative, 0, R1C1), // R[0]C[0] + new XLSheetRange(3, 2, 3, 2) + }; + + // No looping: Maximum allowed value for relative column is `XLHelper.MaxColumnNumber-1`. + yield return new object[] + { + new XLSheetPoint(1, 1), // R1C1 + new ReferenceArea(Relative, 0, Relative, 16383, R1C1), // R[0]C[16383] + new XLSheetRange(1, XLHelper.MaxColumnNumber, 1, XLHelper.MaxColumnNumber) + }; + + // No looping: Minimum allowed value for relative column is `-XLHelper.MaxColumnNumber+1`. + yield return new object[] + { + new XLSheetPoint(1, XLHelper.MaxColumnNumber), // R1C16384 + new ReferenceArea(Relative, 0, Relative, -16383, R1C1), // R[0]C[-16383] + new XLSheetRange(1, 1, 1, 1) // R1C1 + }; + + // Looping: when relative column adjusted to anchor is above the max column, it loops back + yield return new object[] + { + new XLSheetPoint(1, 16380), // R1C16380 + new ReferenceArea(Relative, 0, Relative, 16380, R1C1), // R[0]C[16380] + new XLSheetRange(1, 16376, 1, 16376) // RC16376 + }; + + // Looping: when relative column adjusted to anchor is below the column 1, it loops back + yield return new object[] + { + new XLSheetPoint(1, 10), // R1C10 + new ReferenceArea(Relative, 0, Relative, -16370, R1C1), // R[0]C[16370] + new XLSheetRange(1, 24, 1, 24) // R1C24 + }; + + // Looping: when relative row adjusted to anchor is above the max row, it loops back + yield return new object[] + { + new XLSheetPoint(15, 1), // R15C1 + new ReferenceArea(Relative, 1048570, Relative, 0, R1C1), // R[1048570]C[0] + new XLSheetRange(9, 1, 9, 1) // R9C1 + }; + + // Looping: when relative row adjusted to anchor is below the row 1, it loops back + yield return new object[] + { + new XLSheetPoint(1048570, 1), // R1048570C1 + new ReferenceArea(Relative, -1048573, Relative, 0, R1C1), // R[-1048573]C[0] + new XLSheetRange(1048573, 1, 1048573, 1) // R1048573C1 + }; + + // Area absolute + yield return new object[] + { + new XLSheetPoint(754, 5742), + new ReferenceArea(new RowCol(Absolute, 3, Absolute, 2, R1C1), new RowCol(Absolute, 7, Absolute, 4, R1C1)), + XLSheetRange.Parse("B3:D7") + }; + + // Area relative + yield return new object[] + { + new XLSheetPoint(3, 6), + new ReferenceArea(new RowCol(Relative, 4, Relative, -1, R1C1), new RowCol(Relative, 6, Relative, 3, R1C1)), // R[4]C[-1]:R[6]C[3] + new XLSheetRange(7, 5, 9, 9) + }; + + // Are with corners not in top left and right bottom + yield return new object[] + { + new XLSheetPoint(3, 6), + new ReferenceArea(new RowCol(Relative, 6, Relative, -1, R1C1), new RowCol(Relative, 4, Relative, 3, R1C1)), // R[6]C[-1]:R[4]C[3] + new XLSheetRange(7, 5, 9, 9) + }; + } + } +} diff --git a/ClosedXML.Tests/Graphics/FontTests.cs b/ClosedXML.Tests/Graphics/FontTests.cs index 6fdd94799..d4cbf220f 100644 --- a/ClosedXML.Tests/Graphics/FontTests.cs +++ b/ClosedXML.Tests/Graphics/FontTests.cs @@ -1,4 +1,5 @@ -using ClosedXML.Excel; +using System; +using ClosedXML.Excel; using ClosedXML.Graphics; using NUnit.Framework; @@ -56,6 +57,54 @@ public void NonExistentFontUsesFallback() Assert.That(nonExistentFontHeight, Is.EqualTo(fallbackFontHeight)); } + [Test] + public void UseEmbeddedFontWhenFallbackFontIsNotPresent() + { + var nonExistentFont = new DummyFont("SomeNonExistentFont", 11); + var engine = new DefaultGraphicEngine("NonExistentFallbackFont"); + Span text = stackalloc int[1] { '8' }; + + var box = engine.GetGlyphBox(text, nonExistentFont, new Dpi(96, 96)); + + // Max digit width of CarlitoBare is 7, unlike MS Sans Serif which is 8 + Assert.AreEqual(7, box.AdvanceWidth); + } + + [TestCase] + public void CanSpecifyFallbackFontWithoutFileSystem() + { + using var fallbackFontStream = TestHelper.GetStreamFromResource("Fonts.TestFontA.ttf"); + var engine = DefaultGraphicEngine.CreateOnlyWithFonts(fallbackFontStream); + + var nonExistentFont = new DummyFont("Nonexistent Font", 20); + var widthOfLetterA = engine.GetTextWidth("A", nonExistentFont, 120); + + const double expectedWidthOfLetterA = 31.25d; + Assert.AreEqual(expectedWidthOfLetterA, widthOfLetterA, 0.0001); + } + + [TestCase] + public void CanSpecifyExtraFontsAsStreamsWithoutFileSystem() + { + using var fallbackFontStream = TestHelper.GetStreamFromResource("Fonts.TestFontA.ttf"); + var fontBStream = TestHelper.GetStreamFromResource("Fonts.TestFontB.ttf"); + var engine = DefaultGraphicEngine.CreateOnlyWithFonts(fallbackFontStream, fontBStream); + + var widthOfLetterB = engine.GetTextWidth("B", new DummyFont("TestFontB", 30), 96); + + const double expectedWidthOfLetterB = 25d; + Assert.AreEqual(expectedWidthOfLetterB, widthOfLetterB, 0.0001); + } + + [TestCase] + public void Issue_1916_CanMeasureSpecificArabicText() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + ws.Cell(1, 1).Value = @"اصين"; + ws.Column(1).AdjustToContents(); + } + private class DummyFont : IXLFontBase { public DummyFont(string name, double size) @@ -85,6 +134,8 @@ public DummyFont(string name, double size) public XLFontFamilyNumberingValues FontFamilyNumbering { get; set; } = XLFontFamilyNumberingValues.NotApplicable; public XLFontCharSet FontCharSet { get; set; } = XLFontCharSet.Default; + + public XLFontScheme FontScheme { get; set; } } } } diff --git a/ClosedXML.Tests/Graphics/PictureInfoTests.cs b/ClosedXML.Tests/Graphics/PictureInfoTests.cs index b0c801eef..7c8394bc9 100644 --- a/ClosedXML.Tests/Graphics/PictureInfoTests.cs +++ b/ClosedXML.Tests/Graphics/PictureInfoTests.cs @@ -1,8 +1,8 @@ -using ClosedXML.Excel.Drawings; -using ClosedXML.Graphics; -using NUnit.Framework; using System.Drawing; using System.Reflection; +using ClosedXML.Excel.Drawings; +using ClosedXML.Graphics; +using NUnit.Framework; namespace ClosedXML.Tests.Graphics { @@ -15,10 +15,11 @@ public void CanReadPng() AssertRasterImage("SampleImagePng.png", XLPictureFormat.Png, new Size(252, 152), 96, 96); } - [Test] - public void CanReadJfif() + [TestCase("SampleImageJfif.jpg", 176, 270, 96, 96)] + [TestCase("jpeg-rgb.jpg", 200, 200, 0, 0)] // Adobe JPG, has APP14 marker right after SOI instead of APP0 + public void CanReadJfif(string filename, int widthPx, int heightPx, int dpiX, int dpiY) { - AssertRasterImage("SampleImageJfif.jpg", XLPictureFormat.Jpeg, new Size(176, 270), 96, 96); + AssertRasterImage($"Jpg.{filename}", XLPictureFormat.Jpeg, new Size(widthPx, heightPx), dpiX, dpiY); } [Test] @@ -90,6 +91,24 @@ public void CanReadEmf() AssertVectorImage("SampleImageEmf.emf", XLPictureFormat.Emf, new Size(28844, 28938)); } + [Test] + public void CanReadExtendedWebp() + { + AssertRasterImage("SampleImageWebpExtendedFormat.webp", XLPictureFormat.Webp, new Size(188, 231), 72, 72); + } + + [Test] + public void CanReadLossyWebp() + { + AssertRasterImage("SampleImageWebpLossy.webp", XLPictureFormat.Webp, new Size(278, 90), 72, 72); + } + + [Test] + public void CanReadLosslessWebp() + { + AssertRasterImage("SampleImageWebpLossless.webp", XLPictureFormat.Webp, new Size(395, 136), 72, 72); + } + private static void AssertRasterImage(string imageName, XLPictureFormat expectedFormat, Size expectedPxSize, double expectedDpiX, double expectedDpiY) { AssertImage(imageName, expectedFormat, expectedPxSize, Size.Empty, expectedDpiX, expectedDpiY); diff --git a/ClosedXML.Tests/IO/XStringConvertTests.cs b/ClosedXML.Tests/IO/XStringConvertTests.cs new file mode 100644 index 000000000..15cff8d26 --- /dev/null +++ b/ClosedXML.Tests/IO/XStringConvertTests.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; +using ClosedXML.IO; + +namespace ClosedXML.Tests.IO; + +internal class XStringConvertTests +{ + [TestCase("", "")] + [TestCase("_x000D_", "\r")] + [TestCase("_x30ab_", "カ")] // Hexadecimal numbers are case insensitive + [TestCase("_x0009_", "\t")] + [TestCase("__x0041__", "_A_")] + [TestCase("A_x0042_C", "ABC")] + [TestCase("_X0041_", "_X0041_")] // Must be lowercase x in the pattern + [TestCase("_x263A_", "\u263a")] // Smiley face + [TestCase("_xD83D__xDE43_", "\ud83d\ude43")] // Astral planes - Upside down smiley face + [TestCase("Result:_x0009_ _x0057_", "Result:\t W")] + [TestCase("DE_x005F_xAB50_0161_title", "DE_xAB50_0161_title")] + [TestCase("_x0001_ _x0002_ _x0003_ _x0004_", "\u0001 \u0002 \u0003 \u0004")] + [TestCase("_x0005_ _x0006_ _x0007_ _x0008_", "\u0005 \u0006 \u0007 \u0008")] + [TestCase("_xaaBB_ _xAAbb_", "\uAABB \uAABB")] + [TestCase(@"_Xceed_Something", @"_Xceed_Something")] // https://github.com/ClosedXML/ClosedXML/issues/1154 + [TestCase("_xD83DDE43_", "_xD83DDE43_")] // 8 hex digit name, decoded by XmlConvert.DecodeName, but not by XString + public void Decodes_encoded_unicode_characters(string sourceText, string expectedText) + { + var decodedText = XStringConvert.Decode(sourceText); + + Assert.That(decodedText, Is.EqualTo(expectedText)); + } +} diff --git a/ClosedXML.Tests/IO/XmlTreeReaderAttributesTests.cs b/ClosedXML.Tests/IO/XmlTreeReaderAttributesTests.cs new file mode 100644 index 000000000..68a10485c --- /dev/null +++ b/ClosedXML.Tests/IO/XmlTreeReaderAttributesTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Xml; +using ClosedXML.Excel.IO; +using ClosedXML.IO; +using NUnit.Framework; + +namespace ClosedXML.Tests.IO; + +/// +/// Test various methods (including extension methods) that reader correctly reads the value of +/// an attribute. +/// +internal class XmlTreeReaderAttributesTests +{ + private const string AttributeName = "test"; + + [TestCase("true", true)] + [TestCase("1", true)] + [TestCase("false", false)] + [TestCase("0", false)] + [TestCase("some text", null)] + [TestCase("TRUE", null)] // xsd says case sensitive, for non-readable values return null + [TestCase("FALSE", null)] + public void GetOptionalBool_reads_xsd_compliant_bool_values(string xmlText, bool? expectedValue) + { + using var reader = CreateReader(xmlText); + var readValue = reader.GetOptionalBool(AttributeName); + + Assert.That(readValue, Is.EqualTo(expectedValue)); + } + + [TestCase("0", 0)] + [TestCase("17", 17)] + [TestCase("2147483647", 2147483647)] + [TestCase("-2147483648", -2147483648)] + [TestCase("+7", 7)] // Canonical representation forbids plus sign or leading zeros, but they are readable + [TestCase("05", 5)] + [TestCase("", null)] + [TestCase("3.0", null)] + [TestCase("2147483648", null)] + [TestCase("-2147483649", null)] + [TestCase("one", null)] + public void GetOptionalInt_reads_xsd_compliant_int_values(string xmlText, int? expectedValue) + { + using var reader = CreateReader(xmlText); + var readValue = reader.GetOptionalInt(AttributeName); + + Assert.That(readValue, Is.EqualTo(expectedValue)); + } + + [TestCase("0", 0u)] + [TestCase("57", 57u)] + [TestCase("2147483647", 2147483647u)] + [TestCase("4294967295", 4294967295u)] + [TestCase("-7", null)] + [TestCase("value", null)] + [TestCase("4294967296", null)] // One above max value + [TestCase("9223372036854775808", null)] + public void GetOptionalUint_reads_xsd_compliant_unsignedInt_values(string xmlText, uint? expectedValue) + { + using var reader = CreateReader(xmlText); + var readValue = reader.GetOptionalUInt(AttributeName); + + Assert.That(readValue, Is.EqualTo(expectedValue)); + } + + [TestCase("0", 0)] + [TestCase("1.75", 1.75)] + [TestCase("-1.75e+10", -1.75e+10)] + [TestCase("+1.75E+10", 1.75e+10)] + [TestCase("2E+308", null)] + [TestCase("-2E+308", null)] + [TestCase("number", null)] + public void GetOptionalDouble_reads_xsd_compliant_double_values(string xmlText, double? expectedValue) + { + // Generally speaking, uint is stored as int in the internal representation, because nearly + // all API expects int and it is just so much easier to work with. + using var reader = CreateReader(xmlText); + var readValue = reader.GetOptionalDouble(AttributeName); + + Assert.That(readValue, Is.EqualTo(expectedValue)); + } + + [TestCase("2025-10-25", "2025-10-25T00:00:00")] + [TestCase("2004-04-12T13:20:00Z", "2004-04-12T13:20:00Z")] + [TestCase("today", null)] + public void GetOptionalDateTime_reads_xsd_compliant_dateTime_values(string xmlText, string expectedString) + { + DateTimeOffset? expectedValue = expectedString is not null ? DateTimeOffset.Parse(expectedString) : null; + using var reader = CreateReader(xmlText); + var readValue = reader.GetOptionalDateTime(AttributeName); + + Assert.That(readValue, Is.EqualTo(expectedValue?.DateTime)); + } + + [Test] + public void GetOptionalString_returns_stored_string_without_XString_decoding() + { + // 0x9 (tab) is invalid character per XML 1.0. + // 0x57 (W) is a valid character per XML 1.0. + const string value = @"Dear <user_name> _x0009_ - _x0057_elcome"; + + using var reader = CreateReader(value); + var readValue = reader.GetOptionalString(AttributeName); + + Assert.That(readValue, Is.EqualTo(@"Dear _x0009_ - _x0057_elcome")); + } + + [TestCase("def", BindingFlags.Default)] + [TestCase("ci", BindingFlags.IgnoreCase)] + [TestCase("CI", null)] // Enums names are case sensitive + [TestCase("Default", null)] // name is not matched, only configured string + [TestCase("", null)] + [TestCase("NonExpectedValue", null)] + public void GetOptionalEnum_returns_enum_parsed_by_enum_mapper(string xmlText, BindingFlags? enumValue) + { + var mapper = new XmlToEnumMapper.Builder().Add(new Dictionary + { + { "def", BindingFlags.Default }, + { "ci", BindingFlags.IgnoreCase }, + }).Build(); + + using var reader = CreateReader(xmlText, mapper); + var readValue = reader.GetOptionalEnum(AttributeName); + + Assert.That(readValue, Is.EqualTo(enumValue)); + } + + private static XmlTreeReader CreateReader(string attributeValue, XmlToEnumMapper mapper = null) + { + var xmlContext = $""; + var xmlReader = XmlReader.Create(new StringReader(xmlContext)); + mapper ??= new XmlToEnumMapper.Builder().Build(); + var reader = new XmlTreeReader(xmlReader, mapper, true); + reader.Open("element", string.Empty); + return reader; + } +} diff --git a/ClosedXML.Tests/IO/XmlTreeReaderExtensionsTests.cs b/ClosedXML.Tests/IO/XmlTreeReaderExtensionsTests.cs new file mode 100644 index 000000000..e579d3cf7 --- /dev/null +++ b/ClosedXML.Tests/IO/XmlTreeReaderExtensionsTests.cs @@ -0,0 +1,67 @@ +using NUnit.Framework; +using System.IO; +using System.Xml; +using ClosedXML.Excel.IO; +using ClosedXML.IO; + +namespace ClosedXML.Tests.IO; + +internal class XmlTreeReaderExtensionsTests +{ + private const string AttributeName = "test"; + + [Test] + public void GetDateTime_throws_when_attribute_is_not_present() + { + using var reader = CreateReader("dummy"); + + var ex = Assert.Throws(() => reader.GetDateTime("nonexistent")); + Assert.That(ex, Has.Message.Contain("XML doesn't contain a required attribute 'nonexistent'.")); + } + + [Test] + public void GetXString_throws_when_attribute_is_not_present() + { + using var reader = CreateReader("dummy"); + + var ex = Assert.Throws(() => reader.GetXString("nonexistent")); + Assert.That(ex, Has.Message.Contain("XML doesn't contain a required attribute 'nonexistent'.")); + } + + [TestCase("&", "&")] + [TestCase("_x0009_", "\t")] + [TestCase("_X0009_", "_X0009_")] + [TestCase("Hello <user> - _x0045__x004F__x004C_", "Hello - EOL")] + public void GetOptionalXString_returns_XString_decoded_xml_decoded_text(string xmlText, string expectedValue) + { + using var reader = CreateReader(xmlText); + var readValue = reader.GetOptionalXString(AttributeName); + + Assert.That(readValue, Is.EqualTo(expectedValue)); + } + + [TestCase("00000000", 0u)] + [TestCase("0G000000", null)] + [TestCase(@"FFFFFFFF", 0xFFFFFFFF)] + [TestCase(@"FFFFFFFF", 0xFFFFFFFF)] + [TestCase("abcdef00", 0xABCDEF00)] + [TestCase("0000000", null)] + [TestCase(@"", null)] + [TestCase(@"hello", null)] + public void GetOptionalUIntHex_parses_8_hex_digits(string xmlText, uint? expectedValue) + { + using var reader = CreateReader(xmlText); + var readValue = reader.GetOptionalUIntHex(AttributeName); + + Assert.That(readValue, Is.EqualTo(expectedValue)); + } + + private static XmlTreeReader CreateReader(string attributeValue) + { + var xmlContext = $""; + var xmlReader = XmlReader.Create(new StringReader(xmlContext)); + var reader = new XmlTreeReader(xmlReader, new XmlToEnumMapper.Builder().Build(), true); + reader.Open("element", string.Empty); + return reader; + } +} diff --git a/ClosedXML.Tests/Resource/Examples/AutoFilter/CustomAutoFilter.xlsx b/ClosedXML.Tests/Resource/Examples/AutoFilter/CustomAutoFilter.xlsx index 2826c5269..2c73a70e3 100644 Binary files a/ClosedXML.Tests/Resource/Examples/AutoFilter/CustomAutoFilter.xlsx and b/ClosedXML.Tests/Resource/Examples/AutoFilter/CustomAutoFilter.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/AutoFilter/DateTimeGroupAutoFilter.xlsx b/ClosedXML.Tests/Resource/Examples/AutoFilter/DateTimeGroupAutoFilter.xlsx index 17f19ba09..565fe5a65 100644 Binary files a/ClosedXML.Tests/Resource/Examples/AutoFilter/DateTimeGroupAutoFilter.xlsx and b/ClosedXML.Tests/Resource/Examples/AutoFilter/DateTimeGroupAutoFilter.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/AutoFilter/DynamicAutoFilter.xlsx b/ClosedXML.Tests/Resource/Examples/AutoFilter/DynamicAutoFilter.xlsx index 240904e43..54221056a 100644 Binary files a/ClosedXML.Tests/Resource/Examples/AutoFilter/DynamicAutoFilter.xlsx and b/ClosedXML.Tests/Resource/Examples/AutoFilter/DynamicAutoFilter.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/AutoFilter/RegularAutoFilter.xlsx b/ClosedXML.Tests/Resource/Examples/AutoFilter/RegularAutoFilter.xlsx index 017cafd28..eb1f5ca7f 100644 Binary files a/ClosedXML.Tests/Resource/Examples/AutoFilter/RegularAutoFilter.xlsx and b/ClosedXML.Tests/Resource/Examples/AutoFilter/RegularAutoFilter.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/AutoFilter/TopBottomAutoFilter.xlsx b/ClosedXML.Tests/Resource/Examples/AutoFilter/TopBottomAutoFilter.xlsx index 04d2b743c..2ae03e23d 100644 Binary files a/ClosedXML.Tests/Resource/Examples/AutoFilter/TopBottomAutoFilter.xlsx and b/ClosedXML.Tests/Resource/Examples/AutoFilter/TopBottomAutoFilter.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Columns/ColumnCells.xlsx b/ClosedXML.Tests/Resource/Examples/Columns/ColumnCells.xlsx index 5b387e020..bf8767784 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Columns/ColumnCells.xlsx and b/ClosedXML.Tests/Resource/Examples/Columns/ColumnCells.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Columns/ColumnCollection.xlsx b/ClosedXML.Tests/Resource/Examples/Columns/ColumnCollection.xlsx index c86104ebd..9a7a9a741 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Columns/ColumnCollection.xlsx and b/ClosedXML.Tests/Resource/Examples/Columns/ColumnCollection.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Columns/ColumnSettings.xlsx b/ClosedXML.Tests/Resource/Examples/Columns/ColumnSettings.xlsx index 8a33eeb73..da1450a1e 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Columns/ColumnSettings.xlsx and b/ClosedXML.Tests/Resource/Examples/Columns/ColumnSettings.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Columns/DeletingColumns.xlsx b/ClosedXML.Tests/Resource/Examples/Columns/DeletingColumns.xlsx index 2b227322e..5246a1765 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Columns/DeletingColumns.xlsx and b/ClosedXML.Tests/Resource/Examples/Columns/DeletingColumns.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Comments/AddingComments.xlsx b/ClosedXML.Tests/Resource/Examples/Comments/AddingComments.xlsx index c31b723b7..dbff6382b 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Comments/AddingComments.xlsx and b/ClosedXML.Tests/Resource/Examples/Comments/AddingComments.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Comments/EditingComments.xlsx b/ClosedXML.Tests/Resource/Examples/Comments/EditingComments.xlsx deleted file mode 100644 index b0bde502c..000000000 Binary files a/ClosedXML.Tests/Resource/Examples/Comments/EditingComments.xlsx and /dev/null differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFBottom.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFBottom.xlsx index 694ea26c7..7b6a26935 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFBottom.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFBottom.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleLowHigh.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleLowHigh.xlsx index 26b22dd38..7b3575797 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleLowHigh.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleLowHigh.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleLowMidHigh.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleLowMidHigh.xlsx index 065f8c269..e6d6493c0 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleLowMidHigh.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleLowMidHigh.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleMinimumMaximum.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleMinimumMaximum.xlsx index ebc045962..1494a4240 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleMinimumMaximum.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFColorScaleMinimumMaximum.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFContains.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFContains.xlsx index 58e792c2c..bd2ac2862 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFContains.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFContains.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx index ec23f8026..104252f63 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBarNegative.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBarNegative.xlsx index 3944b236d..6d1fd0db8 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBarNegative.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBarNegative.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBars.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBars.xlsx index df4c9c7db..7930e0886 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBars.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBars.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDatesOccurring.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDatesOccurring.xlsx index d28985776..67d246cc3 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDatesOccurring.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDatesOccurring.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEndsWith.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEndsWith.xlsx index 92e2a040c..553fd6999 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEndsWith.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEndsWith.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEqualsNumber.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEqualsNumber.xlsx index a11f5cea0..629463e19 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEqualsNumber.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEqualsNumber.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEqualsString.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEqualsString.xlsx index c932d30b3..110e47969 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEqualsString.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFEqualsString.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIconSet.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIconSet.xlsx index baf67a79e..c6ff0b9ca 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIconSet.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIconSet.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIsBlank.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIsBlank.xlsx index 60e89fab6..947d6b97c 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIsBlank.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIsBlank.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIsError.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIsError.xlsx index a9a540b3e..bb1818661 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIsError.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFIsError.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFMultipleConditions.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFMultipleConditions.xlsx index d22591041..4eee7bd55 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFMultipleConditions.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFMultipleConditions.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotBlank.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotBlank.xlsx index f007d386c..58f7b5dec 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotBlank.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotBlank.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotContains.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotContains.xlsx index 21f047aeb..664fc537c 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotContains.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotContains.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotEqualsNumber.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotEqualsNumber.xlsx index 325034429..a71e5201d 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotEqualsNumber.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotEqualsNumber.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotEqualsString.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotEqualsString.xlsx index 8dd0f6774..3a899da11 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotEqualsString.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotEqualsString.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotError.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotError.xlsx index d1dc35e9a..8a6f1affe 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotError.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFNotError.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFStartsWith.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFStartsWith.xlsx index 36c7d66a4..7ea4d367a 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFStartsWith.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFStartsWith.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFStopIfTrue.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFStopIfTrue.xlsx index 1c21f38cc..833cbbfbd 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFStopIfTrue.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFStopIfTrue.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFTop.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFTop.xlsx index b5eb4d896..1ba9a1fda 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFTop.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFTop.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Delete/DeleteFewWorksheets.xlsx b/ClosedXML.Tests/Resource/Examples/Delete/DeleteFewWorksheets.xlsx index 539c6a081..d31ec58ff 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Delete/DeleteFewWorksheets.xlsx and b/ClosedXML.Tests/Resource/Examples/Delete/DeleteFewWorksheets.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Delete/RemoveRows.xlsx b/ClosedXML.Tests/Resource/Examples/Delete/RemoveRows.xlsx index b3c999a68..3ce16a0ce 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Delete/RemoveRows.xlsx and b/ClosedXML.Tests/Resource/Examples/Delete/RemoveRows.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ImageHandling/ImageAnchors.xlsx b/ClosedXML.Tests/Resource/Examples/ImageHandling/ImageAnchors.xlsx index 513b88040..0f05a4c34 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ImageHandling/ImageAnchors.xlsx and b/ClosedXML.Tests/Resource/Examples/ImageHandling/ImageAnchors.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ImageHandling/ImageFormats.xlsx b/ClosedXML.Tests/Resource/Examples/ImageHandling/ImageFormats.xlsx index 3f08a07dc..015f6a17e 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ImageHandling/ImageFormats.xlsx and b/ClosedXML.Tests/Resource/Examples/ImageHandling/ImageFormats.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Loading/ChangingBasicTable.xlsx b/ClosedXML.Tests/Resource/Examples/Loading/ChangingBasicTable.xlsx index b1206f13f..3b88926b8 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Loading/ChangingBasicTable.xlsx and b/ClosedXML.Tests/Resource/Examples/Loading/ChangingBasicTable.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/AddingDataSet.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/AddingDataSet.xlsx index a548e23e0..8dbdf656c 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/AddingDataSet.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/AddingDataSet.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/AddingDataTableAsWorksheet.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/AddingDataTableAsWorksheet.xlsx index 54cc28d85..c278d5a0f 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/AddingDataTableAsWorksheet.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/AddingDataTableAsWorksheet.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/AdjustToContents.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/AdjustToContents.xlsx index 007bde6d0..95585f634 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/AdjustToContents.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/AdjustToContents.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/AdjustToContentsWithAutoFilter.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/AdjustToContentsWithAutoFilter.xlsx index fd5c301ef..a30fed80c 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/AdjustToContentsWithAutoFilter.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/AdjustToContentsWithAutoFilter.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/AutoFilter.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/AutoFilter.xlsx index 0fda64adb..bae1b1caa 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/AutoFilter.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/AutoFilter.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/BasicTable.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/BasicTable.xlsx index 63b394e03..60540e836 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/BasicTable.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/BasicTable.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/BlankCells.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/BlankCells.xlsx index 203b72a59..85ca50741 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/BlankCells.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/BlankCells.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/CellValues.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/CellValues.xlsx index 668064a6d..a7c1cadbc 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/CellValues.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/CellValues.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/Collections.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/Collections.xlsx index 8e46e8ee2..f42ee0b78 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/Collections.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/Collections.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/CopyingRowsAndColumns.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/CopyingRowsAndColumns.xlsx index b871eac21..bb8b43a7e 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/CopyingRowsAndColumns.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/CopyingRowsAndColumns.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/CopyingWorksheets.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/CopyingWorksheets.xlsx index 1a2f50dac..6465ee8d5 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/CopyingWorksheets.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/CopyingWorksheets.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/DataTypes.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/DataTypes.xlsx index 0cae63f38..0abdbbe2b 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/DataTypes.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/DataTypes.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/DataTypesUnderDifferentCulture.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/DataTypesUnderDifferentCulture.xlsx deleted file mode 100644 index 01057c9a3..000000000 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/DataTypesUnderDifferentCulture.xlsx and /dev/null differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/DataValidation.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/DataValidation.xlsx index e49142bda..5eca3e88b 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/DataValidation.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/DataValidation.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationDate.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationDate.xlsx index 68dd2d8dd..2934db443 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationDate.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationDate.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationDecimal.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationDecimal.xlsx index b903bb301..1541ee37d 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationDecimal.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationDecimal.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationTextLength.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationTextLength.xlsx index 2ce80d0cd..07bf5fb89 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationTextLength.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationTextLength.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationTime.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationTime.xlsx index 14d6c8bfd..3607c147d 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationTime.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationTime.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationWholeNumber.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationWholeNumber.xlsx index be2bd89c2..e74690cf6 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/DataValidationWholeNumber.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/DataValidationWholeNumber.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/Formulas.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/Formulas.xlsx index d3606a986..f34927b9d 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/Formulas.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/Formulas.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/FormulasWithEvaluation.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/FormulasWithEvaluation.xlsx index 49dbc9c44..2327116b6 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/FormulasWithEvaluation.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/FormulasWithEvaluation.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/FreezePanes.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/FreezePanes.xlsx index d25be1a7e..01ea2a6db 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/FreezePanes.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/FreezePanes.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/HideSheets.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/HideSheets.xlsx index 65bf62dcc..398c4c5e9 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/HideSheets.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/HideSheets.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/HideUnhide.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/HideUnhide.xlsx index e7162093a..6ff6eb5bd 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/HideUnhide.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/HideUnhide.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/Hyperlinks.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/Hyperlinks.xlsx index 437937991..e25727775 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/Hyperlinks.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/Hyperlinks.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/InsertingData.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/InsertingData.xlsx index 231e3c128..ae8c28b39 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/InsertingData.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/InsertingData.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/LambdaExpressions.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/LambdaExpressions.xlsx index 5a8a32305..fb592dff3 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/LambdaExpressions.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/LambdaExpressions.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/MergeCells.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/MergeCells.xlsx index 4f310286c..3430cea8a 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/MergeCells.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/MergeCells.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/MergeMoves.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/MergeMoves.xlsx index 8d0ffa43f..bd35062cc 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/MergeMoves.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/MergeMoves.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/Outline.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/Outline.xlsx index 521aed8df..a254b4178 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/Outline.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/Outline.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/RightToLeft.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/RightToLeft.xlsx index d76cd987e..7e803161e 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/RightToLeft.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/RightToLeft.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/SheetProtection.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/SheetProtection.xlsx index 22884db7a..927b58c90 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/SheetProtection.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/SheetProtection.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/SheetViews.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/SheetViews.xlsx index 410f41c57..9fd8fc996 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/SheetViews.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/SheetViews.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/ShiftingFormulas.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/ShiftingFormulas.xlsx index d76e17ae8..d070abbbd 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/ShiftingFormulas.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/ShiftingFormulas.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/ShowCase.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/ShowCase.xlsx index a0de23a83..70c23230a 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/ShowCase.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/ShowCase.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/TabColors.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/TabColors.xlsx index faa736cf8..203d325b9 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/TabColors.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/TabColors.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/WorkbookProperties.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/WorkbookProperties.xlsx index 152754ea9..3e357b2ad 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/WorkbookProperties.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/WorkbookProperties.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Misc/WorkbookProtection.xlsx b/ClosedXML.Tests/Resource/Examples/Misc/WorkbookProtection.xlsx index 1334069e6..0e4e38157 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Misc/WorkbookProtection.xlsx and b/ClosedXML.Tests/Resource/Examples/Misc/WorkbookProtection.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/PageSetup/HeaderFooters.xlsx b/ClosedXML.Tests/Resource/Examples/PageSetup/HeaderFooters.xlsx index 37793e4fe..ca71c965f 100644 Binary files a/ClosedXML.Tests/Resource/Examples/PageSetup/HeaderFooters.xlsx and b/ClosedXML.Tests/Resource/Examples/PageSetup/HeaderFooters.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/PageSetup/Margins.xlsx b/ClosedXML.Tests/Resource/Examples/PageSetup/Margins.xlsx index 7868f1a6c..926e219d3 100644 Binary files a/ClosedXML.Tests/Resource/Examples/PageSetup/Margins.xlsx and b/ClosedXML.Tests/Resource/Examples/PageSetup/Margins.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/PageSetup/Page.xlsx b/ClosedXML.Tests/Resource/Examples/PageSetup/Page.xlsx index 461b87a5a..3a95c42e6 100644 Binary files a/ClosedXML.Tests/Resource/Examples/PageSetup/Page.xlsx and b/ClosedXML.Tests/Resource/Examples/PageSetup/Page.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/PageSetup/SheetTab.xlsx b/ClosedXML.Tests/Resource/Examples/PageSetup/SheetTab.xlsx index d0d02ee7b..2f94afa90 100644 Binary files a/ClosedXML.Tests/Resource/Examples/PageSetup/SheetTab.xlsx and b/ClosedXML.Tests/Resource/Examples/PageSetup/SheetTab.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/PageSetup/Sheets.xlsx b/ClosedXML.Tests/Resource/Examples/PageSetup/Sheets.xlsx index 81037c172..8e870ce83 100644 Binary files a/ClosedXML.Tests/Resource/Examples/PageSetup/Sheets.xlsx and b/ClosedXML.Tests/Resource/Examples/PageSetup/Sheets.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/PageSetup/TwoPages.xlsx b/ClosedXML.Tests/Resource/Examples/PageSetup/TwoPages.xlsx index d63813707..b34873b7a 100644 Binary files a/ClosedXML.Tests/Resource/Examples/PageSetup/TwoPages.xlsx and b/ClosedXML.Tests/Resource/Examples/PageSetup/TwoPages.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx b/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx index e1a37668a..c283a4f6d 100644 Binary files a/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx and b/ClosedXML.Tests/Resource/Examples/PivotTables/PivotTables.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/AddingRowToTables.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/AddingRowToTables.xlsx index b1619314f..339745831 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/AddingRowToTables.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/AddingRowToTables.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/ClearingRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/ClearingRanges.xlsx index 8902a3ecc..3f99860db 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/ClearingRanges.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/ClearingRanges.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/CopyingRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/CopyingRanges.xlsx index ebebd767a..3d3ef6535 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/CopyingRanges.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/CopyingRanges.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/CurrentRowColumn.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/CurrentRowColumn.xlsx index 4837a0823..108e8c3a3 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/CurrentRowColumn.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/CurrentRowColumn.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/DefinedNames.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/DefinedNames.xlsx new file mode 100644 index 000000000..8da41ade2 Binary files /dev/null and b/ClosedXML.Tests/Resource/Examples/Ranges/DefinedNames.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/DefiningRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/DefiningRanges.xlsx index 37c401210..8684c1030 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/DefiningRanges.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/DefiningRanges.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/DeletingRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/DeletingRanges.xlsx index f2de2f807..9fa04dd58 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/DeletingRanges.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/DeletingRanges.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/InsertingDeletingColumns.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/InsertingDeletingColumns.xlsx index 980473a62..50b8780fb 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/InsertingDeletingColumns.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/InsertingDeletingColumns.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/InsertingDeletingRows.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/InsertingDeletingRows.xlsx index 0cb0de9dd..95a2e6b6a 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/InsertingDeletingRows.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/InsertingDeletingRows.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/MultipleRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/MultipleRanges.xlsx index 637d28ae4..490e5a533 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/MultipleRanges.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/MultipleRanges.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/NamedRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/NamedRanges.xlsx deleted file mode 100644 index 35a548f7c..000000000 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/NamedRanges.xlsx and /dev/null differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/SelectingRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/SelectingRanges.xlsx index 4d476d2d5..6804df0a5 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/SelectingRanges.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/SelectingRanges.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/ShiftingRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/ShiftingRanges.xlsx index 82ec75629..5d45eeaa1 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/ShiftingRanges.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/ShiftingRanges.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/SortExample.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/SortExample.xlsx index 1d5d6510b..b726082d8 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/SortExample.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/SortExample.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/Sorting.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/Sorting.xlsx index 9a89b137b..71ab6d924 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/Sorting.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/Sorting.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/TransposeRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/TransposeRanges.xlsx index 0647c4f01..1b2822400 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/TransposeRanges.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/TransposeRanges.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/TransposeRangesPlus.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/TransposeRangesPlus.xlsx index 2e33c7b25..34bbbaace 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/TransposeRangesPlus.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/TransposeRangesPlus.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Ranges/WalkingRanges.xlsx b/ClosedXML.Tests/Resource/Examples/Ranges/WalkingRanges.xlsx index d537fc041..357c5feb6 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Ranges/WalkingRanges.xlsx and b/ClosedXML.Tests/Resource/Examples/Ranges/WalkingRanges.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Rows/RowCells.xlsx b/ClosedXML.Tests/Resource/Examples/Rows/RowCells.xlsx index 655c17be5..68f228238 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Rows/RowCells.xlsx and b/ClosedXML.Tests/Resource/Examples/Rows/RowCells.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Rows/RowCollection.xlsx b/ClosedXML.Tests/Resource/Examples/Rows/RowCollection.xlsx index e9e292430..2b8a3b04a 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Rows/RowCollection.xlsx and b/ClosedXML.Tests/Resource/Examples/Rows/RowCollection.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Rows/RowSettings.xlsx b/ClosedXML.Tests/Resource/Examples/Rows/RowSettings.xlsx index 677b4d821..753fc6be6 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Rows/RowSettings.xlsx and b/ClosedXML.Tests/Resource/Examples/Rows/RowSettings.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Sparklines/SampleSparklines.xlsx b/ClosedXML.Tests/Resource/Examples/Sparklines/SampleSparklines.xlsx index 1f08d5321..bebf8ad36 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Sparklines/SampleSparklines.xlsx and b/ClosedXML.Tests/Resource/Examples/Sparklines/SampleSparklines.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/DefaultStyles.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/DefaultStyles.xlsx index 5e4582e74..c38e581db 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/DefaultStyles.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/DefaultStyles.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/PurpleWorksheet.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/PurpleWorksheet.xlsx index f4b2d3202..211ec13c9 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/PurpleWorksheet.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/PurpleWorksheet.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/StyleAlignment.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/StyleAlignment.xlsx index b30e5ac27..4214c0af1 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/StyleAlignment.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/StyleAlignment.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/StyleBorder.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/StyleBorder.xlsx index f3f2442a9..b44d9a846 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/StyleBorder.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/StyleBorder.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/StyleFill.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/StyleFill.xlsx index bbfea0f60..51fd43952 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/StyleFill.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/StyleFill.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/StyleFont.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/StyleFont.xlsx index 2649fe65e..cb3fbd140 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/StyleFont.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/StyleFont.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/StyleIncludeQuotePrefix.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/StyleIncludeQuotePrefix.xlsx index d2134f656..db1c54588 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/StyleIncludeQuotePrefix.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/StyleIncludeQuotePrefix.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/StyleNumberFormat.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/StyleNumberFormat.xlsx index 02016b773..828bbc82a 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/StyleNumberFormat.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/StyleNumberFormat.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/StyleRowsColumns.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/StyleRowsColumns.xlsx index ca8425f6b..533eaf33a 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/StyleRowsColumns.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/StyleRowsColumns.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/StyleWorksheet.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/StyleWorksheet.xlsx index 7a490e058..b7731beec 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/StyleWorksheet.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/StyleWorksheet.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/UsingColors.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/UsingColors.xlsx index 3d4f7eb09..92253f2eb 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/UsingColors.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/UsingColors.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/UsingPhonetics.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/UsingPhonetics.xlsx index abef79ea4..0135c21b3 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/UsingPhonetics.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/UsingPhonetics.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Styles/UsingRichText.xlsx b/ClosedXML.Tests/Resource/Examples/Styles/UsingRichText.xlsx index 7d5d25eec..6288b3a91 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Styles/UsingRichText.xlsx and b/ClosedXML.Tests/Resource/Examples/Styles/UsingRichText.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Tables/InsertingTables.xlsx b/ClosedXML.Tests/Resource/Examples/Tables/InsertingTables.xlsx index d7f865980..a34c92c9d 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Tables/InsertingTables.xlsx and b/ClosedXML.Tests/Resource/Examples/Tables/InsertingTables.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Tables/ResizingTables.xlsx b/ClosedXML.Tests/Resource/Examples/Tables/ResizingTables.xlsx index d50de199f..caae201ed 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Tables/ResizingTables.xlsx and b/ClosedXML.Tests/Resource/Examples/Tables/ResizingTables.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/Tables/UsingTables.xlsx b/ClosedXML.Tests/Resource/Examples/Tables/UsingTables.xlsx index eaae4852d..2195ca1b5 100644 Binary files a/ClosedXML.Tests/Resource/Examples/Tables/UsingTables.xlsx and b/ClosedXML.Tests/Resource/Examples/Tables/UsingTables.xlsx differ diff --git a/ClosedXML.Tests/Resource/Fonts/TestFontA.ttf b/ClosedXML.Tests/Resource/Fonts/TestFontA.ttf new file mode 100644 index 000000000..ed32e1e2c Binary files /dev/null and b/ClosedXML.Tests/Resource/Fonts/TestFontA.ttf differ diff --git a/ClosedXML.Tests/Resource/Fonts/TestFontB.ttf b/ClosedXML.Tests/Resource/Fonts/TestFontB.ttf new file mode 100644 index 000000000..b3bfb3944 Binary files /dev/null and b/ClosedXML.Tests/Resource/Fonts/TestFontB.ttf differ diff --git a/ClosedXML.Tests/Resource/Images/SampleImageJfif.jpg b/ClosedXML.Tests/Resource/Images/Jpg/SampleImageJfif.jpg similarity index 100% rename from ClosedXML.Tests/Resource/Images/SampleImageJfif.jpg rename to ClosedXML.Tests/Resource/Images/Jpg/SampleImageJfif.jpg diff --git a/ClosedXML.Tests/Resource/Images/Jpg/jpeg-rgb.jpg b/ClosedXML.Tests/Resource/Images/Jpg/jpeg-rgb.jpg new file mode 100644 index 000000000..5dae77cf3 Binary files /dev/null and b/ClosedXML.Tests/Resource/Images/Jpg/jpeg-rgb.jpg differ diff --git a/ClosedXML.Tests/Resource/Images/SampleImageWebpExtendedFormat.webp b/ClosedXML.Tests/Resource/Images/SampleImageWebpExtendedFormat.webp new file mode 100644 index 000000000..781a9d32e Binary files /dev/null and b/ClosedXML.Tests/Resource/Images/SampleImageWebpExtendedFormat.webp differ diff --git a/ClosedXML.Tests/Resource/Images/SampleImageWebpLossless.webp b/ClosedXML.Tests/Resource/Images/SampleImageWebpLossless.webp new file mode 100644 index 000000000..1fc454e74 Binary files /dev/null and b/ClosedXML.Tests/Resource/Images/SampleImageWebpLossless.webp differ diff --git a/ClosedXML.Tests/Resource/Images/SampleImageWebpLossy.webp b/ClosedXML.Tests/Resource/Images/SampleImageWebpLossy.webp new file mode 100644 index 000000000..904dd3a43 Binary files /dev/null and b/ClosedXML.Tests/Resource/Images/SampleImageWebpLossy.webp differ diff --git a/ClosedXML.Tests/Resource/Other/Cells/EmptySi.xlsx b/ClosedXML.Tests/Resource/Other/Cells/EmptySi.xlsx new file mode 100644 index 000000000..d0aa41d1e Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Cells/EmptySi.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Cells/EmptyText.xlsx b/ClosedXML.Tests/Resource/Other/Cells/EmptyText.xlsx new file mode 100644 index 000000000..947c4f114 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Cells/EmptyText.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Charts/PreserveCharts/outputfile.xlsx b/ClosedXML.Tests/Resource/Other/Charts/PreserveCharts/outputfile.xlsx index fe5db26a4..55a781222 100644 Binary files a/ClosedXML.Tests/Resource/Other/Charts/PreserveCharts/outputfile.xlsx and b/ClosedXML.Tests/Resource/Other/Charts/PreserveCharts/outputfile.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Comments/InsetsUnitConversion.xlsx b/ClosedXML.Tests/Resource/Other/Comments/InsetsUnitConversion.xlsx new file mode 100644 index 000000000..6df3f5a24 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Comments/InsetsUnitConversion.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/ConditionalFormats/Extra_formulas_CellIs_type.xlsx b/ClosedXML.Tests/Resource/Other/ConditionalFormats/Extra_formulas_CellIs_type.xlsx new file mode 100644 index 000000000..c9ac42675 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/ConditionalFormats/Extra_formulas_CellIs_type.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/ConditionalFormats/Extra_formulas_Expression_type.xlsx b/ClosedXML.Tests/Resource/Other/ConditionalFormats/Extra_formulas_Expression_type.xlsx new file mode 100644 index 000000000..a1a7bdb91 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/ConditionalFormats/Extra_formulas_Expression_type.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Cubes/CubeFromRange-Input.xlsx b/ClosedXML.Tests/Resource/Other/Cubes/CubeFromRange-Input.xlsx new file mode 100644 index 000000000..f1c8afe34 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Cubes/CubeFromRange-Input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Cubes/CubeFromRange-Output.xlsx b/ClosedXML.Tests/Resource/Other/Cubes/CubeFromRange-Output.xlsx new file mode 100644 index 000000000..584377dd3 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Cubes/CubeFromRange-Output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Drawings/NoDrawings/outputfile.xlsx b/ClosedXML.Tests/Resource/Other/Drawings/NoDrawings/outputfile.xlsx index 04abfa0b5..1e4c549f0 100644 Binary files a/ClosedXML.Tests/Resource/Other/Drawings/NoDrawings/outputfile.xlsx and b/ClosedXML.Tests/Resource/Other/Drawings/NoDrawings/outputfile.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Drawings/picture-webp.xlsx b/ClosedXML.Tests/Resource/Other/Drawings/picture-webp.xlsx new file mode 100644 index 000000000..823ca7534 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Drawings/picture-webp.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Formulas/ArrayFormula.xlsx b/ClosedXML.Tests/Resource/Other/Formulas/ArrayFormula.xlsx new file mode 100644 index 000000000..55f55acb0 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Formulas/ArrayFormula.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Formulas/BooleanFormulaValues.xlsx b/ClosedXML.Tests/Resource/Other/Formulas/BooleanFormulaValues.xlsx index a4370443e..ea0e53418 100644 Binary files a/ClosedXML.Tests/Resource/Other/Formulas/BooleanFormulaValues.xlsx and b/ClosedXML.Tests/Resource/Other/Formulas/BooleanFormulaValues.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Formulas/DataTableFormula-Excel-Input.xlsx b/ClosedXML.Tests/Resource/Other/Formulas/DataTableFormula-Excel-Input.xlsx new file mode 100644 index 000000000..12797589d Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Formulas/DataTableFormula-Excel-Input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Formulas/DataTableFormula-Output.xlsx b/ClosedXML.Tests/Resource/Other/Formulas/DataTableFormula-Output.xlsx new file mode 100644 index 000000000..7d75f5eb4 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Formulas/DataTableFormula-Output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/InlineStrings/outputfile.xlsx b/ClosedXML.Tests/Resource/Other/InlineStrings/outputfile.xlsx index 20f73ca28..d42f0335b 100644 Binary files a/ClosedXML.Tests/Resource/Other/InlineStrings/outputfile.xlsx and b/ClosedXML.Tests/Resource/Other/InlineStrings/outputfile.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/InlinedRichText/ChangeRichTextToFormula/output.xlsx b/ClosedXML.Tests/Resource/Other/InlinedRichText/ChangeRichTextToFormula/output.xlsx index 993e1faf2..f345cec0d 100644 Binary files a/ClosedXML.Tests/Resource/Other/InlinedRichText/ChangeRichTextToFormula/output.xlsx and b/ClosedXML.Tests/Resource/Other/InlinedRichText/ChangeRichTextToFormula/output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/NoActiveSheet/noactive_negativeId.xlsx b/ClosedXML.Tests/Resource/Other/NoActiveSheet/noactive_negativeId.xlsx new file mode 100644 index 000000000..df8e7efba Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/NoActiveSheet/noactive_negativeId.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/NumberFormats/NonSequentialNumberFormatsIds-Input.xlsx b/ClosedXML.Tests/Resource/Other/NumberFormats/NonSequentialNumberFormatsIds-Input.xlsx new file mode 100644 index 000000000..623efd5e9 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/NumberFormats/NonSequentialNumberFormatsIds-Input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/NumberFormats/NonSequentialNumberFormatsIds-Output.xlsx b/ClosedXML.Tests/Resource/Other/NumberFormats/NonSequentialNumberFormatsIds-Output.xlsx new file mode 100644 index 000000000..53e89ba6b Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/NumberFormats/NonSequentialNumberFormatsIds-Output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PageSetup/Negative_first_page_number.xlsx b/ClosedXML.Tests/Resource/Other/PageSetup/Negative_first_page_number.xlsx new file mode 100644 index 000000000..2d8375c08 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PageSetup/Negative_first_page_number.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Parts/MultiplePartsHaveNonUniqueRelId-input.xlsx b/ClosedXML.Tests/Resource/Other/Parts/MultiplePartsHaveNonUniqueRelId-input.xlsx new file mode 100644 index 000000000..3c9bf8585 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Parts/MultiplePartsHaveNonUniqueRelId-input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Parts/MultiplePartsHaveNonUniqueRelId-output.xlsx b/ClosedXML.Tests/Resource/Other/Parts/MultiplePartsHaveNonUniqueRelId-output.xlsx new file mode 100644 index 000000000..cc5c5e633 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Parts/MultiplePartsHaveNonUniqueRelId-output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Parts/WorksheetWithDrawingCanBeModified-input.xlsx b/ClosedXML.Tests/Resource/Other/Parts/WorksheetWithDrawingCanBeModified-input.xlsx new file mode 100644 index 000000000..c6b2d1048 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Parts/WorksheetWithDrawingCanBeModified-input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Parts/WorksheetWithDrawingCanBeModified-output.xlsx b/ClosedXML.Tests/Resource/Other/Parts/WorksheetWithDrawingCanBeModified-output.xlsx new file mode 100644 index 000000000..54aa16986 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Parts/WorksheetWithDrawingCanBeModified-output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Pictures/ImageShapeZOrder-Input.xlsx b/ClosedXML.Tests/Resource/Other/Pictures/ImageShapeZOrder-Input.xlsx new file mode 100644 index 000000000..0969dd9e1 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Pictures/ImageShapeZOrder-Input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Pictures/ImageShapeZOrder-Output.xlsx b/ClosedXML.Tests/Resource/Other/Pictures/ImageShapeZOrder-Output.xlsx new file mode 100644 index 000000000..1e8cd6377 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Pictures/ImageShapeZOrder-Output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_empty_table.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_empty_table.xlsx new file mode 100644 index 000000000..b26a0b9ae Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_empty_table.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_column_and_one_value.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_column_and_one_value.xlsx new file mode 100644 index 000000000..13bf43053 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_column_and_one_value.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_column_and_two_values.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_column_and_two_values.xlsx new file mode 100644 index 000000000..db7a05deb Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_column_and_two_values.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_column_without_value.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_column_without_value.xlsx new file mode 100644 index 000000000..3071a77b9 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_column_without_value.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_row_without_value.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_row_without_value.xlsx new file mode 100644 index 000000000..24fcdf902 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Create/Add_one_row_without_value.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Sources/PivotTable-AllSources-external-data.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Sources/PivotTable-AllSources-external-data.xlsx new file mode 100644 index 000000000..82d36ff25 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Sources/PivotTable-AllSources-external-data.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Sources/PivotTable-AllSources-input.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Sources/PivotTable-AllSources-input.xlsx new file mode 100644 index 000000000..0f7e5525b Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Sources/PivotTable-AllSources-input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Sources/PivotTable-AllSources-output.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Sources/PivotTable-AllSources-output.xlsx new file mode 100644 index 000000000..ca54b679a Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Sources/PivotTable-AllSources-output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Style/Add_grand_column_total_styles.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Add_grand_column_total_styles.xlsx new file mode 100644 index 000000000..d1a35dc05 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Add_grand_column_total_styles.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Style/Add_grand_row_total_styles.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Add_grand_row_total_styles.xlsx new file mode 100644 index 000000000..24fdc8178 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Add_grand_row_total_styles.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Style/Modify_pivot_field_label_style.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Modify_pivot_field_label_style.xlsx new file mode 100644 index 000000000..885697c22 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Modify_pivot_field_label_style.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Style/Set_pivot_field_header_style-compact.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Set_pivot_field_header_style-compact.xlsx new file mode 100644 index 000000000..3bc2464e5 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Set_pivot_field_header_style-compact.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Style/Set_pivot_field_header_style-tabular.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Set_pivot_field_header_style-tabular.xlsx new file mode 100644 index 000000000..d1afa31f4 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Set_pivot_field_header_style-tabular.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Style/Set_pivot_field_subtotals_style.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Set_pivot_field_subtotals_style.xlsx new file mode 100644 index 000000000..922973459 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Set_pivot_field_subtotals_style.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Style/Style_data_cells_at_intersection_of_value_field_and_axis_field_with_specific_values.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Style_data_cells_at_intersection_of_value_field_and_axis_field_with_specific_values.xlsx new file mode 100644 index 000000000..b88c1c20c Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Style_data_cells_at_intersection_of_value_field_and_axis_field_with_specific_values.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Style/Style_data_cells_at_intersection_of_values_field_and_row_or_column_field.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Style_data_cells_at_intersection_of_values_field_and_row_or_column_field.xlsx new file mode 100644 index 000000000..8ebd1f3b7 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Style_data_cells_at_intersection_of_values_field_and_row_or_column_field.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/Style/Style_values_at_intersection_of_row_and_column.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Style_values_at_intersection_of_row_and_column.xlsx new file mode 100644 index 000000000..f09fa3012 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/Style/Style_values_at_intersection_of_row_and_column.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/TableProps/Property_layout_sets_layout_of_pivot_table_and_all_fields-compact.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/TableProps/Property_layout_sets_layout_of_pivot_table_and_all_fields-compact.xlsx new file mode 100644 index 000000000..a73957fce Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/TableProps/Property_layout_sets_layout_of_pivot_table_and_all_fields-compact.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/TableProps/Property_layout_sets_layout_of_pivot_table_and_all_fields-outline.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/TableProps/Property_layout_sets_layout_of_pivot_table_and_all_fields-outline.xlsx new file mode 100644 index 000000000..b8e08591a Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/TableProps/Property_layout_sets_layout_of_pivot_table_and_all_fields-outline.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTable/TableProps/Property_layout_sets_layout_of_pivot_table_and_all_fields-tabular.xlsx b/ClosedXML.Tests/Resource/Other/PivotTable/TableProps/Property_layout_sets_layout_of_pivot_table_and_all_fields-tabular.xlsx new file mode 100644 index 000000000..c37251307 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTable/TableProps/Property_layout_sets_layout_of_pivot_table_and_all_fields-tabular.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/BlankPivotTableField/BlankPivotTableField.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/BlankPivotTableField/BlankPivotTableField.xlsx index a029180bf..6e6f9bf4b 100644 Binary files a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/BlankPivotTableField/BlankPivotTableField.xlsx and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/BlankPivotTableField/BlankPivotTableField.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/ChartsheetAndPivotTable.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/ChartsheetAndPivotTable.xlsx new file mode 100644 index 000000000..30dfb2ee1 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/ChartsheetAndPivotTable.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/LongText/outputfile.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/LongText/outputfile.xlsx index 00aa8be34..952085b52 100644 Binary files a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/LongText/outputfile.xlsx and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/LongText/outputfile.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotCacheWithoutSourceData-input.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotCacheWithoutSourceData-input.xlsx new file mode 100644 index 000000000..c5e958b53 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotCacheWithoutSourceData-input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotCacheWithoutSourceData-output.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotCacheWithoutSourceData-output.xlsx new file mode 100644 index 000000000..4c72ef6f8 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotCacheWithoutSourceData-output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotMixedDataTypesInTableColumn/input.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotMixedDataTypesInTableColumn/input.xlsx new file mode 100644 index 000000000..7d484a4cf Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotMixedDataTypesInTableColumn/input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotSubtotalsSource/input.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotSubtotalsSource/input.xlsx index 94ed12e8f..81fc82464 100644 Binary files a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotSubtotalsSource/input.xlsx and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotSubtotalsSource/input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotSubtotalsSource/output.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotSubtotalsSource/output.xlsx new file mode 100644 index 000000000..469f714df Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotSubtotalsSource/output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithNoneTheme/outputfile.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithNoneTheme/outputfile.xlsx index 3fbd2417b..e7b5b073c 100644 Binary files a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithNoneTheme/outputfile.xlsx and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithNoneTheme/outputfile.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithoutSourceData-input.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithoutSourceData-input.xlsx new file mode 100644 index 000000000..b43816596 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithoutSourceData-input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithoutSourceData-output.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithoutSourceData-output.xlsx new file mode 100644 index 000000000..a33aae9b9 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/PivotTableWithoutSourceData-output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/RenamedFieldIsRemovedFromPivotTable-output.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/RenamedFieldIsRemovedFromPivotTable-output.xlsx new file mode 100644 index 000000000..e74041228 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/RenamedFieldIsRemovedFromPivotTable-output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/SourceSheetWithWhitespace/outputfile.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/SourceSheetWithWhitespace/outputfile.xlsx index 320aa62a1..95874997d 100644 Binary files a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/SourceSheetWithWhitespace/outputfile.xlsx and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/SourceSheetWithWhitespace/outputfile.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/TwoPivotTablesWithSingleSource/output.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/TwoPivotTablesWithSingleSource/output.xlsx index 83cac2e96..f543b3ef3 100644 Binary files a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/TwoPivotTablesWithSingleSource/output.xlsx and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/TwoPivotTablesWithSingleSource/output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VariousDataTypesInTableColumns/input.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VariousDataTypesInTableColumns/input.xlsx new file mode 100644 index 000000000..184464e52 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VariousDataTypesInTableColumns/input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VariousDataTypesInTableColumns/output.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VariousDataTypesInTableColumns/output.xlsx new file mode 100644 index 000000000..de6d4b4ea Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VariousDataTypesInTableColumns/output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VersioningAttributes/outputfile.xlsx b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VersioningAttributes/outputfile.xlsx index 64dae0a90..a3cf1f70a 100644 Binary files a/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VersioningAttributes/outputfile.xlsx and b/ClosedXML.Tests/Resource/Other/PivotTableReferenceFiles/VersioningAttributes/outputfile.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/RichText/kanji-with-new-line-input.xlsx b/ClosedXML.Tests/Resource/Other/RichText/kanji-with-new-line-input.xlsx new file mode 100644 index 000000000..087399bde Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/RichText/kanji-with-new-line-input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/RichText/kanji-with-new-line-output.xlsx b/ClosedXML.Tests/Resource/Other/RichText/kanji-with-new-line-output.xlsx new file mode 100644 index 000000000..e02a050d6 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/RichText/kanji-with-new-line-output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Shapes/sheet-with-form-controls-input.xlsx b/ClosedXML.Tests/Resource/Other/Shapes/sheet-with-form-controls-input.xlsx new file mode 100644 index 000000000..7bdad3a3d Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Shapes/sheet-with-form-controls-input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Shapes/sheet-with-form-controls-output.xlsx b/ClosedXML.Tests/Resource/Other/Shapes/sheet-with-form-controls-output.xlsx new file mode 100644 index 000000000..6c7a18d6c Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Shapes/sheet-with-form-controls-output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/ConditionalFormattingOrder/ConditionalFormattingOrder.xlsx b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/ConditionalFormattingOrder/ConditionalFormattingOrder.xlsx index 84c186d58..583e805d6 100644 Binary files a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/ConditionalFormattingOrder/ConditionalFormattingOrder.xlsx and b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/ConditionalFormattingOrder/ConditionalFormattingOrder.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-MoveFill-Input.xlsx b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-MoveFill-Input.xlsx new file mode 100644 index 000000000..c9f81df11 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-MoveFill-Input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-MoveFill-Output.xlsx b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-MoveFill-Output.xlsx new file mode 100644 index 000000000..b4d763316 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-MoveFill-Output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-SavePredefinedValues-Input.xlsx b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-SavePredefinedValues-Input.xlsx new file mode 100644 index 000000000..8f54068a1 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-SavePredefinedValues-Input.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-SavePredefinedValues-Output.xlsx b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-SavePredefinedValues-Output.xlsx new file mode 100644 index 000000000..c6e068746 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/FillAtReservedPosition-SavePredefinedValues-Output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/RowColors/output.xlsx b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/RowColors/output.xlsx index 0bdaf2b8b..fd2652e8b 100644 Binary files a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/RowColors/output.xlsx and b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/RowColors/output.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/ThemeColors/inputfile.xlsx b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/ThemeColors/inputfile.xlsx new file mode 100644 index 000000000..99335d237 Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/ThemeColors/inputfile.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/TransparentBackgroundFill/TransparentBackgroundFill.xlsx b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/TransparentBackgroundFill/TransparentBackgroundFill.xlsx index 8e76ffd76..3ffafc7ec 100644 Binary files a/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/TransparentBackgroundFill/TransparentBackgroundFill.xlsx and b/ClosedXML.Tests/Resource/Other/StyleReferenceFiles/TransparentBackgroundFill/TransparentBackgroundFill.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Styles/Alignment/TextRotation.xlsx b/ClosedXML.Tests/Resource/Other/Styles/Alignment/TextRotation.xlsx new file mode 100644 index 000000000..2558da97f Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Styles/Alignment/TextRotation.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Tables/TotalRowSstId.xlsx b/ClosedXML.Tests/Resource/Other/Tables/TotalRowSstId.xlsx new file mode 100644 index 000000000..6c258749b Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Tables/TotalRowSstId.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Unicode/let_it_go_in_emoji-outputfile.xlsx b/ClosedXML.Tests/Resource/Other/Unicode/let_it_go_in_emoji-outputfile.xlsx new file mode 100644 index 000000000..1fccb310b Binary files /dev/null and b/ClosedXML.Tests/Resource/Other/Unicode/let_it_go_in_emoji-outputfile.xlsx differ diff --git a/ClosedXML.Tests/Resource/Other/Unicode/let_it_go_in_emoji.txt b/ClosedXML.Tests/Resource/Other/Unicode/let_it_go_in_emoji.txt new file mode 100644 index 000000000..9510d6cc2 --- /dev/null +++ b/ClosedXML.Tests/Resource/Other/Unicode/let_it_go_in_emoji.txt @@ -0,0 +1 @@ +The ❄️ 🌟 🔦 ⚪ on the mountain 🌙 🌠. 🙅🏻 a👣 to 🐝 👀. A 🏰 of 😢, and it 👀 like☝️️ the 👑. The 💨 is 🐺 like this 🌀 ❄️ ☔️ 🏠. 🙅🏻 keep it in, ☁️ 💡 ☝️️ tried. 🙅🏻 let 👬👫 in,🙅🏻 let 👬👫 👀. 🐝 the 👍 👧 👇 always have to 🐝. 🙅🏻, don't 👐, 🚫 let 👬👫💡. Well now 👬👫 💡. 👐 it 🚗,, 👐 it 🚗,,🙅🏻 ✊ it back anymore. 👐 it 🚗,, 👐 it 🚗, turn ✈️ and 🔨 the 🚪. ☝️️ 🚫 care, what 👬👫 going to 👄, let the ☔️ ⚡ ❄️ 😡 on, the ❄️ ⛄️ 🙅🏻 bothered ☝️️ anyway. It's 😜😂 how some ✈️ 🚆 makes everything 😳 🐜. And the 😱 that once 👮 me, 🙅🏻 get to☝️️ at all. It's 🕓 to 👀 what☝️️ can do. To 📝 the 📊 and 🔨 through. 🚫 👍 , 🚫 👎, 🚫 👮 for ☝️️. ☝️️ 🏃. 👐 it 🚗,, 👐 it 🚗., ☝️️ am ☝️ with the 🌀 and 🌌. 👐 it 🚗,, 👐 it 🚗..👇 🙅🏻 👀 ☝️️ 😭 . 👉 ☝️️ 🚶, and 👉 ☝️️ stay. Let the⚡ ❄️ 😡 on. ☝️️ 💪 ❄️ through the 🌀 into the 🌎.☝️️ 👤 is 🌀 in ❄️ ⛄️ fractals all 🔁. And 1️⃣💡 💎 like an ❄️ 📢. ☝️️ 🙅🏻 🏃 back, the past is in the past. 👐 it 🚗,,👐 it 🚗,. And ☝️️ 🚀 like the 💔 of 🌌. 👐 it 🚗,, 👐 it 🚗.. That 💁 is 🚫. Here ☝️️ 🚶, in the 🔦 of ☀️. Let the ⚡ ❄️ 😡 on, the ❄️ ⛄️ 🙅🏻 bothered ☝️️ anyway. \ No newline at end of file diff --git a/ClosedXML.Tests/Resource/TryToLoad/CellsWithDateTimeDataTypeOrFormatting.xlsx b/ClosedXML.Tests/Resource/TryToLoad/CellsWithDateTimeDataTypeOrFormatting.xlsx new file mode 100644 index 000000000..21a310e5f Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/CellsWithDateTimeDataTypeOrFormatting.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/CellsWithDateTimeWithLocalePrefix.xlsx b/ClosedXML.Tests/Resource/TryToLoad/CellsWithDateTimeWithLocalePrefix.xlsx new file mode 100644 index 000000000..328734a95 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/CellsWithDateTimeWithLocalePrefix.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/CellsWithTimeSpanDataTypeOrFormatting.xlsx b/ClosedXML.Tests/Resource/TryToLoad/CellsWithTimeSpanDataTypeOrFormatting.xlsx new file mode 100644 index 000000000..7d76d7a66 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/CellsWithTimeSpanDataTypeOrFormatting.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/CommentsWithIndexedColor81.xlsx b/ClosedXML.Tests/Resource/TryToLoad/CommentsWithColorNamesAndIndexes.xlsx similarity index 83% rename from ClosedXML.Tests/Resource/TryToLoad/CommentsWithIndexedColor81.xlsx rename to ClosedXML.Tests/Resource/TryToLoad/CommentsWithColorNamesAndIndexes.xlsx index 715c0731f..1b67d5378 100644 Binary files a/ClosedXML.Tests/Resource/TryToLoad/CommentsWithIndexedColor81.xlsx and b/ClosedXML.Tests/Resource/TryToLoad/CommentsWithColorNamesAndIndexes.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/DefaultColumnWidth.xlsx b/ClosedXML.Tests/Resource/TryToLoad/DefaultColumnWidth.xlsx new file mode 100644 index 000000000..c0bd54f67 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/DefaultColumnWidth.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/DialogSheet.xlsx b/ClosedXML.Tests/Resource/TryToLoad/DialogSheet.xlsx new file mode 100644 index 000000000..1fc231bf8 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/DialogSheet.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/EmptyStyles.xlsx b/ClosedXML.Tests/Resource/TryToLoad/EmptyStyles.xlsx new file mode 100644 index 000000000..8b8a8c3b5 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/EmptyStyles.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/InvalidColors.xlsx b/ClosedXML.Tests/Resource/TryToLoad/InvalidColors.xlsx new file mode 100644 index 000000000..6e8431665 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/InvalidColors.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/PivotTableWithBorder.xlsx b/ClosedXML.Tests/Resource/TryToLoad/PivotTableWithBorder.xlsx new file mode 100644 index 000000000..7993690a3 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/PivotTableWithBorder.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/SheetDefaultColumnWidth.xlsx b/ClosedXML.Tests/Resource/TryToLoad/SheetDefaultColumnWidth.xlsx new file mode 100644 index 000000000..c0400d907 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/SheetDefaultColumnWidth.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/SheetsWithoutRelId.xlsx b/ClosedXML.Tests/Resource/TryToLoad/SheetsWithoutRelId.xlsx new file mode 100644 index 000000000..cbb7bb987 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/SheetsWithoutRelId.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/ValidationWithSheetReference.xlsx b/ClosedXML.Tests/Resource/TryToLoad/ValidationWithSheetReference.xlsx new file mode 100644 index 000000000..19f9be2d0 Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/ValidationWithSheetReference.xlsx differ diff --git a/ClosedXML.Tests/Resource/TryToLoad/~$LoadPivotTables.xlsx b/ClosedXML.Tests/Resource/TryToLoad/~$LoadPivotTables.xlsx new file mode 100644 index 000000000..ef53d490d Binary files /dev/null and b/ClosedXML.Tests/Resource/TryToLoad/~$LoadPivotTables.xlsx differ diff --git a/ClosedXML.Tests/TestHelper.cs b/ClosedXML.Tests/TestHelper.cs index dd3f4fef5..77f0fcc3e 100644 --- a/ClosedXML.Tests/TestHelper.cs +++ b/ClosedXML.Tests/TestHelper.cs @@ -101,7 +101,24 @@ public static void RunTestExample(string filePartName, bool evaluateFormulae } } - public static void CreateAndCompare(Func workbookGenerator, string referenceResource, bool evaluateFormulae = false) + /// + /// Create a workbook and compare it with a saved resource. + /// + /// A function that gets an empty workbook and fills it with data. + /// Reference workbook saved in resources + /// Should formulas of created workbook be evaluated and values saved? + /// Should the created workbook be validated during by OpenXmlSdk validator? + public static void CreateAndCompare(Action workbookGenerator, string referenceResource, bool evaluateFormulae = false, bool validate = true) + { + CreateAndCompare(() => + { + var wb = new XLWorkbook(); + workbookGenerator(wb); + return wb; + }, referenceResource, evaluateFormulae, validate); + } + + public static void CreateAndCompare(Func workbookGenerator, string referenceResource, bool evaluateFormulae = false, bool validate = true) { Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("en-US"); @@ -118,7 +135,7 @@ public static void CreateAndCompare(Func workbookGenerator, string var filePath2 = Path.Combine(directory, fileName); using (var wb = workbookGenerator.Invoke()) - wb.SaveAs(filePath2, true, evaluateFormulae); + wb.SaveAs(filePath2, validate, evaluateFormulae); if (CompareWithResources) { @@ -137,6 +154,58 @@ public static void CreateAndCompare(Func workbookGenerator, string } } + /// + /// Load a file from the , modify it, save it through ClosedXML + /// and compare the saved file against the . + /// + /// Useful for checking whether we can load data from Excel and save it while keeping various feature in the OpenXML intact. + public static void LoadModifyAndCompare(string loadResourcePath, Action modify, string expectedOutputResourcePath, bool evaluateFormulae = false, bool validate = true) + { + using var stream = GetStreamFromResource(GetResourcePath(loadResourcePath)); + using var ms = new MemoryStream(); + CreateAndCompare(() => + { + var wb = new XLWorkbook(stream); + modify(wb); + wb.SaveAs(ms, validate); + return wb; + }, expectedOutputResourcePath, evaluateFormulae, validate); + } + + /// + /// Load a file from the , save it through ClosedXML without modifications + /// and compare the saved file against the . + /// + /// Useful for checking whether we can load data from Excel and save it while keeping various feature in the OpenXML intact. + public static void LoadSaveAndCompare(string loadResourcePath, string expectedOutputResourcePath, bool evaluateFormulae = false, bool validate = true) + { + LoadModifyAndCompare(loadResourcePath, _ => { }, expectedOutputResourcePath, evaluateFormulae, validate); + } + + /// + /// A testing method to load a workbook from resource and assert the state of the loaded workbook. + /// + public static void LoadAndAssert(Action assertWorkbook, string loadResourcePath, LoadOptions options = null) + { + using var stream = GetStreamFromResource(GetResourcePath(loadResourcePath)); + using var wb = new XLWorkbook(stream, options ?? new LoadOptions()); + + assertWorkbook(wb); + } + + /// + /// A testing method to load a workbook with a single worksheet from resource and assert + /// the state of the loaded workbook. + /// + public static void LoadAndAssert(Action assertWorksheet, string loadResourcePath, LoadOptions options = null) + { + LoadAndAssert(wb => + { + var ws = wb.Worksheets.Single(); + assertWorksheet(wb, ws); + }, loadResourcePath, options); + } + public static string GetResourcePath(string filePartName) { return filePartName.Replace('\\', '.').TrimStart('.'); @@ -160,5 +229,52 @@ public static IEnumerable ListResourceFiles(Func predic { return _extractor.GetFileNames(predicate); } + + /// + /// A method for testing of a saving and loading capabilities of ClosedXML. Use this + /// method to check properties are correctly saved and loaded. + /// + /// This method is specialized, so it only works on one sheet. + /// + /// Method to setup a worksheet that will be saved and the saved file will be compared to + /// . + /// + /// + /// will be loaded and this method will check that it + /// was loaded correctly (i.e. properties are what was set in ). + /// + /// Saved reference file. + public static void CreateSaveLoadAssert(Action createWorksheet, Action assertLoadedWorkbook, string referenceResource) + { + CreateAndCompare(wb => + { + var ws = wb.AddWorksheet(); + createWorksheet(wb, ws); + }, referenceResource); + LoadAndAssert(assertLoadedWorkbook, referenceResource); + } + + /// + /// Basically can survive through save and load cycle. Doesn't check against actual file. + /// Useful for testing is internal structures are correctly initialized after load. + /// + /// Code to create a workbook. + /// Method to assert that workbook was loaded correctly. + public static void CreateSaveLoadAssert(Action createWorksheet, Action assertLoadedWorkbook, bool validate = true, bool evaluateFormulas = false) + { + using var ms = new MemoryStream(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet(); + createWorksheet(wb, ws); + wb.SaveAs(ms, validate, evaluateFormulas); + } + + using (var wb = new XLWorkbook(ms)) + { + var ws = wb.Worksheets.Single(); + assertLoadedWorkbook(wb, ws); + } + } } } diff --git a/ClosedXML.Tests/Utils/PackageHelper.cs b/ClosedXML.Tests/Utils/PackageHelper.cs index 1f59e1346..9d5528acd 100644 --- a/ClosedXML.Tests/Utils/PackageHelper.cs +++ b/ClosedXML.Tests/Utils/PackageHelper.cs @@ -56,7 +56,6 @@ public static void WriteBinaryPart(Package package, Uri uri, Stream content) /// /// /// - /// public static Stream ReadBinaryPart(Package package, Uri uri) { if (!package.PartExists(uri)) @@ -272,7 +271,6 @@ public static bool TryReadPart(Package package, Uri uri, Action deserial /// /// /// - /// public static bool Compare(Package left, Package right, bool compareToFirstDifference, out string message) { return Compare(left, right, compareToFirstDifference, null, out message); @@ -286,7 +284,6 @@ public static bool Compare(Package left, Package right, bool compareToFirstDiffe /// /// /// - /// public static bool Compare(Package left, Package right, bool compareToFirstDifference, Func excludeMethod, out string message) { diff --git a/ClosedXML.Tests/Utils/PivotTableComparer.cs b/ClosedXML.Tests/Utils/PivotTableComparer.cs index b2347f59a..1462a511a 100644 --- a/ClosedXML.Tests/Utils/PivotTableComparer.cs +++ b/ClosedXML.Tests/Utils/PivotTableComparer.cs @@ -69,10 +69,10 @@ public bool Equals(XLPivotTable x, XLPivotTable y) && x.PrintExpandCollapsedButtons.Equals(y.PrintExpandCollapsedButtons) && x.RepeatRowLabels.Equals(y.RepeatRowLabels) && x.PrintTitles.Equals(y.PrintTitles) - && x.SaveSourceData.Equals(y.SaveSourceData) + && x.PivotCache.SaveSourceData.Equals(y.PivotCache.SaveSourceData) && x.EnableShowDetails.Equals(y.EnableShowDetails) - && x.RefreshDataOnOpen.Equals(y.RefreshDataOnOpen) - && x.ItemsToRetainPerField.Equals(y.ItemsToRetainPerField) + && x.PivotCache.RefreshDataOnOpen.Equals(y.PivotCache.RefreshDataOnOpen) + && x.PivotCache.ItemsToRetainPerField.Equals(y.PivotCache.ItemsToRetainPerField) && x.EnableCellEditing.Equals(y.EnableCellEditing) && x.ShowRowHeaders.Equals(y.ShowRowHeaders) && x.ShowColumnHeaders.Equals(y.ShowColumnHeaders) diff --git a/ClosedXML.Tests/Utils/ResourceFileExtractor.cs b/ClosedXML.Tests/Utils/ResourceFileExtractor.cs index 9612652a9..1f638961b 100644 --- a/ClosedXML.Tests/Utils/ResourceFileExtractor.cs +++ b/ClosedXML.Tests/Utils/ResourceFileExtractor.cs @@ -208,7 +208,6 @@ public string ReadFileFromResourceFormat(string fileName, params object[] format /// /// Specific path /// Read file name - /// public string ReadSpecificFileFromResource(string specificPath, string fileName) { ResourceFileExtractor _ext = new ResourceFileExtractor(Assembly, specificPath); @@ -219,7 +218,6 @@ public string ReadSpecificFileFromResource(string specificPath, string fileName) /// Read file in current assembly by specific file name /// /// - /// /// ApplicationException. public Stream ReadFileFromResourceToStream(string fileName) { diff --git a/ClosedXML.Tests/Utils/StreamHelper.cs b/ClosedXML.Tests/Utils/StreamHelper.cs index 378d5f7f6..e376e7bea 100644 --- a/ClosedXML.Tests/Utils/StreamHelper.cs +++ b/ClosedXML.Tests/Utils/StreamHelper.cs @@ -23,7 +23,10 @@ public static class StreamHelper private static readonly IEnumerable<(string PartSubstring, XName NodeName, XName AttrName)> ignoredAttributes = new List<(string PartSubstring, XName NodeName, XName AttrName)> { - ("sheet", XName.Get("cfRule", @"http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"), XName.Get("id")) + ("sheet", XName.Get("cfRule", @"http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"), XName.Get("id")), + // count and uniqueCount were removed due to streaming, but they are used by every file, for now ignore difference. + ("/xl/sharedStrings.xml", XName.Get("sst", @"http://schemas.openxmlformats.org/spreadsheetml/2006/main"), XName.Get("count")), + ("/xl/sharedStrings.xml", XName.Get("sst", @"http://schemas.openxmlformats.org/spreadsheetml/2006/main"), XName.Get("uniqueCount")), }; public static void StreamToStreamAppend(Stream streamIn, Stream streamToWrite) diff --git a/ClosedXML.sln b/ClosedXML.sln index e42d63923..df90bba72 100644 --- a/ClosedXML.sln +++ b/ClosedXML.sln @@ -24,6 +24,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{073C .github\PULL_REQUEST_TEMPLATE.md = .github\PULL_REQUEST_TEMPLATE.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClosedXML.IO", "ClosedXML.IO\ClosedXML.IO.csproj", "{4C18F5F6-A34A-422E-A018-2D14EB0D234E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClosedXML.IO.CodeGen", "ClosedXML.IO.CodeGen\ClosedXML.IO.CodeGen.csproj", "{CDF9B5AA-7471-4654-AAEC-2186D3287C2F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,6 +50,14 @@ Global {09B066ED-E4A7-4545-A1A4-FF03DD524BDF}.Debug|Any CPU.Build.0 = Debug|Any CPU {09B066ED-E4A7-4545-A1A4-FF03DD524BDF}.Release|Any CPU.ActiveCfg = Release|Any CPU {09B066ED-E4A7-4545-A1A4-FF03DD524BDF}.Release|Any CPU.Build.0 = Release|Any CPU + {4C18F5F6-A34A-422E-A018-2D14EB0D234E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C18F5F6-A34A-422E-A018-2D14EB0D234E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C18F5F6-A34A-422E-A018-2D14EB0D234E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C18F5F6-A34A-422E-A018-2D14EB0D234E}.Release|Any CPU.Build.0 = Release|Any CPU + {CDF9B5AA-7471-4654-AAEC-2186D3287C2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CDF9B5AA-7471-4654-AAEC-2186D3287C2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CDF9B5AA-7471-4654-AAEC-2186D3287C2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CDF9B5AA-7471-4654-AAEC-2186D3287C2F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ClosedXML.sln.DotSettings b/ClosedXML.sln.DotSettings new file mode 100644 index 000000000..427c2593f --- /dev/null +++ b/ClosedXML.sln.DotSettings @@ -0,0 +1,171 @@ + + False + CF + NA + RC + True + True + True + True + False + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + diff --git a/ClosedXML/Attributes/XLColumnAttribute.cs b/ClosedXML/Attributes/XLColumnAttribute.cs index 67ab26fd0..c71b5fe9d 100644 --- a/ClosedXML/Attributes/XLColumnAttribute.cs +++ b/ClosedXML/Attributes/XLColumnAttribute.cs @@ -1,4 +1,3 @@ -using ClosedXML.Excel; using System; using System.Linq; using System.Reflection; @@ -8,17 +7,17 @@ namespace ClosedXML.Attributes [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] public class XLColumnAttribute : Attribute { - public String Header { get; set; } + public String? Header { get; set; } public Boolean Ignore { get; set; } public Int32 Order { get; set; } - private static XLColumnAttribute GetXLColumnAttribute(MemberInfo mi) + private static XLColumnAttribute? GetXLColumnAttribute(MemberInfo mi) { if (!mi.HasAttribute()) return null; return mi.GetAttributes().First(); } - internal static String GetHeader(MemberInfo mi) + internal static String? GetHeader(MemberInfo mi) { var attribute = GetXLColumnAttribute(mi); if (attribute == null) return null; diff --git a/ClosedXML/ClosedXML.csproj b/ClosedXML/ClosedXML.csproj index cbf4452c1..5c5cb4080 100644 --- a/ClosedXML/ClosedXML.csproj +++ b/ClosedXML/ClosedXML.csproj @@ -1,7 +1,13 @@  - netstandard2.0 + 11.0 + netstandard2.0;netstandard2.1 + + annotations + enable @@ -10,29 +16,46 @@ - - $(NoWarn);NU1605;NU5104;CS1591 + $(NoWarn);NU1605;CS1591 + + + + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + - - - + + + + + + diff --git a/ClosedXML/ClosedXML.csproj.DotSettings b/ClosedXML/ClosedXML.csproj.DotSettings new file mode 100644 index 000000000..7f1ab0613 --- /dev/null +++ b/ClosedXML/ClosedXML.csproj.DotSettings @@ -0,0 +1,12 @@ + + True + True + True + True + True + True + True + True + True + True + True diff --git a/ClosedXML/ClosedXML.nuspec b/ClosedXML/ClosedXML.nuspec deleted file mode 100644 index 1ada2d838..000000000 --- a/ClosedXML/ClosedXML.nuspec +++ /dev/null @@ -1,21 +0,0 @@ - - - - $id$ - $version$ - Codestin Search App - $author$ - $author$ - ClosedXML is a .NET library for reading, manipulating and writing Excel 2007+ (.xlsx, .xlsm) files. It aims to provide an intuitive and user-friendly interface to dealing with the underlying OpenXML API. - https://github.com/ClosedXML/ClosedXML/blob/master/LICENSE - https://github.com/ClosedXML/ClosedXML - false - @ClosedXML - ClosedXML - - - - - - - \ No newline at end of file diff --git a/ClosedXML/Excel/AutoFilters/IXLAutoFilter.cs b/ClosedXML/Excel/AutoFilters/IXLAutoFilter.cs index 8afbac971..bb0c6f13a 100644 --- a/ClosedXML/Excel/AutoFilters/IXLAutoFilter.cs +++ b/ClosedXML/Excel/AutoFilters/IXLAutoFilter.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; @@ -6,30 +8,136 @@ namespace ClosedXML.Excel { public enum XLFilterDynamicType { AboveAverage, BelowAverage } - public enum XLFilterType { Regular, Custom, TopBottom, Dynamic, DateTimeGrouping } + public enum XLFilterType { None, Regular, Custom, TopBottom, Dynamic } public enum XLTopBottomPart { Top, Bottom } + /// + /// + /// Autofilter can sort and filter (hide) values in a non-empty area of a sheet. Each table can + /// have autofilter and each worksheet can have at most one range with an autofilter. First row + /// of the area contains headers, remaining rows contain sorted and filtered data. + /// + /// + /// Sorting of rows is done method, using the passed parameters. The sort + /// properties ( and ) are updated from + /// properties passed to the method. Sorting can be done only on values of + /// one column. + /// + /// + /// Autofilter can filter rows through method. The filter evaluates + /// conditions of the autofilter and leaves visible only rows that satisfy the conditions. + /// Rows that don't satisfy filter conditions are marked as hidden. + /// Filter conditions can be specified for each column (accessible through + /// methods), e.g. sheet.AutoFilter.Column(1).Top(10, XLTopBottomType.Percent) + /// creates a filter that displays only rows with values in top 10% percentile. + /// + /// public interface IXLAutoFilter { - [Obsolete("Use IsEnabled")] - Boolean Enabled { get; set; } + /// + /// Get rows of that were hidden because they didn't satisfy filter + /// conditions during last filtering. + /// + /// + /// Visibility is automatically updated on filter change. + /// IEnumerable HiddenRows { get; } + + /// + /// Is autofilter enabled? When autofilter is enabled, it shows the arrow buttons and might + /// contain some filter that hide some rows. Disabled autofilter doesn't show arrow buttons + /// and all rows are visible. + /// Boolean IsEnabled { get; set; } - IXLRange Range { get; set; } - Int32 SortColumn { get; set; } - Boolean Sorted { get; set; } - XLSortOrder SortOrder { get; set; } + + /// + /// Range of the autofilter. It consists of a header in first row, followed by data rows. + /// It doesn't include totals row for tables. + /// + IXLRange Range { get; } + + /// + /// What column was used during last . Contains undefined value for not + /// yet autofilter. + /// + Int32 SortColumn { get; } + + /// + /// Are values in the autofilter range sorted? I.e. the values were either already loaded + /// sorted or has been called at least once. + /// + /// + /// If true, and contain valid values. + /// + Boolean Sorted { get; } + + /// + /// What sorting order was used during last . Contains undefined value + /// for not yet autofilter. + /// + XLSortOrder SortOrder { get; } + + /// + /// Get rows of that are visible because they satisfied filter + /// conditions during last filtering. + /// + /// + /// Visibility is not updated on filter change. + /// IEnumerable VisibleRows { get; } + /// + /// Disable autofilter, remove all filters and unhide all rows of the . + /// IXLAutoFilter Clear(); - IXLFilterColumn Column(String column); + /// + /// Get filter configuration for a column. + /// + /// + /// Column letter that determines number in the range, from A as the first column + /// of a . + /// + /// Filter configuration for the column. + /// Invalid column. + IXLFilterColumn Column(String columnLetter); - IXLFilterColumn Column(Int32 column); + /// + /// Get filter configuration for a column. + /// + /// Column number in the range, from 1 as the first column of a . + /// Filter configuration for the column. + IXLFilterColumn Column(Int32 columnNumber); + /// + /// Apply autofilter filters to the range and show every row that satisfies the conditions + /// and hide the ones that don't satisfy conditions. + /// + /// + /// Filter is generally automatically applied on a filter change. This method could be + /// called after a cell value change or row deletion. + /// IXLAutoFilter Reapply(); + /// + /// Sort rows of the range using data of one column. + /// + /// + /// This method sets , and properties. + /// + /// + /// Column number in the range, from 1 to width of the . + /// + /// Should rows be sorted in ascending or descending order? + /// + /// Should values on the column be matched case sensitive. + /// + /// + /// true - rows with blank value in the column will always at the end, regardless of + /// sorting order. false - blank will be treated as empty string and sorted + /// accordingly. + /// IXLAutoFilter Sort(Int32 columnToSortBy = 1, XLSortOrder sortOrder = XLSortOrder.Ascending, Boolean matchCase = false, Boolean ignoreBlanks = true); } } diff --git a/ClosedXML/Excel/AutoFilters/IXLCustomFilteredColumn.cs b/ClosedXML/Excel/AutoFilters/IXLCustomFilteredColumn.cs index 37c6a1726..3d2d5a6f0 100644 --- a/ClosedXML/Excel/AutoFilters/IXLCustomFilteredColumn.cs +++ b/ClosedXML/Excel/AutoFilters/IXLCustomFilteredColumn.cs @@ -3,17 +3,17 @@ namespace ClosedXML.Excel { public interface IXLCustomFilteredColumn { - void EqualTo(T value) where T : IComparable; - void NotEqualTo(T value) where T : IComparable; - void GreaterThan(T value) where T : IComparable; - void LessThan(T value) where T : IComparable; - void EqualOrGreaterThan(T value) where T : IComparable; - void EqualOrLessThan(T value) where T : IComparable; - void BeginsWith(String value); - void NotBeginsWith(String value); - void EndsWith(String value); - void NotEndsWith(String value); - void Contains(String value); - void NotContains(String value); + void EqualTo(XLCellValue value, bool reapply = true); + void NotEqualTo(XLCellValue value, bool reapply = true); + void GreaterThan(XLCellValue value, bool reapply = true); + void LessThan(XLCellValue value, bool reapply = true); + void EqualOrGreaterThan(XLCellValue value, bool reapply = true); + void EqualOrLessThan(XLCellValue value, bool reapply = true); + void BeginsWith(String value, bool reapply = true); + void NotBeginsWith(String value, bool reapply = true); + void EndsWith(String value, bool reapply = true); + void NotEndsWith(String value, bool reapply = true); + void Contains(String value, bool reapply = true); + void NotContains(String value, bool reapply = true); } -} \ No newline at end of file +} diff --git a/ClosedXML/Excel/AutoFilters/IXLFilterColumn.cs b/ClosedXML/Excel/AutoFilters/IXLFilterColumn.cs index 106f48560..078f3567c 100644 --- a/ClosedXML/Excel/AutoFilters/IXLFilterColumn.cs +++ b/ClosedXML/Excel/AutoFilters/IXLFilterColumn.cs @@ -2,71 +2,217 @@ namespace ClosedXML.Excel { - public enum XLTopBottomType { Items, Percent } + /// + /// Type of a filter that is used to determine number of + /// visible top/bottom values. + /// + public enum XLTopBottomType + { + /// + /// Filter should display requested number of items. + /// + Items, + + /// + /// Number of displayed items is determined as a percentage data rows of auto filter. + /// + Percent + } public enum XLDateTimeGrouping { Year, Month, Day, Hour, Minute, Second } + /// + /// + /// AutoFilter filter configuration for one column in an autofilter area. + /// Filters determine visibility of rows in the autofilter area. Value in the row must satisfy + /// all filters in all columns in order for row to be visible, otherwise it is . + /// + /// + /// Column can have only one type of filter, so it's not possible to combine several different + /// filter types on one column. Methods for adding filters clear other types or remove + /// previously set filters when needed. Some types of filters can have multiple conditions (e.g. + /// can have many values while + /// can be only one). + /// + /// + /// Whenever filter configuration changes, the filters are immediately reapplied. + /// + /// public interface IXLFilterColumn { - void Clear(); - - IXLFilteredColumn AddFilter(T value) where T : IComparable; - - IXLDateTimeGroupFilteredColumn AddDateGroupFilter(DateTime date, XLDateTimeGrouping dateTimeGrouping); - - void Top(Int32 value, XLTopBottomType type = XLTopBottomType.Items); - - void Bottom(Int32 value, XLTopBottomType type = XLTopBottomType.Items); - - void AboveAverage(); - - void BelowAverage(); - - IXLFilterConnector EqualTo(T value) where T : IComparable; - - IXLFilterConnector NotEqualTo(T value) where T : IComparable; - - IXLFilterConnector GreaterThan(T value) where T : IComparable; - - IXLFilterConnector LessThan(T value) where T : IComparable; - - IXLFilterConnector EqualOrGreaterThan(T value) where T : IComparable; - - IXLFilterConnector EqualOrLessThan(T value) where T : IComparable; - - void Between(T minValue, T maxValue) where T : IComparable; - - void NotBetween(T minValue, T maxValue) where T : IComparable; - - IXLFilterConnector BeginsWith(String value); - - IXLFilterConnector NotBeginsWith(String value); - - IXLFilterConnector EndsWith(String value); - - IXLFilterConnector NotEndsWith(String value); - - IXLFilterConnector Contains(String value); - - IXLFilterConnector NotContains(String value); - - XLFilterType FilterType { get; set; } - Int32 TopBottomValue { get; set; } - XLTopBottomType TopBottomType { get; set; } - XLTopBottomPart TopBottomPart { get; set; } - XLFilterDynamicType DynamicType { get; set; } - Double DynamicValue { get; set; } - - IXLFilterColumn SetFilterType(XLFilterType value); - - IXLFilterColumn SetTopBottomValue(Int32 value); - - IXLFilterColumn SetTopBottomType(XLTopBottomType value); - - IXLFilterColumn SetTopBottomPart(XLTopBottomPart value); - - IXLFilterColumn SetDynamicType(XLFilterDynamicType value); - - IXLFilterColumn SetDynamicValue(Double value); + /// + /// Remove all filters from the column. + /// + /// Should the autofilter be immediately reapplied? + void Clear(bool reapply = true); + + /// + /// + /// Switch to the filter if filter column has a + /// different type (for current type ) and add + /// to a set of allowed values. Excel displays regular filter as + /// a list of possible values in a column with checkbox next to it and user can check which + /// one should be displayed. + /// + /// + /// From technical perspective, the passed is converted to + /// a localized string (using current locale) and the column values satisfy the filter + /// condition, when the formatted string of a cell + /// matches any filter string. + /// + /// + /// Examples of less intuitive behavior: filter value is 2.5 in locale cs-CZ that + /// uses "," as a decimal separator. The passed + /// is number 2.5, converted immediately to a string 2,5. The string is used for + /// comparison with values of cells in the column: + /// + /// Number 2.5 formatted with two decimal places as 2,50 will not match. + /// Number 2.5 with default formatting will be matched, because its string is + /// 2,5 in cs-CZ locale (but not in others, e.g. en-US locale). + /// Text 2,5 will be matched. + /// + /// + /// + /// + /// This behavior of course highly depends on locale and working with same file on two + /// different locales might lead to different results. + /// + /// Value of the filter. The type is XLCellValue, but that's for + /// convenience sake. The value is converted to a string and filter works with string. + /// Should the autofilter be immediately reapplied? + /// Fluent API allowing to add additional filter value. + IXLFilteredColumn AddFilter(XLCellValue value, bool reapply = true); + + /// + /// + /// Enable autofilter (if needed), switch to the filter + /// if filter column has a different type (for current type ) and + /// add a filter that is satisfied when cell value is a + /// and the tested date has same components from + /// component up to the component with same value + /// as the . + /// + /// + /// The condition basically defines a date range (based on the ) + /// and all dates in the range satisfy the filter. If condition is a day, all date-times + /// in the day satisfy the filter. If condition is a month, all date-times in the month + /// satisfy the filter. + /// + /// + /// + /// Example: + /// + /// // Filter will be satisfied if the cell value is a XLDataType.DateTime and the month, + /// // and year are same as the passed date. The day component in the DateTime + /// // is ignored + /// AddDateGroupFilter(new DateTime(2023, 7, 15), XLDateTimeGrouping.Month) + /// + /// + /// + /// + /// There can be multiple date group filters and they are + /// filter types, i.e. they don't delete filters from . The cell + /// value is satisfied, if it matches any of the text values from + /// or any date group filter. + /// + /// + /// Date which components are compared with date values of the column. + /// + /// Starting component of the grouping. Tested date must match all date components of the + /// from this one to the . + /// + /// Should the autofilter be immediately reapplied? + /// Fluent API allowing to add additional date time group value. + IXLFilteredColumn AddDateGroupFilter(DateTime date, XLDateTimeGrouping dateTimeGrouping, bool reapply = true); + + /// If is out of range 1..500. + void Top(Int32 value, XLTopBottomType type = XLTopBottomType.Items, bool reapply = true); + + /// If is out of range 1..500. + void Bottom(Int32 value, XLTopBottomType type = XLTopBottomType.Items, bool reapply = true); + + void AboveAverage(bool reapply = true); + + void BelowAverage(bool reapply = true); + + IXLFilterConnector EqualTo(XLCellValue value, bool reapply = true); + + IXLFilterConnector NotEqualTo(XLCellValue value, bool reapply = true); + + IXLFilterConnector GreaterThan(XLCellValue value, bool reapply = true); + + IXLFilterConnector LessThan(XLCellValue value, bool reapply = true); + + IXLFilterConnector EqualOrGreaterThan(XLCellValue value, bool reapply = true); + + IXLFilterConnector EqualOrLessThan(XLCellValue value, bool reapply = true); + + void Between(XLCellValue minValue, XLCellValue maxValue, bool reapply = true); + + void NotBetween(XLCellValue minValue, XLCellValue maxValue, bool reapply = true); + + IXLFilterConnector BeginsWith(String value, bool reapply = true); + + IXLFilterConnector NotBeginsWith(String value, bool reapply = true); + + IXLFilterConnector EndsWith(String value, bool reapply = true); + + IXLFilterConnector NotEndsWith(String value, bool reapply = true); + + IXLFilterConnector Contains(String value, bool reapply = true); + + IXLFilterConnector NotContains(String value, bool reapply = true); + + /// + /// Current filter type used by the filter columns. + /// + XLFilterType FilterType { get; } + + /// + /// Configuration of a filter. It contains how many + /// items/percent (depends on ) should filter accept. + /// + /// + /// Returns undefined value, if is not . + /// + Int32 TopBottomValue { get; } + + /// + /// Configuration of a filter. It contains the content + /// interpretation of a property, i.e. does it mean how many + /// percents or how many items? + /// + /// + /// Returns undefined value, if is not . + /// + XLTopBottomType TopBottomType { get; } + + /// + /// Configuration of a filter. It determines if filter + /// should accept items from top or bottom. + /// + /// + /// Returns undefined value, if is not . + /// + XLTopBottomPart TopBottomPart { get; } + + /// + /// Configuration of a filter. It determines the type of + /// dynamic filter. + /// + /// + /// Returns undefined value, if is not . + /// + XLFilterDynamicType DynamicType { get; } + + /// + /// Configuration of a filter. It contains the dynamic + /// value used by the filter, e.g. average. The interpretation depends on + /// . + /// + /// + /// Returns undefined value, if is not . + /// + Double DynamicValue { get; } } } diff --git a/ClosedXML/Excel/AutoFilters/IXLFilterConnector.cs b/ClosedXML/Excel/AutoFilters/IXLFilterConnector.cs index d5d52be4d..5c9eb5792 100644 --- a/ClosedXML/Excel/AutoFilters/IXLFilterConnector.cs +++ b/ClosedXML/Excel/AutoFilters/IXLFilterConnector.cs @@ -1,5 +1,3 @@ -using System; - namespace ClosedXML.Excel { public interface IXLFilterConnector diff --git a/ClosedXML/Excel/AutoFilters/IXLFilteredColumn.cs b/ClosedXML/Excel/AutoFilters/IXLFilteredColumn.cs index 86db9db51..656314093 100644 --- a/ClosedXML/Excel/AutoFilters/IXLFilteredColumn.cs +++ b/ClosedXML/Excel/AutoFilters/IXLFilteredColumn.cs @@ -1,8 +1,28 @@ using System; -namespace ClosedXML.Excel + +namespace ClosedXML.Excel; + +/// +/// A fluent API interface for adding another values to a +/// filter. It is chained by method or +/// . +/// +public interface IXLFilteredColumn { - public interface IXLFilteredColumn - { - IXLFilteredColumn AddFilter(T value) where T : IComparable; - } -} \ No newline at end of file + /// + /// Add another value to a subset of allowed values for a + /// filter. See for more details. + /// + /// Value of the filter. The type is XLCellValue, but that's for + /// convenience sake. The value is converted to a string and filter works with string. + /// Should the autofilter be immediately reapplied? + /// Fluent API allowing to add additional filter value. + IXLFilteredColumn AddFilter(XLCellValue value, bool reapply = true); + + /// + /// Add another grouping to a set of allowed groupings. See + /// for more details. + /// + /// Fluent API allowing to add additional date group filter. + IXLFilteredColumn AddDateGroupFilter(DateTime date, XLDateTimeGrouping dateTimeGrouping, bool reapply = true); +} diff --git a/ClosedXML/Excel/AutoFilters/XLAutoFilter.cs b/ClosedXML/Excel/AutoFilters/XLAutoFilter.cs index 35a51cfad..d48e6f4c7 100644 --- a/ClosedXML/Excel/AutoFilters/XLAutoFilter.cs +++ b/ClosedXML/Excel/AutoFilters/XLAutoFilter.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Linq; @@ -8,111 +10,59 @@ namespace ClosedXML.Excel internal class XLAutoFilter : IXLAutoFilter { - private readonly Dictionary _columns = new Dictionary(); - - public XLAutoFilter() - { - Filters = new Dictionary>(); - } + /// + /// Key is column number. + /// + private readonly Dictionary _columns = new(); - public Dictionary> Filters { get; private set; } + internal IReadOnlyDictionary Columns => _columns; #region IXLAutoFilter Members - [Obsolete("Use IsEnabled")] - public Boolean Enabled { get => IsEnabled; set => IsEnabled = value; } - public IEnumerable HiddenRows { get => Range.Rows(r => r.WorksheetRow().IsHidden); } + public IEnumerable HiddenRows => Range.Rows(r => r.WorksheetRow().IsHidden); + public Boolean IsEnabled { get; set; } + public IXLRange Range { get; set; } + public Int32 SortColumn { get; set; } + public Boolean Sorted { get; set; } - public XLSortOrder SortOrder { get; set; } - public IEnumerable VisibleRows { get => Range.Rows(r => !r.WorksheetRow().IsHidden); } - IXLAutoFilter IXLAutoFilter.Clear() - { - return Clear(); - } + public XLSortOrder SortOrder { get; set; } - public IXLFilterColumn Column(String column) - { - var columnNumber = XLHelper.GetColumnNumberFromLetter(column); - if (columnNumber < 1 || columnNumber > XLHelper.MaxColumnNumber) - throw new ArgumentOutOfRangeException(nameof(column), "Column '" + column + "' is outside the allowed column range."); + public IEnumerable VisibleRows => Range.Rows(r => !r.WorksheetRow().IsHidden); - return Column(columnNumber); - } + IXLAutoFilter IXLAutoFilter.Clear() => Clear(); - public IXLFilterColumn Column(Int32 column) - { - if (column < 1 || column > XLHelper.MaxColumnNumber) - throw new ArgumentOutOfRangeException(nameof(column), "Column " + column + " is outside the allowed column range."); + IXLFilterColumn IXLAutoFilter.Column(String columnLetter) => Column(columnLetter); - if (!_columns.TryGetValue(column, out XLFilterColumn filterColumn)) - { - filterColumn = new XLFilterColumn(this, column); - _columns.Add(column, filterColumn); - } + IXLFilterColumn IXLAutoFilter.Column(Int32 columnNumber) => Column(columnNumber); - return filterColumn; + IXLAutoFilter IXLAutoFilter.Sort(Int32 columnToSortBy, XLSortOrder sortOrder, Boolean matchCase, Boolean ignoreBlanks) + { + return Sort(columnToSortBy, sortOrder, matchCase, ignoreBlanks); } public IXLAutoFilter Reapply() { - var ws = Range.Worksheet as XLWorksheet; - ws.SuspendEvents(); - // Recalculate shown / hidden rows var rows = Range.Rows(2, Range.RowCount()); rows.ForEach(row => row.WorksheetRow().Unhide() ); + foreach (var filterColumn in _columns.Values) + filterColumn.Refresh(); + foreach (IXLRangeRow row in rows) { var rowMatch = true; - foreach (var columnIndex in Filters.Keys) + foreach (var (columnIndex, column) in _columns) { - var columnFilters = Filters[columnIndex]; - - var columnFilterMatch = true; - - // If the first filter is an 'Or', we need to fudge the initial condition - if (columnFilters.Count > 0 && columnFilters.First().Connector == XLConnector.Or) - { - columnFilterMatch = false; - } - - foreach (var filter in columnFilters) - { - var condition = filter.Condition; - var isText = filter.Value is String; - var isDateTime = filter.Value is DateTime; - - Boolean filterMatch; - - if (isText) - filterMatch = condition(row.Cell(columnIndex).GetFormattedString()); - else if (isDateTime) - filterMatch = row.Cell(columnIndex).DataType == XLDataType.DateTime && - condition(row.Cell(columnIndex).GetDateTime()); - else - filterMatch = row.Cell(columnIndex).DataType == XLDataType.Number && - condition(row.Cell(columnIndex).GetDouble()); - - if (filter.Connector == XLConnector.And) - { - columnFilterMatch &= filterMatch; - if (!columnFilterMatch) break; - } - else - { - columnFilterMatch |= filterMatch; - if (columnFilterMatch) break; - } - } - + var cell = row.Cell(columnIndex); + var columnFilterMatch = column.Check(cell); rowMatch &= columnFilterMatch; if (!rowMatch) break; @@ -121,30 +71,48 @@ public IXLAutoFilter Reapply() if (!rowMatch) row.WorksheetRow().Hide(); } - ws.ResumeEvents(); return this; } - IXLAutoFilter IXLAutoFilter.Sort(Int32 columnToSortBy, XLSortOrder sortOrder, Boolean matchCase, - Boolean ignoreBlanks) + #endregion IXLAutoFilter Members + + internal XLFilterColumn Column(String columnLetter) { - return Sort(columnToSortBy, sortOrder, matchCase, ignoreBlanks); + var columnNumber = XLHelper.GetColumnNumberFromLetter(columnLetter); + if (columnNumber < 1 || columnNumber > XLHelper.MaxColumnNumber) + throw new ArgumentOutOfRangeException(nameof(columnLetter), "Column '" + columnLetter + "' is outside the allowed column range."); + + return Column(columnNumber); } - #endregion IXLAutoFilter Members + internal XLFilterColumn Column(Int32 columnNumber) + { + if (columnNumber < 1 || columnNumber > XLHelper.MaxColumnNumber) + throw new ArgumentOutOfRangeException(nameof(columnNumber), "Column " + columnNumber + " is outside the allowed column range."); + + if (!_columns.TryGetValue(columnNumber, out XLFilterColumn filterColumn)) + { + filterColumn = new XLFilterColumn(this, columnNumber); + _columns.Add(columnNumber, filterColumn); + } - public XLAutoFilter Clear() + return filterColumn; + } + + internal XLAutoFilter Clear() { if (!IsEnabled) return this; IsEnabled = false; - Filters.Clear(); + foreach (var filterColumn in _columns.Values) + filterColumn.Clear(false); + foreach (IXLRangeRow row in Range.Rows().Where(r => r.RowNumber() > 1)) row.WorksheetRow().Unhide(); return this; } - public XLAutoFilter Set(IXLRangeBase range) + internal XLAutoFilter Set(IXLRangeBase range) { var firstOverlappingTable = range.Worksheet.Tables.FirstOrDefault(t => t.RangeUsed().Intersects(range)); if (firstOverlappingTable != null) @@ -155,13 +123,11 @@ public XLAutoFilter Set(IXLRangeBase range) return this; } - public XLAutoFilter Sort(Int32 columnToSortBy, XLSortOrder sortOrder, Boolean matchCase, Boolean ignoreBlanks) + internal XLAutoFilter Sort(Int32 columnToSortBy, XLSortOrder sortOrder, Boolean matchCase, Boolean ignoreBlanks) { if (!IsEnabled) throw new InvalidOperationException("Filter has not been enabled."); - var ws = Range.Worksheet as XLWorksheet; - ws.SuspendEvents(); Range.Range(Range.FirstCell().CellBelow(), Range.LastCell()).Sort(columnToSortBy, sortOrder, matchCase, ignoreBlanks); @@ -169,8 +135,6 @@ public XLAutoFilter Sort(Int32 columnToSortBy, XLSortOrder sortOrder, Boolean ma SortOrder = sortOrder; SortColumn = columnToSortBy; - ws.ResumeEvents(); - Reapply(); return this; diff --git a/ClosedXML/Excel/AutoFilters/XLCustomFilteredColumn.cs b/ClosedXML/Excel/AutoFilters/XLCustomFilteredColumn.cs index 4c6bd52b4..f36350705 100644 --- a/ClosedXML/Excel/AutoFilters/XLCustomFilteredColumn.cs +++ b/ClosedXML/Excel/AutoFilters/XLCustomFilteredColumn.cs @@ -1,141 +1,85 @@ using System; -using System.Linq; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +internal class XLCustomFilteredColumn : IXLCustomFilteredColumn { - internal class XLCustomFilteredColumn : IXLCustomFilteredColumn + private readonly XLFilterColumn _filterColumn; + private readonly XLConnector _connector; + + public XLCustomFilteredColumn(XLFilterColumn filterColumn, XLConnector connector) + { + _filterColumn = filterColumn; + _connector = connector; + } + + public void EqualTo(XLCellValue value, Boolean reapply) + { + ApplyCustomFilter(value, XLFilterOperator.Equal, reapply); + } + + public void NotEqualTo(XLCellValue value, Boolean reapply) + { + ApplyCustomFilter(value, XLFilterOperator.NotEqual, reapply); + } + + public void GreaterThan(XLCellValue value, Boolean reapply) + { + ApplyCustomFilter(value, XLFilterOperator.GreaterThan, reapply); + } + + public void LessThan(XLCellValue value, Boolean reapply) + { + ApplyCustomFilter(value, XLFilterOperator.LessThan, reapply); + } + + public void EqualOrGreaterThan(XLCellValue value, Boolean reapply) + { + ApplyCustomFilter(value, XLFilterOperator.EqualOrGreaterThan, reapply); + } + + public void EqualOrLessThan(XLCellValue value, Boolean reapply) + { + ApplyCustomFilter(value, XLFilterOperator.EqualOrLessThan, reapply); + } + + public void BeginsWith(String value, Boolean reapply) + { + ApplyWildcardCustomFilter(value + "*", true, reapply); + } + + public void NotBeginsWith(String value, Boolean reapply) + { + ApplyWildcardCustomFilter(value + "*", false, reapply); + } + + public void EndsWith(String value, Boolean reapply) + { + ApplyWildcardCustomFilter("*" + value, true, reapply); + } + + public void NotEndsWith(String value, Boolean reapply) + { + ApplyWildcardCustomFilter("*" + value, false, reapply); + } + + public void Contains(String value, Boolean reapply) + { + ApplyWildcardCustomFilter("*" + value + "*", true, reapply); + } + + public void NotContains(String value, Boolean reapply) + { + ApplyWildcardCustomFilter("*" + value + "*", false, reapply); + } + + private void ApplyCustomFilter(XLCellValue value, XLFilterOperator op, bool reapply) + { + _filterColumn.AddFilter(XLFilter.CreateCustomFilter(value, op, _connector), reapply); + } + + private void ApplyWildcardCustomFilter(string pattern, bool match, bool reapply) { - private readonly XLAutoFilter _autoFilter; - private readonly Int32 _column; - private readonly XLConnector _connector; - - public XLCustomFilteredColumn(XLAutoFilter autoFilter, Int32 column, XLConnector connector) - { - _autoFilter = autoFilter; - _column = column; - _connector = connector; - } - - #region IXLCustomFilteredColumn Members - - public void EqualTo(T value) where T: IComparable - { - if (typeof(T) == typeof(String)) - { - ApplyCustomFilter(value, XLFilterOperator.Equal, - v => - v.ToString().Equals(value.ToString(), StringComparison.InvariantCultureIgnoreCase)); - } - else - { - ApplyCustomFilter(value, XLFilterOperator.Equal, - v => v.CastTo().CompareTo(value) == 0); - } - } - - public void NotEqualTo(T value) where T: IComparable - { - if (typeof(T) == typeof(String)) - { - ApplyCustomFilter(value, XLFilterOperator.NotEqual, - v => - !v.ToString().Equals(value.ToString(), StringComparison.InvariantCultureIgnoreCase)); - } - else - { - ApplyCustomFilter(value, XLFilterOperator.NotEqual, - v => v.CastTo().CompareTo(value) != 0); - } - } - - public void GreaterThan(T value) where T: IComparable - { - ApplyCustomFilter(value, XLFilterOperator.GreaterThan, - v => v.CastTo().CompareTo(value) > 0); - } - - public void LessThan(T value) where T: IComparable - { - ApplyCustomFilter(value, XLFilterOperator.LessThan, v => v.CastTo().CompareTo(value) < 0); - } - - public void EqualOrGreaterThan(T value) where T: IComparable - { - ApplyCustomFilter(value, XLFilterOperator.EqualOrGreaterThan, - v => v.CastTo().CompareTo(value) >= 0); - } - - public void EqualOrLessThan(T value) where T: IComparable - { - ApplyCustomFilter(value, XLFilterOperator.EqualOrLessThan, - v => v.CastTo().CompareTo(value) <= 0); - } - - public void BeginsWith(String value) - { - ApplyCustomFilter(value + "*", XLFilterOperator.Equal, - s => ((string)s).StartsWith(value, StringComparison.InvariantCultureIgnoreCase)); - } - - public void NotBeginsWith(String value) - { - ApplyCustomFilter(value + "*", XLFilterOperator.NotEqual, - s => - !((string)s).StartsWith(value, StringComparison.InvariantCultureIgnoreCase)); - } - - public void EndsWith(String value) - { - ApplyCustomFilter("*" + value, XLFilterOperator.Equal, - s => ((string)s).EndsWith(value, StringComparison.InvariantCultureIgnoreCase)); - } - - public void NotEndsWith(String value) - { - ApplyCustomFilter("*" + value, XLFilterOperator.NotEqual, - s => !((string)s).EndsWith(value, StringComparison.InvariantCultureIgnoreCase)); - } - - public void Contains(String value) - { - ApplyCustomFilter("*" + value + "*", XLFilterOperator.Equal, - s => ((string)s).ToLower().Contains(value.ToLower())); - } - - public void NotContains(String value) - { - ApplyCustomFilter("*" + value + "*", XLFilterOperator.Equal, - s => !((string)s).ToLower().Contains(value.ToLower())); - } - - #endregion - - private void ApplyCustomFilter(T value, XLFilterOperator op, Func condition) - where T : IComparable - { - _autoFilter.Filters[_column].Add(new XLFilter - { - Value = value, - Operator = op, - Connector = _connector, - Condition = condition - }); - var rows = _autoFilter.Range.Rows(2, _autoFilter.Range.RowCount()); - foreach (IXLRangeRow row in rows) - { - if (_connector == XLConnector.And) - { - if (!row.WorksheetRow().IsHidden) - { - if (condition(row.Cell(_column).GetValue())) - row.WorksheetRow().Unhide(); - else - row.WorksheetRow().Hide(); - } - } - else if (condition(row.Cell(_column).GetValue())) - row.WorksheetRow().Unhide(); - } - } + _filterColumn.AddFilter(XLFilter.CreateCustomPatternFilter(pattern, match, _connector), reapply); } } diff --git a/ClosedXML/Excel/AutoFilters/XLDateTimeGroupFilteredColumn.cs b/ClosedXML/Excel/AutoFilters/XLDateTimeGroupFilteredColumn.cs deleted file mode 100644 index 981532a59..000000000 --- a/ClosedXML/Excel/AutoFilters/XLDateTimeGroupFilteredColumn.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; - -namespace ClosedXML.Excel -{ - public interface IXLDateTimeGroupFilteredColumn - { - IXLDateTimeGroupFilteredColumn AddDateGroupFilter(DateTime date, XLDateTimeGrouping dateTimeGrouping); - } - - internal class XLDateTimeGroupFilteredColumn : IXLDateTimeGroupFilteredColumn - { - private readonly XLAutoFilter _autoFilter; - private readonly Int32 _column; - - public XLDateTimeGroupFilteredColumn(XLAutoFilter autoFilter, Int32 column) - { - _autoFilter = autoFilter; - _column = column; - } - - public IXLDateTimeGroupFilteredColumn AddDateGroupFilter(DateTime date, XLDateTimeGrouping dateTimeGrouping) - { - Func condition = date2 => IsMatch(date, (DateTime) date2, dateTimeGrouping); - - _autoFilter.Filters[_column].Add(new XLFilter - { - Value = date, - Condition = condition, - Operator = XLFilterOperator.Equal, - Connector = XLConnector.Or, - DateTimeGrouping = dateTimeGrouping - }); - - var rows = _autoFilter.Range.Rows(2, _autoFilter.Range.RowCount()); - foreach (IXLRangeRow row in rows) - { - if (row.Cell(_column).DataType == XLDataType.DateTime && condition(row.Cell(_column).GetDateTime())) - row.WorksheetRow().Unhide(); - } - - return this; - } - - internal static Boolean IsMatch(DateTime date1, DateTime date2, XLDateTimeGrouping dateTimeGrouping) - { - Boolean isMatch = true; - if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Year) isMatch &= date1.Year.Equals(date2.Year); - if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Month) isMatch &= date1.Month.Equals(date2.Month); - if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Day) isMatch &= date1.Day.Equals(date2.Day); - if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Hour) isMatch &= date1.Hour.Equals(date2.Hour); - if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Minute) isMatch &= date1.Minute.Equals(date2.Minute); - if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Second) isMatch &= date1.Second.Equals(date2.Second); - - return isMatch; - } - } -} diff --git a/ClosedXML/Excel/AutoFilters/XLFilter.cs b/ClosedXML/Excel/AutoFilters/XLFilter.cs index 42ac11220..a11f6a3be 100644 --- a/ClosedXML/Excel/AutoFilters/XLFilter.cs +++ b/ClosedXML/Excel/AutoFilters/XLFilter.cs @@ -1,5 +1,9 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; +using System.Globalization; +using ClosedXML.Excel.CalcEngine; namespace ClosedXML.Excel { @@ -7,17 +11,223 @@ internal enum XLConnector { And, Or } internal enum XLFilterOperator { Equal, NotEqual, GreaterThan, LessThan, EqualOrGreaterThan, EqualOrLessThan } + /// + /// A single filter condition for auto filter. + /// internal class XLFilter { - public XLFilter(XLFilterOperator op = XLFilterOperator.Equal) + private XLFilter() { - Operator = op; } - public Func Condition { get; set; } public XLConnector Connector { get; set; } + public XLDateTimeGrouping DateTimeGrouping { get; set; } - public XLFilterOperator Operator { get; set; } + + /// + /// Value for that is compared using . + /// + public XLCellValue CustomValue { get; init; } + + public Func Condition { get; init; } + + public XLFilterOperator Operator { get; init; } = XLFilterOperator.Equal; + + /// + /// Value for filter. + /// public Object Value { get; set; } + + internal static XLFilter CreateCustomFilter(XLCellValue value, XLFilterOperator op, XLConnector connector) + { + // Keep in closure, so it doesn't have to be checked for every cell. + var comparer = StringComparer.CurrentCultureIgnoreCase; + return new XLFilter + { + CustomValue = value, + Operator = op, + Connector = connector, + Condition = (cell, _) => CustomFilterSatisfied(cell.CachedValue, op, value, comparer), + }; + } + + internal static XLFilter CreateCustomPatternFilter(string filterValue, bool match, XLConnector connector) + { + // Excel really parses value in current culture to detect type (e.g. 1,00 is detected as a number in cs-CZ). + var testValue = XLCellValue.FromText(filterValue, CultureInfo.CurrentCulture); + if (testValue.Type == XLDataType.Text) + { + // Custom filter Equal matches strings with a pattern. Custom uses it mostly for filters like begin-with (e.g. `ABC*`). + var wildcard = filterValue; + return new XLFilter + { + CustomValue = wildcard, + Operator = match ? XLFilterOperator.Equal : XLFilterOperator.NotEqual, + Connector = connector, + Condition = match ? (c, _) => TextMatchesWildcard(wildcard, c) : (c, _) => !TextMatchesWildcard(wildcard, c), + }; + } + + // Keep in closure, so it doesn't have to be checked for every cell. + var comparer = StringComparer.CurrentCultureIgnoreCase; + return new XLFilter + { + CustomValue = filterValue, + Operator = match ? XLFilterOperator.Equal : XLFilterOperator.NotEqual, + Connector = connector, + Condition = match ? (c, _) => ContentMatches(c, filterValue) + : (c, _) => !CustomFilterSatisfied(c.CachedValue, XLFilterOperator.Equal, testValue, comparer), + }; + + static bool TextMatchesWildcard(string pattern, IXLCell cell) + { + var cachedValue = cell.CachedValue; + if (!cachedValue.IsText) + return false; + + var wildcard = new Wildcard(pattern); + var position = wildcard.Search(cachedValue.GetText().AsSpan()); + return position >= 0; + } + } + + internal static XLFilter CreateRegularFilter(string filterValue) + { + + return new XLFilter + { + Value = filterValue, + Operator = XLFilterOperator.Equal, + Connector = XLConnector.Or, + Condition = (cell, _) => ContentMatches(cell, filterValue) + }; + } + + internal static XLFilter CreateDateGroupFilter(DateTime date, XLDateTimeGrouping dateTimeGrouping) + { + return new XLFilter + { + Value = date, + Condition = HasSameGroup, + Operator = XLFilterOperator.Equal, + Connector = XLConnector.Or, + DateTimeGrouping = dateTimeGrouping + }; + + bool HasSameGroup(IXLCell cell, XLFilterColumn _) + { + var cachedValue = cell.CachedValue; + return cachedValue.IsDateTime && IsMatch(date, cachedValue.GetDateTime(), dateTimeGrouping); + } + + static Boolean IsMatch(DateTime date1, DateTime date2, XLDateTimeGrouping dateTimeGrouping) + { + Boolean isMatch = true; + if (dateTimeGrouping >= XLDateTimeGrouping.Year) isMatch &= date1.Year.Equals(date2.Year); + if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Month) isMatch &= date1.Month.Equals(date2.Month); + if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Day) isMatch &= date1.Day.Equals(date2.Day); + if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Hour) isMatch &= date1.Hour.Equals(date2.Hour); + if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Minute) isMatch &= date1.Minute.Equals(date2.Minute); + if (isMatch && dateTimeGrouping >= XLDateTimeGrouping.Second) isMatch &= date1.Second.Equals(date2.Second); + + return isMatch; + } + } + + private static bool ContentMatches(IXLCell cell, string filterValue) + { + // IXLCell.GetFormattedString() could trigger formula evaluation. + var cachedValue = cell.CachedValue; + var formattedString = ((XLCell)cell).GetFormattedString(cachedValue); + return formattedString.Equals(filterValue, StringComparison.OrdinalIgnoreCase); + } + + private static bool CustomFilterSatisfied(XLCellValue cellValue, XLFilterOperator op, XLCellValue filterValue, StringComparer textComparer) + { + // Blanks are rather strange case. Excel parsing logic for custom filter value into + // XLCellValue is very inconsistent. E.g. 'does not equal' for empty string ignores + // blanks and empty strings. + // For custom compare filters, blank never matches. + if (cellValue.IsBlank || filterValue.IsBlank) + return false; + + if (cellValue.Type != filterValue.Type) + { + // Types are different, but could still be unified numbers and thus comparable. + if (!(cellValue.IsUnifiedNumber && filterValue.IsUnifiedNumber)) + return false; + } + + // Note that custom filter even error values, basically everything as a number. + var comparison = cellValue.Type switch + { + XLDataType.Text => textComparer.Compare(cellValue.GetText(), filterValue.GetText()), + XLDataType.Boolean => cellValue.GetBoolean().CompareTo(filterValue.GetBoolean()), + XLDataType.Error => cellValue.GetError().CompareTo(filterValue.GetError()), + _ => cellValue.GetUnifiedNumber().CompareTo(filterValue.GetUnifiedNumber()) + }; + + // !!! Deviation from Excel !!! + // Excel interprets custom filter with `equal` operator (and *only* equal operator) as + // comparison of formatted string of a cell value with wildcard represented by custom + // value filter value. + // We do the sane thing and compare them for equality, so $10 is equal to 10. + return op switch + { + XLFilterOperator.LessThan => comparison < 0, + XLFilterOperator.EqualOrLessThan => comparison <= 0, + XLFilterOperator.Equal => comparison == 0, + XLFilterOperator.NotEqual => comparison != 0, + XLFilterOperator.EqualOrGreaterThan => comparison >= 0, + XLFilterOperator.GreaterThan => comparison > 0, + _ => throw new NotSupportedException(), + }; + } + + internal static XLFilter CreateTopBottom(bool takeTop, int percentsOrItemCount) + { + bool TopFilter(IXLCell cell, XLFilterColumn filterColumn) + { + var cachedValue = cell.CachedValue; + return cachedValue.IsUnifiedNumber && cachedValue.GetUnifiedNumber() >= filterColumn.TopBottomFilterValue; + } + bool BottomFilter(IXLCell cell, XLFilterColumn filterColumn) + { + var cachedValue = cell.CachedValue; + return cachedValue.IsUnifiedNumber && cachedValue.GetUnifiedNumber() <= filterColumn.TopBottomFilterValue; + } + + return new XLFilter + { + Value = percentsOrItemCount, + Operator = XLFilterOperator.Equal, + Connector = XLConnector.Or, + Condition = takeTop ? TopFilter : BottomFilter, + }; + } + + internal static XLFilter CreateAverage(double initialAverage, bool aboveAverage) + { + bool AboveAverage(IXLCell cell, XLFilterColumn filterColumn) + { + var cachedValue = cell.CachedValue; + var average = filterColumn.DynamicValue; + return cachedValue.IsUnifiedNumber && cachedValue.GetUnifiedNumber() > average; + } + bool BelowAverage(IXLCell cell, XLFilterColumn filterColumn) + { + var cachedValue = cell.CachedValue; + var average = filterColumn.DynamicValue; + return cachedValue.IsUnifiedNumber && cachedValue.GetUnifiedNumber() < average; + } + + return new XLFilter + { + Value = initialAverage, + Operator = XLFilterOperator.Equal, + Connector = XLConnector.Or, + Condition = aboveAverage ? AboveAverage : BelowAverage, + }; + } } } diff --git a/ClosedXML/Excel/AutoFilters/XLFilterColumn.cs b/ClosedXML/Excel/AutoFilters/XLFilterColumn.cs index 3217cf75d..d57e5e1b4 100644 --- a/ClosedXML/Excel/AutoFilters/XLFilterColumn.cs +++ b/ClosedXML/Excel/AutoFilters/XLFilterColumn.cs @@ -1,14 +1,15 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Linq; namespace ClosedXML.Excel { - using System.Collections.Generic; - - internal class XLFilterColumn : IXLFilterColumn + internal class XLFilterColumn : IXLFilterColumn, IXLFilteredColumn, IEnumerable { private readonly XLAutoFilter _autoFilter; private readonly Int32 _column; + private readonly List _filters = new(); public XLFilterColumn(XLAutoFilter autoFilter, Int32 column) { @@ -18,201 +19,116 @@ public XLFilterColumn(XLAutoFilter autoFilter, Int32 column) #region IXLFilterColumn Members - public void Clear() + public void Clear(bool reapply) { - if (_autoFilter.Filters.ContainsKey(_column)) - _autoFilter.Filters.Remove(_column); + _filters.Clear(); + FilterType = XLFilterType.None; + if (reapply) + _autoFilter.Reapply(); } - public IXLFilteredColumn AddFilter(T value) where T : IComparable + public IXLFilteredColumn AddFilter(XLCellValue value, bool reapply) { - if (typeof(T) == typeof(String)) - { - ApplyCustomFilter(value, XLFilterOperator.Equal, - v => - v.ToString().Equals(value.ToString(), StringComparison.InvariantCultureIgnoreCase), - XLFilterType.Regular); - } - else - { - ApplyCustomFilter(value, XLFilterOperator.Equal, - v => v.CastTo().CompareTo(value) == 0, XLFilterType.Regular); - } - return new XLFilteredColumn(_autoFilter, _column); + SwitchFilter(XLFilterType.Regular); + AddFilter(XLFilter.CreateRegularFilter(value.ToString()), reapply); + return this; } - public IXLDateTimeGroupFilteredColumn AddDateGroupFilter(DateTime date, XLDateTimeGrouping dateTimeGrouping) + public IXLFilteredColumn AddDateGroupFilter(DateTime date, XLDateTimeGrouping dateTimeGrouping, bool reapply) { - Func condition = date2 => XLDateTimeGroupFilteredColumn.IsMatch(date, (DateTime)date2, dateTimeGrouping); - - _autoFilter.IsEnabled = true; - - if (_autoFilter.Filters.TryGetValue(_column, out List filterList)) - filterList.Add( - new XLFilter - { - Value = date, - Operator = XLFilterOperator.Equal, - Connector = XLConnector.Or, - Condition = condition, - DateTimeGrouping = dateTimeGrouping - } - ); - else - { - _autoFilter.Filters.Add( - _column, - new List - { - new XLFilter - { - Value = date, - Operator = XLFilterOperator.Equal, - Connector = XLConnector.Or, - Condition = condition, - DateTimeGrouping = dateTimeGrouping - } - } - ); - } - - _autoFilter.Column(_column).FilterType = XLFilterType.DateTimeGrouping; - - var ws = _autoFilter.Range.Worksheet as XLWorksheet; - ws.SuspendEvents(); - - var rows = _autoFilter.Range.Rows(2, _autoFilter.Range.RowCount()); - - foreach (IXLRangeRow row in rows) - { - if (row.Cell(_column).DataType == XLDataType.DateTime && condition(row.Cell(_column).GetDateTime())) - row.WorksheetRow().Unhide(); - else - row.WorksheetRow().Hide(); - } - ws.ResumeEvents(); - - return new XLDateTimeGroupFilteredColumn(_autoFilter, _column); + SwitchFilter(XLFilterType.Regular); + AddFilter(XLFilter.CreateDateGroupFilter(date, dateTimeGrouping), reapply); + return this; } - public void Top(Int32 value, XLTopBottomType type = XLTopBottomType.Items) + public void Top(Int32 value, XLTopBottomType type, bool reapply) { - _autoFilter.Column(_column).TopBottomPart = XLTopBottomPart.Top; - SetTopBottom(value, type); + SetTopBottom(value, type, takeTop: true, reapply); } - public void Bottom(Int32 value, XLTopBottomType type = XLTopBottomType.Items) + public void Bottom(Int32 value, XLTopBottomType type, bool reapply) { - _autoFilter.Column(_column).TopBottomPart = XLTopBottomPart.Bottom; - SetTopBottom(value, type, false); + SetTopBottom(value, type, takeTop: false, reapply); } - public void AboveAverage() + public void AboveAverage(bool reapply) { - ShowAverage(true); + SetAverage(aboveAverage: true, reapply); } - public void BelowAverage() + public void BelowAverage(bool reapply) { - ShowAverage(false); + SetAverage(aboveAverage: false, reapply); } - public IXLFilterConnector EqualTo(T value) where T : IComparable + public IXLFilterConnector EqualTo(XLCellValue value, Boolean reapply) { - if (typeof(T) == typeof(String)) - { - return ApplyCustomFilter(value, XLFilterOperator.Equal, - v => - v.ToString().Equals(value.ToString(), - StringComparison.InvariantCultureIgnoreCase)); - } - - return ApplyCustomFilter(value, XLFilterOperator.Equal, - v => v.CastTo().CompareTo(value) == 0); + return AddCustomFilter(value.ToString(), true, reapply); } - public IXLFilterConnector NotEqualTo(T value) where T : IComparable + public IXLFilterConnector NotEqualTo(XLCellValue value, Boolean reapply) { - if (typeof(T) == typeof(String)) - { - return ApplyCustomFilter(value, XLFilterOperator.NotEqual, - v => - !v.ToString().Equals(value.ToString(), - StringComparison.InvariantCultureIgnoreCase)); - } - - return ApplyCustomFilter(value, XLFilterOperator.NotEqual, - v => v.CastTo().CompareTo(value) != 0); + return AddCustomFilter(value.ToString(), false, reapply); } - public IXLFilterConnector GreaterThan(T value) where T : IComparable + public IXLFilterConnector GreaterThan(XLCellValue value, Boolean reapply) { - return ApplyCustomFilter(value, XLFilterOperator.GreaterThan, - v => v.CastTo().CompareTo(value) > 0); + return AddCustomFilter(value, XLFilterOperator.GreaterThan, reapply); } - public IXLFilterConnector LessThan(T value) where T : IComparable + public IXLFilterConnector LessThan(XLCellValue value, Boolean reapply) { - return ApplyCustomFilter(value, XLFilterOperator.LessThan, - v => v.CastTo().CompareTo(value) < 0); + return AddCustomFilter(value, XLFilterOperator.LessThan, reapply); } - public IXLFilterConnector EqualOrGreaterThan(T value) where T : IComparable + public IXLFilterConnector EqualOrGreaterThan(XLCellValue value, Boolean reapply) { - return ApplyCustomFilter(value, XLFilterOperator.EqualOrGreaterThan, - v => v.CastTo().CompareTo(value) >= 0); + return AddCustomFilter(value, XLFilterOperator.EqualOrGreaterThan, reapply); } - public IXLFilterConnector EqualOrLessThan(T value) where T : IComparable + public IXLFilterConnector EqualOrLessThan(XLCellValue value, Boolean reapply) { - return ApplyCustomFilter(value, XLFilterOperator.EqualOrLessThan, - v => v.CastTo().CompareTo(value) <= 0); + return AddCustomFilter(value, XLFilterOperator.EqualOrLessThan, reapply); } - public void Between(T minValue, T maxValue) where T : IComparable + public void Between(XLCellValue minValue, XLCellValue maxValue, Boolean reapply) { - EqualOrGreaterThan(minValue).And.EqualOrLessThan(maxValue); + EqualOrGreaterThan(minValue, false).And.EqualOrLessThan(maxValue, reapply); } - public void NotBetween(T minValue, T maxValue) where T : IComparable + public void NotBetween(XLCellValue minValue, XLCellValue maxValue, Boolean reapply) { - LessThan(minValue).Or.GreaterThan(maxValue); + LessThan(minValue, false).Or.GreaterThan(maxValue, reapply); } - public static Func BeginsWithFunction { get; } = (value, input) => ((string)input).StartsWith(value, StringComparison.InvariantCultureIgnoreCase); - - public IXLFilterConnector BeginsWith(String value) + public IXLFilterConnector BeginsWith(String value, Boolean reapply) { - return ApplyCustomFilter(value + "*", XLFilterOperator.Equal, s => BeginsWithFunction(value, s)); + return AddCustomFilter(value + "*", true, reapply); } - public IXLFilterConnector NotBeginsWith(String value) + public IXLFilterConnector NotBeginsWith(String value, Boolean reapply) { - return ApplyCustomFilter(value + "*", XLFilterOperator.NotEqual, s => !BeginsWithFunction(value, s)); + return AddCustomFilter(value + "*", false, reapply); } - public static Func EndsWithFunction { get; } = (value, input) => ((string)input).EndsWith(value, StringComparison.InvariantCultureIgnoreCase); - - public IXLFilterConnector EndsWith(String value) + public IXLFilterConnector EndsWith(String value, Boolean reapply) { - return ApplyCustomFilter("*" + value, XLFilterOperator.Equal, s => EndsWithFunction(value, s)); + return AddCustomFilter("*" + value, true, reapply); } - public IXLFilterConnector NotEndsWith(String value) + public IXLFilterConnector NotEndsWith(String value, Boolean reapply) { - return ApplyCustomFilter("*" + value, XLFilterOperator.NotEqual, s => !EndsWithFunction(value, s)); + return AddCustomFilter("*" + value, false, reapply); } - public static Func ContainsFunction { get; } = (value, input) => ((string)input).IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0; - - public IXLFilterConnector Contains(String value) + public IXLFilterConnector Contains(String value, Boolean reapply) { - return ApplyCustomFilter("*" + value + "*", XLFilterOperator.Equal, s => ContainsFunction(value, s)); + return AddCustomFilter("*" + value + "*", true, reapply); } - public IXLFilterConnector NotContains(String value) + public IXLFilterConnector NotContains(String value, Boolean reapply) { - return ApplyCustomFilter("*" + value + "*", XLFilterOperator.Equal, s => !ContainsFunction(value, s)); + return AddCustomFilter("*" + value + "*", false, reapply); } public XLFilterType FilterType { get; set; } @@ -222,211 +138,169 @@ public IXLFilterConnector NotContains(String value) public XLTopBottomPart TopBottomPart { get; set; } public XLFilterDynamicType DynamicType { get; set; } - public Double DynamicValue { get; set; } + + /// + /// Basically average for dynamic filters. Value is refreshed during filter reapply. + /// + public Double DynamicValue { get; set; } = double.NaN; #endregion IXLFilterColumn Members - private void SetTopBottom(Int32 value, XLTopBottomType type, Boolean takeTop = true) - { - _autoFilter.IsEnabled = true; - _autoFilter.Column(_column).SetFilterType(XLFilterType.TopBottom) - .SetTopBottomValue(value) - .SetTopBottomType(type); + /// + /// A filter value used by top/bottom filter to compare with cell value. + /// + internal double TopBottomFilterValue { get; private set; } = double.NaN; - var values = GetValues(value, type, takeTop); + public IEnumerator GetEnumerator() => _filters.GetEnumerator(); - Clear(); - _autoFilter.Filters.Add(_column, new List()); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - Boolean addToList = true; - var ws = _autoFilter.Range.Worksheet as XLWorksheet; - ws.SuspendEvents(); - var rows = _autoFilter.Range.Rows(2, _autoFilter.Range.RowCount()); - foreach (IXLRangeRow row in rows) - { - Boolean foundOne = false; - foreach (double val in values) - { - Func condition = v => (v as IComparable).CompareTo(val) == 0; - if (addToList) - { - _autoFilter.Filters[_column].Add(new XLFilter - { - Value = val, - Operator = XLFilterOperator.Equal, - Connector = XLConnector.Or, - Condition = condition - }); - } - - var cell = row.Cell(_column); - if (cell.DataType != XLDataType.Number || !condition(cell.GetDouble())) continue; - row.WorksheetRow().Unhide(); - foundOne = true; - } - if (!foundOne) - row.WorksheetRow().Hide(); - - addToList = false; - } - ws.ResumeEvents(); + private void SetTopBottom(Int32 percentOrItemCount, XLTopBottomType type, Boolean takeTop, Boolean reapply) + { + if (percentOrItemCount is < 1 or > 500) + throw new ArgumentOutOfRangeException(nameof(percentOrItemCount), "Value must be between 1 and 500."); + + ResetFilter(XLFilterType.TopBottom); + TopBottomValue = percentOrItemCount; + TopBottomType = type; + TopBottomPart = takeTop ? XLTopBottomPart.Top : XLTopBottomPart.Bottom; + + AddFilter(XLFilter.CreateTopBottom(takeTop, percentOrItemCount), reapply); } - private IEnumerable GetValues(int value, XLTopBottomType type, bool takeTop) + private double GetTopBottomFilterValue(XLTopBottomType type, int value, bool takeTop) { var column = _autoFilter.Range.Column(_column); var subColumn = column.Column(2, column.CellCount()); - var cellsUsed = subColumn.CellsUsed(c => c.DataType == XLDataType.Number); - if (takeTop) - { - if (type == XLTopBottomType.Items) - { - return cellsUsed.Select(c => c.GetDouble()).OrderByDescending(d => d).Take(value).Distinct(); - } - - var numerics1 = cellsUsed.Select(c => c.GetDouble()); - Int32 valsToTake1 = numerics1.Count() * value / 100; - return numerics1.OrderByDescending(d => d).Take(valsToTake1).Distinct(); - } + var columnNumbers = subColumn.CellsUsed(c => c.CachedValue.IsUnifiedNumber).Select(c => c.CachedValue.GetUnifiedNumber()); + var comparer = takeTop + ? Comparer.Create((x, y) => -x.CompareTo(y)) + : Comparer.Create((x, y) => x.CompareTo(y)); - if (type == XLTopBottomType.Items) + switch (type) { - return cellsUsed.Select(c => c.GetDouble()).OrderBy(d => d).Take(value).Distinct(); + case XLTopBottomType.Items: + var itemCount = value; + return columnNumbers.OrderBy(d => d, comparer).Take(itemCount).DefaultIfEmpty(double.NaN).LastOrDefault(); + case XLTopBottomType.Percent: + var percent = value; + var materializedNumbers = columnNumbers.ToArray(); + + // Ceiling, so there is always at least one item. + var itemCountByPercents = (int)Math.Ceiling(materializedNumbers.Length * (double)percent / 100); + return materializedNumbers.OrderBy(d => d, comparer).Take(itemCountByPercents).DefaultIfEmpty(Double.NaN).LastOrDefault(); + default: + throw new NotSupportedException(); } - - var numerics = cellsUsed.Select(c => c.GetDouble()); - Int32 valsToTake = numerics.Count() * value / 100; - return numerics.OrderBy(d => d).Take(valsToTake).Distinct(); } - private void ShowAverage(Boolean aboveAverage) + private void SetAverage(Boolean aboveAverage, Boolean reapply) { - _autoFilter.IsEnabled = true; - _autoFilter.Column(_column).SetFilterType(XLFilterType.Dynamic) - .SetDynamicType(aboveAverage - ? XLFilterDynamicType.AboveAverage - : XLFilterDynamicType.BelowAverage); - var values = GetAverageValues(aboveAverage); - - Clear(); - _autoFilter.Filters.Add(_column, new List()); - - Boolean addToList = true; - var ws = _autoFilter.Range.Worksheet as XLWorksheet; - ws.SuspendEvents(); - var rows = _autoFilter.Range.Rows(2, _autoFilter.Range.RowCount()); - - foreach (IXLRangeRow row in rows) - { - Boolean foundOne = false; - foreach (double val in values) - { - Func condition = v => (v as IComparable).CompareTo(val) == 0; - if (addToList) - { - _autoFilter.Filters[_column].Add(new XLFilter - { - Value = val, - Operator = XLFilterOperator.Equal, - Connector = XLConnector.Or, - Condition = condition - }); - } - - var cell = row.Cell(_column); - if (cell.DataType != XLDataType.Number || !condition(cell.GetDouble())) continue; - row.WorksheetRow().Unhide(); - foundOne = true; - } - - if (!foundOne) - row.WorksheetRow().Hide(); - - addToList = false; - } - - ws.ResumeEvents(); + ResetFilter(XLFilterType.Dynamic); + DynamicType = aboveAverage + ? XLFilterDynamicType.AboveAverage + : XLFilterDynamicType.BelowAverage; + + // `Average` is recalculated during reapply, so no need to calculate it twice. + DynamicValue = reapply ? double.NaN : GetAverageFilterValue(); + AddFilter(XLFilter.CreateAverage(DynamicValue, aboveAverage), reapply); } - private IEnumerable GetAverageValues(bool aboveAverage) + private double GetAverageFilterValue() { var column = _autoFilter.Range.Column(_column); var subColumn = column.Column(2, column.CellCount()); - Double average = subColumn.CellsUsed(c => c.DataType == XLDataType.Number).Select(c => c.GetDouble()) + return subColumn.CellsUsed(c => c.CachedValue.IsUnifiedNumber) + .Select(c => c.CachedValue.GetUnifiedNumber()) + .DefaultIfEmpty(Double.NaN) .Average(); + } - if (aboveAverage) - { - return - subColumn.CellsUsed(c => c.DataType == XLDataType.Number).Select(c => c.GetDouble()) - .Where(c => c > average).Distinct(); - } + private IXLFilterConnector AddCustomFilter(XLCellValue value, XLFilterOperator op, Boolean reapply) + { + ResetFilter(XLFilterType.Custom); + AddFilter(XLFilter.CreateCustomFilter(value, op, XLConnector.Or), reapply); + return new XLFilterConnector(this); + } + + private IXLFilterConnector AddCustomFilter(string pattern, bool match, bool reapply) + { + ResetFilter(XLFilterType.Custom); + AddFilter(XLFilter.CreateCustomPatternFilter(pattern, match, XLConnector.Or), reapply); + return new XLFilterConnector(this); + } - return - subColumn.CellsUsed(c => c.DataType == XLDataType.Number).Select(c => c.GetDouble()) - .Where(c => c < average).Distinct(); + private void ResetFilter(XLFilterType type) + { + Clear(false); + _autoFilter.IsEnabled = true; + FilterType = type; } - private IXLFilterConnector ApplyCustomFilter(T value, XLFilterOperator op, Func condition, - XLFilterType filterType = XLFilterType.Custom) - where T : IComparable + private void SwitchFilter(XLFilterType type) { _autoFilter.IsEnabled = true; - if (filterType == XLFilterType.Custom) + if (FilterType == type) + return; + + Clear(false); + FilterType = type; + } + + internal void AddFilter(XLFilter filter, bool reapply = false) + { + var maxFilters = FilterType switch { - Clear(); - _autoFilter.Filters.Add(_column, - new List - { - new XLFilter - { - Value = value, - Operator = op, - Connector = XLConnector.Or, - Condition = condition - } - }); + XLFilterType.None => 0, + XLFilterType.Regular => int.MaxValue, + XLFilterType.Custom => 2, + XLFilterType.TopBottom => 1, + XLFilterType.Dynamic => 1, + _ => throw new NotSupportedException() + }; + if (_filters.Count >= maxFilters) + throw new InvalidOperationException($"{FilterType} filter can have max {maxFilters} conditions."); + + _filters.Add(filter); + if (reapply) + _autoFilter.Reapply(); + } + + internal void Refresh() + { + if (FilterType == XLFilterType.Dynamic) + { + // Update average value of a filter, so it is saved correctly and filter uses + // correct value, even is cell values changed and avg was stale. + DynamicValue = GetAverageFilterValue(); + _filters[0].Value = DynamicValue; } - else + + if (FilterType == XLFilterType.TopBottom) { - if (_autoFilter.Filters.TryGetValue(_column, out List filterList)) - filterList.Add(new XLFilter - { - Value = value, - Operator = op, - Connector = XLConnector.Or, - Condition = condition - }); - else - { - _autoFilter.Filters.Add(_column, - new List - { - new XLFilter - { - Value = value, - Operator = op, - Connector = XLConnector.Or, - Condition = condition - } - }); - } + var takeTop = TopBottomPart == XLTopBottomPart.Top; + TopBottomFilterValue = GetTopBottomFilterValue(TopBottomType, TopBottomValue, takeTop); } - _autoFilter.Column(_column).FilterType = filterType; - _autoFilter.Reapply(); - return new XLFilterConnector(_autoFilter, _column); } - public IXLFilterColumn SetFilterType(XLFilterType value) { FilterType = value; return this; } - - public IXLFilterColumn SetTopBottomValue(Int32 value) { TopBottomValue = value; return this; } - - public IXLFilterColumn SetTopBottomType(XLTopBottomType value) { TopBottomType = value; return this; } - - public IXLFilterColumn SetTopBottomPart(XLTopBottomPart value) { TopBottomPart = value; return this; } + internal bool Check(IXLCell cell) + { + if (_filters.Count == 0) + return true; - public IXLFilterColumn SetDynamicType(XLFilterDynamicType value) { DynamicType = value; return this; } + if (_filters.Count == 1) + return _filters[0].Condition(cell, this); - public IXLFilterColumn SetDynamicValue(Double value) { DynamicValue = value; return this; } + // All filter conditions are connected by a single type of logical condition. Regular + // filters use 'Or', custom has up to two clauses connected by 'And'/'Or' and rest is + // single clause. + var connector = _filters[1].Connector; + return connector switch + { + XLConnector.And => _filters.All(filter => filter.Condition(cell, this)), + XLConnector.Or => _filters.Any(filter => filter.Condition(cell, this)), + _ => throw new NotSupportedException(), + }; + } } } diff --git a/ClosedXML/Excel/AutoFilters/XLFilterConnector.cs b/ClosedXML/Excel/AutoFilters/XLFilterConnector.cs index 3aac0ecfc..c9b0072df 100644 --- a/ClosedXML/Excel/AutoFilters/XLFilterConnector.cs +++ b/ClosedXML/Excel/AutoFilters/XLFilterConnector.cs @@ -1,33 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +namespace ClosedXML.Excel; -namespace ClosedXML.Excel +internal class XLFilterConnector : IXLFilterConnector { - internal class XLFilterConnector : IXLFilterConnector - { - private readonly XLAutoFilter _autoFilter; - private readonly Int32 _column; - - public XLFilterConnector(XLAutoFilter autoFilter, Int32 column) - { - _autoFilter = autoFilter; - _column = column; - } - - #region IXLFilterConnector Members + private readonly XLFilterColumn _filterColumn; - public IXLCustomFilteredColumn And - { - get { return new XLCustomFilteredColumn(_autoFilter, _column, XLConnector.And); } - } + public XLFilterConnector(XLFilterColumn filterColumn) + { + _filterColumn = filterColumn; + } - public IXLCustomFilteredColumn Or - { - get { return new XLCustomFilteredColumn(_autoFilter, _column, XLConnector.Or); } - } + public IXLCustomFilteredColumn And => new XLCustomFilteredColumn(_filterColumn, XLConnector.And); - #endregion - } -} \ No newline at end of file + public IXLCustomFilteredColumn Or => new XLCustomFilteredColumn(_filterColumn, XLConnector.Or); +} diff --git a/ClosedXML/Excel/AutoFilters/XLFilteredColumn.cs b/ClosedXML/Excel/AutoFilters/XLFilteredColumn.cs deleted file mode 100644 index fde9dbad2..000000000 --- a/ClosedXML/Excel/AutoFilters/XLFilteredColumn.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; - -namespace ClosedXML.Excel -{ - internal class XLFilteredColumn : IXLFilteredColumn - { - private readonly XLAutoFilter _autoFilter; - private readonly Int32 _column; - - public XLFilteredColumn(XLAutoFilter autoFilter, Int32 column) - { - _autoFilter = autoFilter; - _column = column; - } - - #region IXLFilteredColumn Members - - public IXLFilteredColumn AddFilter(T value) where T : IComparable - { - Func condition; - Boolean isText; - if (typeof(T) == typeof(String)) - { - condition = v => v.ToString().Equals(value.ToString(), StringComparison.InvariantCultureIgnoreCase); - isText = true; - } - else - { - condition = v => v.CastTo().CompareTo(value) == 0; - isText = false; - } - - _autoFilter.Filters[_column].Add(new XLFilter - { - Value = value, - Condition = condition, - Operator = XLFilterOperator.Equal, - Connector = XLConnector.Or - }); - - var rows = _autoFilter.Range.Rows(2, _autoFilter.Range.RowCount()); - - foreach (IXLRangeRow row in rows) - { - if ((isText && condition(row.Cell(_column).GetString())) || - (!isText && row.Cell(_column).DataType == XLDataType.Number && - condition(row.Cell(_column).GetValue()))) - { - row.WorksheetRow().Unhide(); - } - } - return this; - } - - #endregion IXLFilteredColumn Members - } -} diff --git a/ClosedXML/Excel/Caching/IXLRepository.cs b/ClosedXML/Excel/Caching/IXLRepository.cs index 4b447a1a8..009078ef5 100644 --- a/ClosedXML/Excel/Caching/IXLRepository.cs +++ b/ClosedXML/Excel/Caching/IXLRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching @@ -26,6 +26,6 @@ internal interface IXLRepository : IXLRepository, IEnumerableValue to put into the repository if key does not exist. /// Value stored in the repository under the specified . If key already existed /// returned value may differ from the input one. - Tvalue Store(ref Tkey key, Tvalue value); + Tvalue? Store(ref Tkey key, Tvalue value); } } diff --git a/ClosedXML/Excel/Caching/XLAlignmentRepository.cs b/ClosedXML/Excel/Caching/XLAlignmentRepository.cs index 1e091ae82..670604e22 100644 --- a/ClosedXML/Excel/Caching/XLAlignmentRepository.cs +++ b/ClosedXML/Excel/Caching/XLAlignmentRepository.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/Caching/XLBorderRepository.cs b/ClosedXML/Excel/Caching/XLBorderRepository.cs index 2350e61be..de6e0ea98 100644 --- a/ClosedXML/Excel/Caching/XLBorderRepository.cs +++ b/ClosedXML/Excel/Caching/XLBorderRepository.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/Caching/XLColorRepository.cs b/ClosedXML/Excel/Caching/XLColorRepository.cs index d6d671b78..6405f9fe5 100644 --- a/ClosedXML/Excel/Caching/XLColorRepository.cs +++ b/ClosedXML/Excel/Caching/XLColorRepository.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/Caching/XLFillRepository.cs b/ClosedXML/Excel/Caching/XLFillRepository.cs index 1dbb79aa1..c089730bd 100644 --- a/ClosedXML/Excel/Caching/XLFillRepository.cs +++ b/ClosedXML/Excel/Caching/XLFillRepository.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/Caching/XLFontRepository.cs b/ClosedXML/Excel/Caching/XLFontRepository.cs index ba619ef4d..a7c4f83b6 100644 --- a/ClosedXML/Excel/Caching/XLFontRepository.cs +++ b/ClosedXML/Excel/Caching/XLFontRepository.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/Caching/XLNumberFormatRepository.cs b/ClosedXML/Excel/Caching/XLNumberFormatRepository.cs index 80e9d1068..2fb9eb0da 100644 --- a/ClosedXML/Excel/Caching/XLNumberFormatRepository.cs +++ b/ClosedXML/Excel/Caching/XLNumberFormatRepository.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/Caching/XLProtectionRepository.cs b/ClosedXML/Excel/Caching/XLProtectionRepository.cs index 44e7de396..21641781e 100644 --- a/ClosedXML/Excel/Caching/XLProtectionRepository.cs +++ b/ClosedXML/Excel/Caching/XLProtectionRepository.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/Caching/XLRangeRepository.cs b/ClosedXML/Excel/Caching/XLRangeRepository.cs index 09469e025..644e1c341 100644 --- a/ClosedXML/Excel/Caching/XLRangeRepository.cs +++ b/ClosedXML/Excel/Caching/XLRangeRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/Caching/XLRepositoryBase.cs b/ClosedXML/Excel/Caching/XLRepositoryBase.cs index 347bf36e2..13c976091 100644 --- a/ClosedXML/Excel/Caching/XLRepositoryBase.cs +++ b/ClosedXML/Excel/Caching/XLRepositoryBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -39,7 +39,7 @@ protected XLRepositoryBase(Func createNew, IEqualityComparer /// Value from the repository stored under specified key or null if key does /// not exist or the entry under this key has already bee GCed. /// True if entry exists and alive, false otherwise. - public bool ContainsKey(ref Tkey key, out Tvalue value) + public bool ContainsKey(ref Tkey key, out Tvalue? value) { if (_storage.TryGetValue(key, out WeakReference cachedReference)) { @@ -59,9 +59,9 @@ public bool ContainsKey(ref Tkey key, out Tvalue value) /// Entity that is stored in the repository under the specified key /// (it can be either the or another entity that has been added to /// the repository before.) - public Tvalue Store(ref Tkey key, Tvalue value) + public Tvalue? Store(ref Tkey key, Tvalue value) { - if (value == null) + if (value is null) return null; do @@ -86,10 +86,10 @@ public Tvalue GetOrCreate(ref Tkey key) _storage.TryRemove(key, out WeakReference _); var value = _createNew(key); - return Store(ref key, value); + return Store(ref key, value)!; } - public Tvalue Replace(ref Tkey oldKey, ref Tkey newKey) + public Tvalue? Replace(ref Tkey oldKey, ref Tkey newKey) { if (_storage.TryRemove(oldKey, out WeakReference cachedReference) && cachedReference != null) { @@ -126,7 +126,7 @@ public IEnumerator GetEnumerator() return val; }) .Where(val => val != null) - .GetEnumerator(); + .GetEnumerator()!; } IEnumerator IEnumerable.GetEnumerator() diff --git a/ClosedXML/Excel/Caching/XLStyleRepository.cs b/ClosedXML/Excel/Caching/XLStyleRepository.cs index d5105d495..37e186244 100644 --- a/ClosedXML/Excel/Caching/XLStyleRepository.cs +++ b/ClosedXML/Excel/Caching/XLStyleRepository.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/Caching/XLWorkbookElementRepositoryBase.cs b/ClosedXML/Excel/Caching/XLWorkbookElementRepositoryBase.cs index 69b2b426b..2223b7162 100644 --- a/ClosedXML/Excel/Caching/XLWorkbookElementRepositoryBase.cs +++ b/ClosedXML/Excel/Caching/XLWorkbookElementRepositoryBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Caching diff --git a/ClosedXML/Excel/CalcEngine/AnyValue.cs b/ClosedXML/Excel/CalcEngine/AnyValue.cs index bf7a064be..f14ba6da2 100644 --- a/ClosedXML/Excel/CalcEngine/AnyValue.cs +++ b/ClosedXML/Excel/CalcEngine/AnyValue.cs @@ -1,5 +1,9 @@ -using System; +#nullable disable + +using System; using System.Globalization; +using System.Linq; +using ClosedXML.Extensions; using CollectionValue = ClosedXML.Excel.CalcEngine.OneOf; namespace ClosedXML.Excel.CalcEngine @@ -85,6 +89,23 @@ public static AnyValue From(Reference reference) public bool IsBlank => _index == BlankValue; + public bool IsLogical => _index == LogicalValue; + + public bool IsNumber => _index == NumberValue; + + public bool IsText => _index == TextValue; + + public bool IsError => _index == ErrorValue; + + public bool IsArray => _index == ArrayValue; + + public bool IsReference => _index == ReferenceValue; + + /// + /// Is the value a scalar (blank, logical, number, text or error). + /// + public bool IsScalarType => IsBlank || IsLogical || IsNumber || IsText || IsError; + public bool TryPickScalar(out ScalarValue scalar, out CollectionValue collection) { scalar = _index switch @@ -117,6 +138,18 @@ public bool TryPickError(out XLError error) return false; } + public bool TryPickArray(out Array array) + { + if (_index == ArrayValue) + { + array = _array; + return true; + } + + array = default; + return false; + } + public bool TryPickReference(out Reference reference, out XLError error) { if (_index == ReferenceValue) @@ -158,6 +191,69 @@ public bool TryPickArea(out XLRangeAddress area, out XLError error) return true; } + /// + /// Return array from a single area reference or array. If value is scalar, return false. + /// + public bool TryPickCollectionArray(out Array array, CalcContext ctx) + { + if (TryPickArea(out var areaAddress, out _)) + { + array = new ReferenceArray(areaAddress, ctx); + return true; + } + + if (TryPickArray(out array)) + return true; + + array = null; + return false; + } + + /// + /// + /// Try to get a value more in line with an array formula semantic. The output is always + /// either single value or an array. + /// + /// + /// Single cell references are turned into a scalar, multi-area references are turned + /// into and single-area references are turned + /// into arrays. + /// + /// + /// + /// Note the difference in nomenclature: single/multi value vs scalar/collection type. + /// + internal bool TryPickSingleOrMultiValue(out ScalarValue scalar, out Array array, CalcContext ctx) + { + if (TryPickScalar(out scalar, out var collection)) + { + array = default; + return true; + } + + // For some weird reason, 1x1 array doesn't count as a scalar, unlike single cell reference + // proof {=TYPE(A1+1)} is 1 (scalar), but {=TYPE({1}+1)} is 64 (array). + if (collection.TryPickT0(out array, out var reference)) + { + scalar = default; + return false; + } + + if (reference.TryGetSingleCellValue(out scalar, ctx)) + { + return true; + } + + if (reference.Areas.Count > 1) + { + scalar = XLError.IncompatibleValue; + return true; + } + + array = new ReferenceArray(reference.Areas[0], ctx); + return false; + } + public TResult Match(Func transformBlank, Func transformLogical, Func transformNumber, Func transformText, Func transformError, Func transformArray, Func transformReference) { return _index switch @@ -263,16 +359,11 @@ public AnyValue UnaryPercent(CalcContext context) private static AnyValue UnaryOperation(in AnyValue value, Func operatorFn, CalcContext context) { - if (value.TryPickScalar(out var scalar, out var collection)) - return UnaryArithmeticOp(scalar, operatorFn, context).ToAnyValue(); + var isSingle = value.TryPickSingleOrMultiValue(out var single, out var array, context); + if (isSingle) + return UnaryArithmeticOp(single, operatorFn, context).ToAnyValue(); - return collection.Match( - array => array.Apply(arrayConst => UnaryArithmeticOp(arrayConst, operatorFn, context)), - reference => reference - .Apply(cellValue => UnaryArithmeticOp(cellValue, operatorFn, context), context) - .Match( - array => array, - error => error)); + return array.Apply(arrayConst => UnaryArithmeticOp(arrayConst, operatorFn, context)); } private static ScalarValue UnaryArithmeticOp(ScalarValue value, Func op, CalcContext ctx) @@ -412,99 +503,31 @@ public static AnyValue Concat(in AnyValue left, in AnyValue right, CalcContext c private static AnyValue BinaryOperation(in AnyValue left, in AnyValue right, BinaryFunc func, CalcContext context) { - var isLeftScalar = left.TryPickScalar(out var leftScalar, out var leftCollection); - var isRightScalar = right.TryPickScalar(out var rightScalar, out var rightCollection); + var isLeftSingle = left.TryPickSingleOrMultiValue(out var leftSingle, out var leftArray, context); + var isRightSingle = right.TryPickSingleOrMultiValue(out var rightSingle, out var rightArray, context); - if (isLeftScalar && isRightScalar) - return func(in leftScalar, in rightScalar, context).ToAnyValue(); + if (isLeftSingle && isRightSingle) + return func(in leftSingle, in rightSingle, context).ToAnyValue(); - if (isLeftScalar) + if (isLeftSingle) { - // Right side is an array - if (rightCollection.TryPickT0(out var rightArray, out var rightReference)) - return new ScalarArray(leftScalar, rightArray.Width, rightArray.Height).Apply(rightArray, func, context); - - // Right side is a reference - if (rightReference.TryGetSingleCellValue(out var rightCellValue, context)) - return func(in leftScalar, in rightCellValue, context).ToAnyValue(); - - var referenceArrayResult = rightReference.ToArray(context); - if (!referenceArrayResult.TryPickT0(out var rightRefArray, out var rightError)) - return rightError; - - return new ScalarArray(leftScalar, rightRefArray.Width, rightRefArray.Height).Apply(rightRefArray, func, context); + var broadcastedLeftArray = new ScalarArray(leftSingle, rightArray.Width, rightArray.Height); + return broadcastedLeftArray.Apply(rightArray, func, context); } - if (isRightScalar) + if (isRightSingle) { - // Left side is an array - if (leftCollection.TryPickT0(out var leftArray, out var leftReference)) - return leftArray.Apply(new ScalarArray(rightScalar, leftArray.Width, leftArray.Height), func, context); - - // Left side is a reference - if (leftReference.TryGetSingleCellValue(out var leftCellValue, context)) - return func(leftCellValue, rightScalar, context).ToAnyValue(); - - var referenceArrayResult = leftReference.ToArray(context); - if (!referenceArrayResult.TryPickT0(out var leftRefArray, out var leftError)) - return leftError; - - return leftRefArray.Apply(new ScalarArray(rightScalar, leftRefArray.Width, leftRefArray.Height), func, context); + var broadcastedRightArray = new ScalarArray(rightSingle, leftArray.Width, leftArray.Height); + return leftArray.Apply(broadcastedRightArray, func, context); } - // Both are aggregates - { - var isLeftArray = leftCollection.TryPickT0(out var leftArray, out var leftReference); - var isRightArray = rightCollection.TryPickT0(out var rightArray, out var rightReference); - - if (isLeftArray && isRightArray) - return leftArray.Apply(rightArray, func, context); - - if (isLeftArray) - { - if (rightReference.TryGetSingleCellValue(out var rightCellValue, context)) - return leftArray.Apply(new ScalarArray(rightCellValue, leftArray.Width, leftArray.Height), func, context); - - if (rightReference.Areas.Count == 1) - return leftArray.Apply(new ReferenceArray(rightReference.Areas[0], context), func, context); - - return leftArray.Apply(new ScalarArray(XLError.IncompatibleValue, leftArray.Width, leftArray.Height), func, context); - } - - if (isRightArray) - { - if (leftReference.TryGetSingleCellValue(out var leftCellValue, context)) - return new ScalarArray(leftCellValue, rightArray.Width, rightArray.Height).Apply(rightArray, func, context); - - if (leftReference.Areas.Count == 1) - return new ReferenceArray(leftReference.Areas[0], context).Apply(rightArray, func, context); + var unifiedRows = Math.Max(leftArray.Height, rightArray.Height); + var unifiedColumns = Math.Max(leftArray.Width, rightArray.Width); - return new ScalarArray(XLError.IncompatibleValue, rightArray.Width, rightArray.Height).Apply(rightArray, func, context); - } + var leftBroadcastedArray = leftArray.Broadcast(unifiedRows, unifiedColumns); + var rightBroadcastedArray = rightArray.Broadcast(unifiedRows, unifiedColumns); - // Both are references - if (leftReference.Areas.Count > 1 && rightReference.Areas.Count > 1) - return XLError.IncompatibleValue; - - if (leftReference.Areas.Count > 1) - return new ScalarArray(XLError.IncompatibleValue, rightReference.Areas[0].ColumnSpan, rightReference.Areas[0].RowSpan); - - if (rightReference.Areas.Count > 1) - return new ScalarArray(XLError.IncompatibleValue, leftReference.Areas[0].ColumnSpan, leftReference.Areas[0].RowSpan); - - var leftArea = leftReference.Areas[0]; - var rightArea = rightReference.Areas[0]; - if (leftArea.IsSingleCell() && rightArea.IsSingleCell()) - { - var leftCellValue = context.GetCellValue(leftArea.Worksheet, leftArea.FirstAddress.RowNumber, leftArea.FirstAddress.ColumnNumber); - var rightCellValue = context.GetCellValue(rightArea.Worksheet, rightArea.FirstAddress.RowNumber, rightArea.FirstAddress.ColumnNumber); - return func(leftCellValue, rightCellValue, context).ToAnyValue(); - } - - var leftRefArray = new ReferenceArray(leftArea, context); - var rightRefArray = new ReferenceArray(rightArea, context); - return leftRefArray.Apply(rightRefArray, func, context); - } + return leftBroadcastedArray.Apply(rightBroadcastedArray, func, context); } private static ScalarValue BinaryArithmeticOp(in ScalarValue left, in ScalarValue right, BinaryNumberFunc func, CalcContext ctx) @@ -579,6 +602,46 @@ private static OneOf CompareValues(ScalarValue left, ScalarValue r (leftError, _) => leftError); } + public override string ToString() + { + return _index switch + { + BlankValue => "Blank", + LogicalValue => $"Logical: {_logical.ToString().ToUpper()}", + NumberValue => $"Number: {_number}", + TextValue => $"Text: {_text}", + ErrorValue => $"Error: {_error.ToDisplayString()}", + ArrayValue => $"Array{_array.Height}x{_array.Width}", + ReferenceValue => $"Reference: {string.Join(",", _reference.Areas.Select(a => $"{a.FirstAddress}:{a.LastAddress}"))}", + _ => throw new InvalidOperationException() + }; + } + + /// + /// Get 2d size of the value. For scalars, it's 1x1, for multi-area references, + /// it's also 1x1,because it is converted to #VALUE! error. + /// + public (int Rows, int Columns) GetArraySize() + { + if (IsScalarType) + return (1, 1); + + if (TryPickArray(out var array)) + return (array.Height, array.Width); + + if (TryPickArea(out var area, out _)) + return (area.RowSpan, area.ColumnSpan); + + // Multi area is just error = scalar + return (1, 1); + } + + /// + /// Return the array value. + /// + /// + public Array GetArray() => _index == ArrayValue ? _array : throw new InvalidCastException(); + private delegate OneOf BinaryNumberFunc(double lhs, double rhs); } diff --git a/ClosedXML/Excel/CalcEngine/Array.cs b/ClosedXML/Excel/CalcEngine/Array.cs index a3d27bdeb..1e58363df 100644 --- a/ClosedXML/Excel/CalcEngine/Array.cs +++ b/ClosedXML/Excel/CalcEngine/Array.cs @@ -1,5 +1,8 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; namespace ClosedXML.Excel.CalcEngine @@ -79,44 +82,25 @@ public Array Apply(Array rightArray, BinaryFunc func, CalcContext ctx) return new ConstArray(data); } - } - - /// - /// An array where all elements have same value. - /// - internal class ScalarArray : Array - { - private readonly ScalarValue _value; - private readonly int _width; - private readonly int _height; - public ScalarArray(ScalarValue value, int width, int height) + /// + /// Broadcast array for calculation of array formulas. + /// + public Array Broadcast(int rows, int columns) { - if (width < 1) throw new ArgumentOutOfRangeException(nameof(width)); - if (height < 1) throw new ArgumentOutOfRangeException(nameof(height)); - _value = value; - _width = width; - _height = height; - } + if (Width == columns && Height == rows) + return this; - public override int Width => _width; + if (Width == 1 && Height == 1) + return new ScalarArray(this[0, 0], columns, rows); - public override int Height => _height; + if (Width == 1) + return new RepeatedColumnArray(this, rows, columns); - public override ScalarValue this[int y, int x] - { - get - { - if (x < 0 || x >= _width || y < 0 || y >= _height) - throw new IndexOutOfRangeException(); + if (Height == 1) + return new RepeatedRowArray(this, rows, columns); - return _value; - } - } - - public override IEnumerator GetEnumerator() - { - return Enumerable.Range(0, _width * _height).Select(_ => _value).GetEnumerator(); + return new ResizedArray(this, rows, columns); } } @@ -125,7 +109,7 @@ public override IEnumerator GetEnumerator() /// internal class ConstArray : Array { - internal readonly ScalarValue[,] _data; + private readonly ScalarValue[,] _data; public ConstArray(ScalarValue[,] data) { @@ -141,6 +125,47 @@ public ConstArray(ScalarValue[,] data) public override int Height => _data.GetLength(0); } + /// + /// Array for array literal from a parser. It uses a 1D array of values as a storage. + /// + internal class LiteralArray : Array + { + private readonly int _rows; + private readonly int _columns; + private readonly IReadOnlyList _elements; + + /// + /// Create a new instance of a . + /// + /// Number of rows of an array/ + /// Number of columns of an array. + /// Row by row data of the array. Has the expected size of an array. + public LiteralArray(int rows, int columns, IReadOnlyList elements) + { + if (rows * columns != elements.Count) + throw new ArgumentException("Number of elements in not the same as size of an array.", nameof(elements)); + + _rows = rows; + _columns = columns; + _elements = elements; + } + + public override ScalarValue this[int y, int x] + { + get + { + if (x < 0 || x >= _columns) + throw new ArgumentOutOfRangeException(nameof(x)); + + return _elements[y * _columns + x]; + } + } + + public override int Width => _columns; + + public override int Height => _rows; + } + /// /// A special case of an array that is actually only numbers. /// @@ -184,4 +209,191 @@ public ReferenceArray(XLRangeAddress area, CalcContext context) public override int Height => _area.RowSpan; } + + internal class RepeatedColumnArray : Array + { + private readonly Array _columnArray; + + public RepeatedColumnArray(Array oneColumnArray, int rows, int columns) + { + Debug.Assert(oneColumnArray.Width == 1); + _columnArray = oneColumnArray; + Width = columns; + Height = rows; + } + + public override int Width { get; } + + public override int Height { get; } + + public override ScalarValue this[int row, int column] + { + get + { + if (row >= Height || column >= Width) + throw new IndexOutOfRangeException(); + + if (row >= _columnArray.Height) + return XLError.NoValueAvailable; + + return _columnArray[row, 0]; + } + } + } + + internal class RepeatedRowArray : Array + { + private readonly Array _rowArray; + + internal RepeatedRowArray(Array oneRowArray, int rows, int columns) + { + Debug.Assert(oneRowArray.Height == 1); + _rowArray = oneRowArray; + Width = columns; + Height = rows; + } + + public override int Width { get; } + + public override int Height { get; } + + public override ScalarValue this[int row, int column] + { + get + { + if (row >= Height || column >= Width) + throw new IndexOutOfRangeException(); + + if (column >= _rowArray.Width) + return XLError.NoValueAvailable; + + return _rowArray[0, column]; + } + } + } + + /// + /// A resize array from another array. Extra items without value have #N/A. + /// + internal class ResizedArray : Array + { + private readonly Array _original; + + public ResizedArray(Array original, int rows, int columns) + { + _original = original; + Height = rows; + Width = columns; + } + + public override int Width { get; } + + public override int Height { get; } + + public override ScalarValue this[int y, int x] + { + get + { + if (y >= Height || x >= Width) + throw new IndexOutOfRangeException(); + + return y < _original.Height && x < _original.Width + ? _original[y, x] + : XLError.NoValueAvailable; + } + } + } + + /// + /// An array where all elements have same value. + /// + internal class ScalarArray : Array + { + private readonly ScalarValue _value; + private readonly int _columns; + private readonly int _rows; + + public ScalarArray(ScalarValue value, int columns, int rows) + { + if (columns < 1) throw new ArgumentOutOfRangeException(nameof(columns)); + if (rows < 1) throw new ArgumentOutOfRangeException(nameof(rows)); + _value = value; + _columns = columns; + _rows = rows; + } + + public override int Width => _columns; + + public override int Height => _rows; + + public override ScalarValue this[int y, int x] + { + get + { + if (x < 0 || x >= _columns || y < 0 || y >= _rows) + throw new IndexOutOfRangeException(); + + return _value; + } + } + + public override IEnumerator GetEnumerator() + { + return Enumerable.Range(0, _columns * _rows).Select(_ => _value).GetEnumerator(); + } + } + + internal class TransposedArray : Array + { + private readonly Array _original; + + public TransposedArray(Array original) + { + _original = original; + } + + public override ScalarValue this[int y, int x] => _original[x, y]; + + public override int Width => _original.Height; + + public override int Height => _original.Width; + } + + /// + /// An array that is a rectangular slice of the original array. + /// + internal class SlicedArray : Array + { + private readonly Array _original; + private readonly int _rowOfs; + private readonly int _colOfs; + + /// + /// Create a sliced array from the original array. + /// + /// Original array. + /// The row offset indicating the starting row of the slice in the original array. + /// The number of rows in the sliced array. + /// The column offset indicating the starting column of the slice in the original array. + /// The number of columns in the sliced array. + public SlicedArray(Array original, int rowOfs, int rows, int colOfs, int cols) + { + if (rowOfs < 0 || rows < 1 || colOfs < 0 || cols < 1 || + rowOfs + rows > original.Height || + colOfs + cols > original.Width) + throw new ArgumentOutOfRangeException(); + + _original = original; + _rowOfs = rowOfs; + Height = rows; + _colOfs = colOfs; + Width = cols; + } + + public override ScalarValue this[int y, int x] => _original[y + _rowOfs, x + _colOfs]; + + public override int Width { get; } + + public override int Height { get; } + } } diff --git a/ClosedXML/Excel/CalcEngine/AstNode.cs b/ClosedXML/Excel/CalcEngine/AstNode.cs index 045fc6833..3993fc15a 100644 --- a/ClosedXML/Excel/CalcEngine/AstNode.cs +++ b/ClosedXML/Excel/CalcEngine/AstNode.cs @@ -1,5 +1,7 @@ +using ClosedXML.Parser; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace ClosedXML.Excel.CalcEngine { @@ -28,10 +30,25 @@ internal class ScalarNode : ValueNode { public ScalarNode(ScalarValue value) { - Value = value.ToAnyValue(); + Value = value; } - public AnyValue Value { get; } + public ScalarValue Value { get; } + + public override TResult Accept(TContext context, IFormulaVisitor visitor) => visitor.Visit(context, this); + } + + /// + /// AST node that contains a constant array. Array is at least 1x1. + /// + internal class ArrayNode : ValueNode + { + public ArrayNode(Array value) + { + Value = value; + } + + public Array Value { get; } public override TResult Accept(TContext context, IFormulaVisitor visitor) => visitor.Visit(context, this); } @@ -112,29 +129,32 @@ public BinaryNode(BinaryOp operation, ValueNode exprLeft, ValueNode exprRight) /// internal class FunctionNode : ValueNode { - public FunctionNode(string name, List parms) : this(null, name, parms) + public FunctionNode(string name, IReadOnlyList parms) : this(null, name, parms) { } - public FunctionNode(PrefixNode prefix, string name, List parms) + public FunctionNode(PrefixNode? prefix, string name, IReadOnlyList parms) { Prefix = prefix; Name = name; Parameters = parms; } - public PrefixNode Prefix { get; } + public PrefixNode? Prefix { get; } /// /// Name of the function. /// public string Name { get; } - public List Parameters { get; } + /// + /// AST nodes for arguments of the function. + /// + public IReadOnlyList Parameters { get; } public override TResult Accept(TContext context, IFormulaVisitor visitor) => visitor.Visit(context, this); } - + /// /// An placeholder node for AST nodes that are not yet supported in ClosedXML. /// @@ -163,7 +183,7 @@ internal class FileNode : AstNode /// /// If a file is referenced directly, a path to the file on the disc/UNC/web link, . /// - public string Path { get; } + public string? Path { get; } public FileNode(string path) { @@ -189,7 +209,7 @@ public FileNode(int numeric) /// internal class PrefixNode : AstNode { - public PrefixNode(FileNode file, string sheet, string firstSheet, string lastSheet) + public PrefixNode(FileNode? file, string? sheet, string? firstSheet, string? lastSheet) { File = file; Sheet = sheet; @@ -200,22 +220,22 @@ public PrefixNode(FileNode file, string sheet, string firstSheet, string lastShe /// /// If prefix references data from another file, can be empty. /// - public FileNode File { get; } + public FileNode? File { get; } /// - /// Name of the sheet, without ! or escaped quotes. Can be empty in some cases (e.g. reference to a named range in an another file). + /// Name of the sheet, without ! or escaped quotes. Can be null in some cases e.g. reference to a named range in an another file). /// - public string Sheet { get; } + public string? Sheet { get; } /// /// If the prefix is for 3D reference, name of first sheet. Empty otherwise. /// - public string FirstSheet { get; } + public string? FirstSheet { get; } /// /// If the prefix is for 3D reference, name of the last sheet. Empty otherwise. /// - public string LastSheet { get; } + public string? LastSheet { get; } public override TResult Accept(TContext context, IFormulaVisitor visitor) => visitor.Visit(context, this); @@ -227,7 +247,7 @@ internal OneOf GetWorksheet(XLWorkbook wb) if (FirstSheet is not null || LastSheet is not null) throw new NotImplementedException("3D references are not yet implemented."); - if (!wb.TryGetWorksheet(Sheet, out var worksheet)) + if (!wb.TryGetWorksheet(Sheet, out XLWorksheet worksheet)) return XLError.CellReference; return OneOf.FromT0(worksheet); @@ -239,25 +259,34 @@ internal OneOf GetWorksheet(XLWorkbook wb) /// internal class ReferenceNode : ValueNode { - public ReferenceNode(PrefixNode prefix, ReferenceItemType type, string address) + public ReferenceNode(PrefixNode? prefix, ReferenceArea referenceArea, bool isA1) { Prefix = prefix; - Type = type; - Address = address; + Address = isA1 ? referenceArea.GetDisplayStringA1() : referenceArea.GetDisplayStringR1C1(); + ReferenceArea = referenceArea; + IsA1 = isA1; } /// /// An optional prefix for reference item. /// - public PrefixNode Prefix { get; } - - public ReferenceItemType Type { get; } + public PrefixNode? Prefix { get; } /// /// An address of a reference that corresponds to . Always without sheet (that is in the prefix). /// public string Address { get; } + /// + /// An area from a parser. + /// + public ReferenceArea ReferenceArea { get; } + + /// + /// Is the reference in A1 style? If false, then it is R1C1. + /// + public bool IsA1 { get; } + public override TResult Accept(TContext context, IFormulaVisitor visitor) => visitor.Visit(context, this); public AnyValue GetReference(CalcContext ctx) @@ -269,18 +298,16 @@ public AnyValue GetReference(CalcContext ctx) return err; // TODO: XLRangeAddress can parse all types of reference item type, utilize known type for faster parsing + cache - return new Reference(new XLRangeAddress((XLWorksheet)ws, Address)); + return new Reference(new XLRangeAddress((XLWorksheet)ws!, Address)); } } - internal enum ReferenceItemType { Cell, VRange, HRange } - /// /// A name node in the formula. Name can refers to a generic formula, in most cases a reference, but it can be any kind of calculation (e.g. A1+7). /// internal class NameNode : ValueNode { - public NameNode(PrefixNode prefix, string name) + public NameNode(PrefixNode? prefix, string name) { Prefix = prefix; Name = name; @@ -289,13 +316,13 @@ public NameNode(PrefixNode prefix, string name) /// /// An optional prefix for reference item. /// - public PrefixNode Prefix { get; } + public PrefixNode? Prefix { get; } public string Name { get; } public override TResult Accept(TContext context, IFormulaVisitor visitor) => visitor.Visit(context, this); - public AnyValue GetValue(XLWorksheet ctxWs, CalcEngine engine) + public AnyValue GetValue(XLWorksheet ctxWs, XLCalcEngine engine) { var worksheet = ctxWs; if (Prefix is not null) @@ -303,43 +330,76 @@ public AnyValue GetValue(XLWorksheet ctxWs, CalcEngine engine) if (!Prefix.GetWorksheet(ctxWs.Workbook).TryPickT0(out var ws, out var err)) return err; - worksheet = (XLWorksheet)ws; + worksheet = (XLWorksheet)ws!; } - if (!TryGetNameRange(worksheet, out var namedRange)) + if (!TryGetNameRange(worksheet, out var definedName)) return XLError.NameNotRecognized; // Parser needs an equal sign for a union of ranges (or braces around formula) - var nameFormula = namedRange.RefersTo; + var nameFormula = definedName.RefersTo; nameFormula = nameFormula.StartsWith("=") ? nameFormula : "=" + nameFormula; - return engine.EvaluateExpression(nameFormula, ctxWs.Workbook, ctxWs); + return engine.EvaluateName(nameFormula, ctxWs); } - internal bool TryGetNameRange(IXLWorksheet ws, out IXLNamedRange range) + internal bool TryGetNameRange(IXLWorksheet ws, [NotNullWhen(true)] out IXLDefinedName? definedName) { - if (ws.NamedRanges.TryGetValue(Name, out range)) + if (ws.DefinedNames.TryGetValue(Name, out var sheetDefinedName)) + { + definedName = sheetDefinedName; return true; + } - if (ws.Workbook.NamedRanges.TryGetValue(Name, out range)) + if (ws.Workbook.DefinedNamesInternal.TryGetValue(Name, out var bookDefinedName)) + { + definedName = bookDefinedName; return true; + } + definedName = null; return false; } } - // TODO: The AST node doesn't have any stuff from StructuredReference term because structured reference is not yet supported and - // the SR grammar has changed in not-yet-released (after 1.5.2) version of XLParser internal class StructuredReferenceNode : ValueNode { - public StructuredReferenceNode(PrefixNode prefix) + public StructuredReferenceNode(PrefixNode? prefix, string? table, StructuredReferenceArea area, string? firstColumn, string? lastColumn) { Prefix = prefix; + Table = table; + Area = area; + FirstColumn = firstColumn; + LastColumn = lastColumn; } /// /// Can be empty if no prefix available. /// - public PrefixNode Prefix { get; } + public PrefixNode? Prefix { get; } + + /// + /// Table of the reference. It can be empty, if formula using the reference is within + /// the table itself (e.g. total formulas). + /// + public string? Table { get; } + + /// + /// Area of the table that is considered for the range of cell of reference. + /// + public StructuredReferenceArea Area { get; } + + /// + /// First column of column range. If the reference refers to the whole table, + /// the value is null. + /// + public string? FirstColumn { get; } + + /// + /// Last column of column range. If structured reference refers only to one column, + /// it is same as . If the reference refers to the whole table, + /// the value is null. + /// + public string? LastColumn { get; } public override TResult Accept(TContext context, IFormulaVisitor visitor) => visitor.Visit(context, this); } diff --git a/ClosedXML/Excel/CalcEngine/Blank.cs b/ClosedXML/Excel/CalcEngine/Blank.cs new file mode 100644 index 000000000..3f98b12b7 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Blank.cs @@ -0,0 +1,21 @@ +#nullable disable + +namespace ClosedXML.Excel +{ + /// + /// A blank value. Used as a value of blank cells or as an optional argument for function calls. + /// + public sealed class Blank + { + private Blank() + { + } + + /// + /// Represents the sole instance of the class. + /// + public static readonly Blank Value = new(); + + public override string ToString() => string.Empty; + } +} diff --git a/ClosedXML/Excel/CalcEngine/CalcContext.cs b/ClosedXML/Excel/CalcEngine/CalcContext.cs index cb1d9b354..b8f6400a7 100644 --- a/ClosedXML/Excel/CalcEngine/CalcContext.cs +++ b/ClosedXML/Excel/CalcEngine/CalcContext.cs @@ -1,29 +1,40 @@ -using ClosedXML.Excel.CalcEngine.Exceptions; -using System; +using ClosedXML.Excel.CalcEngine.Exceptions; using System.Globalization; using System.Collections.Generic; using System.Linq; +using ClosedXML.Excel.CalcEngine.Visitors; +using ClosedXML.Parser; +using System; +using System.Threading; +using ClosedXML.Excel.CalcEngine.Functions; namespace ClosedXML.Excel.CalcEngine { internal class CalcContext { - private readonly CalcEngine _calcEngine; - private readonly XLWorkbook _workbook; - private readonly XLWorksheet _worksheet; - private readonly IXLAddress _formulaAddress; + private readonly XLCalcEngine _calcEngine; + private readonly XLWorkbook? _workbook; + private readonly XLWorksheet? _worksheet; + private readonly IXLAddress? _formulaAddress; + private readonly bool _recursive; + + public CalcContext(XLCalcEngine calcEngine, CultureInfo culture, XLCell cell) + : this(calcEngine, culture, cell.Worksheet.Workbook, cell.Worksheet, cell.Address) + { + } - public CalcContext(CalcEngine calcEngine, CultureInfo culture, XLWorkbook workbook, XLWorksheet worksheet, IXLAddress formulaAddress) + public CalcContext(XLCalcEngine calcEngine, CultureInfo culture, XLWorkbook? workbook, XLWorksheet? worksheet, IXLAddress? formulaAddress, bool recursive = false) { _calcEngine = calcEngine; _workbook = workbook; _worksheet = worksheet; _formulaAddress = formulaAddress; + _recursive = recursive; Culture = culture; } // LEGACY: Remove once legacy functions are migrated - internal CalcEngine CalcEngine => _calcEngine ?? throw new MissingContextException(); + internal XLCalcEngine CalcEngine => _calcEngine ?? throw new MissingContextException(); /// /// Worksheet of the cell the formula is calculating. @@ -51,56 +62,249 @@ public CalcContext(CalcEngine calcEngine, CultureInfo culture, XLWorkbook workbo /// public bool UseImplicitIntersection => true; - internal ScalarValue GetCellValue(XLWorksheet worksheet, int rowNumber, int columnNumber) + /// + /// Should functions be calculated per item of multi-values argument in the scalar parameters. + /// + public bool IsArrayCalculation { get; set; } + + /// + /// Sheet that is being recalculated. If set, formula can read dirty + /// values from other sheets, but not from this sheetId. + /// + public uint? RecalculateSheetId { get; set; } + + internal XLSheetPoint FormulaSheetPoint => new(FormulaAddress.RowNumber, FormulaAddress.ColumnNumber); + + /// + /// What date system should be used in calculation. Either 1900 or 1904. + /// + internal bool Use1904DateSystem { get; init; } = false; + + /// + /// An upper limit (exclusive) of used calendar system. + /// + internal double DateSystemUpperLimit => Use1904DateSystem ? XLHelper.Calendar1904UpperLimit : XLHelper.Calendar1900UpperLimit; + + internal CancellationToken CancellationToken { get; init; } = CancellationToken.None; + + /// + /// A helper method to check is user cancelled the calculation in function loops. + /// + internal void ThrowIfCancelled() { - worksheet ??= Worksheet; - var cell = worksheet.GetCell(rowNumber, columnNumber); - if (cell is null) - return ScalarValue.Blank; + CancellationToken.ThrowIfCancellationRequested(); + } - if (cell.IsEvaluating) - throw new InvalidOperationException($"Cell {cell.Address} is a part of circular reference."); + internal ScalarValue GetCellValue(XLWorksheet? sheet, int rowNumber, int columnNumber) + { + sheet ??= Worksheet; + var valueSlice = sheet.Internals.CellsCollection.ValueSlice; + var point = new XLSheetPoint(rowNumber, columnNumber); + var formula = sheet.Internals.CellsCollection.FormulaSlice.Get(point); + + if (formula is null) + return valueSlice.GetCellValue(point); + + if (!formula.IsDirty) + return valueSlice.GetCellValue(point); - return ConvertLegacyCellValueToScalarValue(cell); + // Used when only one sheet should be recalculated, leaving other sheets with their data. + if (RecalculateSheetId is not null && sheet.SheetId != RecalculateSheetId.Value) + return valueSlice.GetCellValue(point); + + // A special branch for functions out of cells (e.g. worksheet.Evaluate("A1+2")). + // These functions are not a part of calculation chain and thus reordering a chain + // for them doesn't make sense. + if (_recursive) + { + var cell = sheet.GetCell(point); + return cell?.Value ?? Blank.Value; + } + + throw new GettingDataException(new XLBookPoint(sheet.SheetId, new XLSheetPoint(rowNumber, columnNumber))); } /// - /// Get cells with a value for a reference. + /// This method goes over slices and returns a value for each non-blank cell. Because it is using + /// slice iterators, it scales with number of cells, not a size of area in reference (i.e. it works + /// fine even if reference is A1:XFD1048576). It also works for 3D references. /// - /// Reference for which to return cells. - /// A lazy (non-materialized) enumerable of cells with a value for the reference. - internal IEnumerable GetNonBlankCells(Reference reference) + internal IEnumerable GetNonBlankValues(Reference reference) { - // XLCells is not suitable here, e.g. it doesn't count a cell twice if it is in multiple areas - var nonBlankCells = Enumerable.Empty(); foreach (var area in reference.Areas) { - var areaCells = Worksheet.Internals.CellsCollection - .GetCells( - area.FirstAddress.RowNumber, area.FirstAddress.ColumnNumber, - area.LastAddress.RowNumber, area.LastAddress.ColumnNumber, - cell => !cell.IsEmpty()); - nonBlankCells = nonBlankCells.Concat(areaCells); + var sheet = area.Worksheet ?? Worksheet; + var range = XLSheetRange.FromRangeAddress(area); + + // A value can be either in a non-empty value slice or a empty cell with a formula. + var enumerator = sheet.Internals.CellsCollection.ForValuesAndFormulas(range); + while (enumerator.MoveNext()) + { + var point = enumerator.Current; + var scalarValue = GetCellValue(sheet, point.Row, point.Column); + if (!scalarValue.IsBlank) + yield return scalarValue; + } } + } - return nonBlankCells; + /// + /// Return all points in the that satisfy the . + /// + internal IEnumerable GetCriteriaPoints(XLRangeAddress areaReference, Criteria criteria) + { + var sheet = areaReference.Worksheet ?? Worksheet; + var area = XLSheetRange.FromRangeAddress(areaReference); + + // This is a performance optimization when user specifies a whole column + // in the tally function (e.g. SUMIF(A:B, "5", C:D)). + if (criteria.CanBlankValueMatch) + { + // Criteria can match blank cells, thus it's not possible to use optimized + // used enumerators and we have to check value of each cell. + foreach (var point in area) + { + var scalarValue = GetCellValue(sheet, point.Row, point.Column); + if (criteria.Match(scalarValue)) + yield return point; + } + } + else + { + // The criteria can never match blank cells. That means we can skip all blank + // cells entirely and use optimized used enumerators. + var enumerator = sheet.Internals.CellsCollection.ForValuesAndFormulas(area); + while (enumerator.MoveNext()) + { + var point = enumerator.Current; + var scalarValue = GetCellValue(sheet, point.Row, point.Column); + if (criteria.Match(scalarValue)) + yield return point; + } + } } - public static ScalarValue ConvertLegacyCellValueToScalarValue(XLCell cell) + internal IEnumerable GetFilteredNonBlankValues(Reference reference, string function, bool skipHiddenRows = false) { - // Blank cells should be 0 in Excel semantic, but cell value can't really represent it - var value = cell.Value; - return value switch + // Allocate one per call, because visitor holds info whether function was found in a formula. + var visitor = new FunctionVisitor(function); + foreach (var area in reference.Areas) { - bool logical => ScalarValue.From(logical), - double number => ScalarValue.From(number), - string text => text == string.Empty && !cell.HasFormula - ? ScalarValue.Blank - : ScalarValue.From(text), - DateTime date => ScalarValue.From(date.ToOADate()), - XLError errorType => ScalarValue.From(errorType), - _ => throw new NotImplementedException($"Not sure how to get convert value {value} (type {value?.GetType().Name}) to AnyValue.") - }; + var sheet = area.Worksheet ?? Worksheet; + var range = XLSheetRange.FromRangeAddress(area); + var currentRow = 0; + var rowIsHidden = true; + + // A value can be either in a non-empty value slice or a empty cell with a formula. + var enumerator = sheet.Internals.CellsCollection.ForValuesAndFormulas(range); + while (enumerator.MoveNext()) + { + var point = enumerator.Current; + + if (skipHiddenRows) + { + // If row changed, update hidden info about current row + if (currentRow != point.Row) + { + currentRow = point.Row; + rowIsHidden = sheet.Internals.RowsCollection.TryGetValue(currentRow, out var row) && row.IsHidden; + } + + if (rowIsHidden) + continue; + } + + var formula = sheet.Internals.CellsCollection.FormulaSlice.Get(point); + if (CallsFunction(formula, visitor)) + continue; + + var scalarValue = GetCellValue(sheet, point.Row, point.Column); + if (!scalarValue.IsBlank) + yield return scalarValue; + } + } + + yield break; + + static bool CallsFunction(XLCellFormula? formula, FunctionVisitor visitor) + { + if (formula is null) + return false; + + if (!formula.A1.Contains(visitor.FunctionName, StringComparison.OrdinalIgnoreCase)) + return false; + + FormulaParser.CellFormulaA1(formula.A1, visitor, visitor); + if (!visitor.Found) + return false; + + // In order to reuse same visitor without allocation, clear the found flag. + visitor.Clear(); + return true; + } + } + + /// + /// This method should be used mostly for range arguments. If a value is scalar, + /// return a single value enumerable. + /// + internal IEnumerable GetNonBlankValues(AnyValue value) + { + if (value.TryPickScalar(out var scalar, out var collection)) + { + if (scalar.IsBlank) + return System.Array.Empty(); + + return new ScalarArray(scalar, 1, 1); + } + + if (collection.TryPickT0(out var array, out var reference)) + return array.Where(x => !x.IsBlank); + + return GetNonBlankValues(reference); + } + + internal IEnumerable GetAllValues(AnyValue value) + { + if (value.TryPickScalar(out var scalar, out var collection)) + return new ScalarArray(scalar, 1, 1); + + if (collection.TryPickT0(out var array, out var reference)) + return array; + + return GetAllCellValues(reference); + } + + internal IEnumerable GetAllCellValues(Reference reference) + { + foreach (var area in reference.Areas) + { + var sheet = area.Worksheet; + foreach (var point in XLSheetRange.FromRangeAddress(area)) + { + yield return GetCellValue(sheet, point.Row, point.Column); + } + } + } + + private class FunctionVisitor : CollectVisitor + { + public FunctionVisitor(string function) + { + FunctionName = function; + } + + internal string FunctionName { get; } + + public bool Found { get; private set; } + + public void Clear() => Found = false; + + public override object? Function(FunctionVisitor context, SymbolRange range, ReadOnlySpan functionName, IReadOnlyList arguments) + { + Found = Found || functionName.Equals(FunctionName.AsSpan(), StringComparison.OrdinalIgnoreCase); + return default; + } } } } diff --git a/ClosedXML/Excel/CalcEngine/CalcEngine.cs b/ClosedXML/Excel/CalcEngine/CalcEngine.cs deleted file mode 100644 index 811509973..000000000 --- a/ClosedXML/Excel/CalcEngine/CalcEngine.cs +++ /dev/null @@ -1,182 +0,0 @@ -using ClosedXML.Excel.CalcEngine.Functions; -using System; -using System.Collections.Generic; -using System.Globalization; - -namespace ClosedXML.Excel.CalcEngine -{ - /// - /// CalcEngine parses strings and returns Expression objects that can - /// be evaluated. - /// - /// - /// This class has three extensibility points: - /// Use the RegisterFunction method to define custom functions. - /// - internal class CalcEngine - { - private readonly CultureInfo _culture; - private ExpressionCache _cache; // cache with parsed expressions - private readonly FormulaParser _parser; - private readonly FunctionRegistry _funcRegistry; // table with constants and functions (pi, sin, etc) - private readonly CalculationVisitor _visitor; - - public CalcEngine(CultureInfo culture) - { - _culture = culture; - _funcRegistry = GetFunctionTable(); - _cache = new ExpressionCache(this); - _parser = new FormulaParser(_funcRegistry); - _visitor = new CalculationVisitor(_funcRegistry); - } - - /// - /// Parses a string into an . - /// - /// String to parse. - /// An object that can be evaluated. - public Formula Parse(string expression) - { - var cst = _parser.ParseCst(expression); - return _parser.ConvertToAst(cst); - } - - /// - /// Evaluates an expression. - /// - /// Expression to evaluate. - /// Workbook where is formula being evaluated. - /// Worksheet where is formula being evaluated. - /// Address of formula. - /// The value of the expression. - /// - /// If you are going to evaluate the same expression several times, - /// it is more efficient to parse it only once using the - /// method and then using the Expression.Evaluate method to evaluate - /// the parsed expression. - /// - public object Evaluate(string expression, XLWorkbook wb = null, XLWorksheet ws = null, IXLAddress address = null) - { - var x = _cache != null - ? _cache[expression] - : Parse(expression); - - var ctx = new CalcContext(this, _culture, wb, ws, address); - var result = x.AstRoot.Accept(ctx, _visitor); - if (ctx.UseImplicitIntersection) - { - result = result.Match( - () => AnyValue.Blank, - logical => logical, - number => number, - text => text, - error => error, - array => array[0, 0].ToAnyValue(), - reference => reference); - } - - return ToCellContentValue(result, ctx); - } - - internal AnyValue EvaluateExpression(string expression, XLWorkbook wb = null, XLWorksheet ws = null, IXLAddress address = null) - { - // Yay, copy pasta. - var x = _cache != null - ? _cache[expression] - : Parse(expression); - - var ctx = new CalcContext(this, _culture, wb, ws, address); - var calculatingVisitor = new CalculationVisitor(_funcRegistry); - return x.AstRoot.Accept(ctx, calculatingVisitor); - } - - /// - /// Gets or sets whether the calc engine should keep a cache with parsed - /// expressions. - /// - public bool CacheExpressions - { - get { return _cache != null; } - set - { - if (value != CacheExpressions) - { - _cache = value - ? new ExpressionCache(this) - : null; - } - } - } - - // build/get static keyword table - private FunctionRegistry GetFunctionTable() - { - var fr = new FunctionRegistry(); - - // register built-in functions (and constants) - Engineering.Register(fr); - Information.Register(fr); - Logical.Register(fr); - Lookup.Register(fr); - MathTrig.Register(fr); - Text.Register(fr); - Statistical.Register(fr); - DateAndTime.Register(fr); - Financial.Register(fr); - - return fr; - } - - /// - /// Convert any kind of formula value to value returned as a content of a cell. - /// - /// bool - represents a logical value. - /// double - represents a number and also date/time as serial date-time. - /// string - represents a text value. - /// - represents a formula calculation error. - /// - /// - private static object ToCellContentValue(AnyValue value, CalcContext ctx) - { - if (value.TryPickScalar(out var scalar, out var collection)) - return ToCellContentValue(scalar); - - if (collection.TryPickT0(out var array, out var reference)) - { - return ToCellContentValue(array[0, 0]); - } - - if (reference.TryGetSingleCellValue(out var cellValue, ctx)) - return ToCellContentValue(cellValue); - - var intersected = reference.ImplicitIntersection(ctx.FormulaAddress); - if (!intersected.TryPickT0(out var singleCellReference, out var error)) - return error; - - if (!singleCellReference.TryGetSingleCellValue(out var singleCellValue, ctx)) - throw new InvalidOperationException("Got multi cell reference instead of single cell reference."); - - return ToCellContentValue(singleCellValue); - } - - private static object ToCellContentValue(ScalarValue value) - { - return value.Match( - () => 0, - logical => logical, - number => number, - text => text, - error => error); - } - } - - internal delegate AnyValue CalcEngineFunction(CalcContext ctx, Span arg); - - /// - /// Delegate that represents CalcEngine functions. - /// - /// List of objects that represent the - /// parameters to be used in the function call. - /// The function result. - internal delegate object LegacyCalcEngineFunction(List parms); -} diff --git a/ClosedXML/Excel/CalcEngine/CalcEngineHelpers.cs b/ClosedXML/Excel/CalcEngine/CalcEngineHelpers.cs deleted file mode 100644 index 10b746c05..000000000 --- a/ClosedXML/Excel/CalcEngine/CalcEngineHelpers.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; - -namespace ClosedXML.Excel.CalcEngine -{ - internal static class CalcEngineHelpers - { - private static Lazy>> patternReplacements = - new Lazy>>(() => - { - // key: the literal string to match - // value: a tuple: first item: the search pattern, second item: the replacement - return new Dictionary>() - { - [@"~~"] = new Tuple(@"~~", "~"), - [@"~*"] = new Tuple(@"~\*", @"\*"), - [@"~?"] = new Tuple(@"~\?", @"\?"), - [@"?"] = new Tuple(@"\?", ".?"), - [@"*"] = new Tuple(@"\*", ".*"), - }; - }); - - internal static bool ValueSatisfiesCriteria(object value, object criteria, CalcEngine ce) - { - // safety... - if (value == null) - { - return false; - } - - // Excel treats TRUE and 1 as unequal, but LibreOffice treats them as equal. We follow Excel's convention - if (criteria is Boolean b1) - return (value is Boolean b2) && b1.Equals(b2); - - if (value is Boolean) return false; - - // if criteria is a number, straight comparison - Double cdbl; - if (criteria is Double dbl2) cdbl = dbl2; - else if (criteria is Int32 i) cdbl = i; // results of DATE function can be an integer - else if (criteria is DateTime dt) cdbl = dt.ToOADate(); - else if (criteria is TimeSpan ts) cdbl = ts.TotalDays; - else if (criteria is String cs) - { - if (value is string && (value as string).Trim().Length == 0) - return cs.Length == 0; - - if (cs.Length == 0) - return cs.Equals(value); - - // if criteria is an expression (e.g. ">20"), use calc engine - if ((cs[0] == '=' && cs.IndexOfAny(new[] { '*', '?' }) < 0) - || cs[0] == '<' - || cs[0] == '>') - { - // build expression - var expression = string.Format("{0}{1}", value, cs); - - // add quotes if necessary - var pattern = @"([\w\s]+)(\W+)(\w+)"; - var m = Regex.Match(expression, pattern); - if (m.Groups.Count == 4 - && (!double.TryParse(m.Groups[1].Value, out double d) || - !double.TryParse(m.Groups[3].Value, out d))) - { - expression = string.Format("\"{0}\"{1}\"{2}\"", - m.Groups[1].Value, - m.Groups[2].Value, - m.Groups[3].Value); - } - - // evaluate - return (bool)ce.Evaluate(expression); - } - - // if criteria is a regular expression, use regex - if (cs.IndexOfAny(new[] { '*', '?' }) > -1) - { - if (cs[0] == '=') cs = cs.Substring(1); - - var pattern = Regex.Replace( - cs, - "(" + String.Join( - "|", - patternReplacements.Value.Values.Select(t => t.Item1)) - + ")", - m => patternReplacements.Value[m.Value].Item2); - pattern = $"^{pattern}$"; - - return Regex.IsMatch(value.ToString(), pattern, RegexOptions.IgnoreCase); - } - - // straight string comparison - if (value is string vs) - return vs.Equals(cs, StringComparison.OrdinalIgnoreCase); - else - return string.Equals(value.ToString(), cs, StringComparison.OrdinalIgnoreCase); - } - else - throw new NotImplementedException(); - - Double vdbl; - if (value is Double dbl) vdbl = dbl; - else if (value is Int32 i) vdbl = i; - else if (value is DateTime dt) vdbl = dt.ToOADate(); - else if (value is TimeSpan ts) vdbl = ts.TotalDays; - else if (value is String s) - { - if (!Double.TryParse(s, out vdbl)) return false; - } - else - throw new NotImplementedException(); - - return Math.Abs(vdbl - cdbl) < Double.Epsilon; - } - - internal static bool ValueIsBlank(object value) - { - if (value == null) - return true; - - if (value is string s) - return s.Length == 0; - - return false; - } - - /// - /// Get total count of cells in the specified range without initializing them all - /// (which might cause serious performance issues on column-wide calculations). - /// - /// Expression referring to the cell range. - /// Total number of cells in the range. - internal static long GetTotalCellsCount(XObjectExpression rangeExpression) - { - var range = (rangeExpression?.Value as CellRangeReference)?.Range; - if (range == null) - return 0; - return (long)(range.LastColumn().ColumnNumber() - range.FirstColumn().ColumnNumber() + 1) * - (long)(range.LastRow().RowNumber() - range.FirstRow().RowNumber() + 1); - } - } -} diff --git a/ClosedXML/Excel/CalcEngine/CalculationVisitor.cs b/ClosedXML/Excel/CalcEngine/CalculationVisitor.cs index 32962a795..2f686e294 100644 --- a/ClosedXML/Excel/CalcEngine/CalculationVisitor.cs +++ b/ClosedXML/Excel/CalcEngine/CalculationVisitor.cs @@ -1,5 +1,7 @@ -using System; +using System; using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using ClosedXML.Parser; namespace ClosedXML.Excel.CalcEngine { @@ -7,6 +9,7 @@ internal class CalculationVisitor : IFormulaVisitor { private readonly FunctionRegistry _functions; private readonly ArrayPool _argsPool; + public CalculationVisitor(FunctionRegistry functions) { _functions = functions; @@ -14,6 +17,11 @@ public CalculationVisitor(FunctionRegistry functions) } public AnyValue Visit(CalcContext context, ScalarNode node) + { + return node.Value.ToAnyValue(); + } + + public AnyValue Visit(CalcContext context, ArrayNode node) { return node.Value; } @@ -72,7 +80,9 @@ public AnyValue Visit(CalcContext context, FunctionNode functionNode) for (var i = 0; i < parameters.Count; ++i) args[i] = parameters[i].Accept(context, this); - return fn.CallFunction(context, args); + return !context.IsArrayCalculation + ? fn.CallFunction(context, args) + : fn.CallAsArray(context, args); } finally { @@ -94,7 +104,134 @@ public AnyValue Visit(CalcContext context, NotSupportedNode node) => throw new NotImplementedException($"Evaluation of {node.FeatureName} is not implemented."); public AnyValue Visit(CalcContext context, StructuredReferenceNode node) - => throw new NotImplementedException($"Evaluation of structured references is not implemented."); + { + // We don't support external links + if (node.Prefix is not null) + return XLError.CellReference; + + if (!TryGetTable(context, node.Table, out var table)) + return XLError.CellReference; + + var area = table.Area; + if (!TryGetColumn(table, node.FirstColumn, area.LeftColumn, out var colStart)) + return XLError.CellReference; + + if (!TryGetColumn(table, node.LastColumn, area.RightColumn, out var colEnd)) + return XLError.CellReference; + + if (colStart > colEnd) + (colEnd, colStart) = (colStart, colEnd); + + // Row range is always continuous, so the result is an area. [[#Header],[#Totals]] is + // not allowed by grammar. + if (!TryGetRows(context, table, node.Area, out var rowStart, out var rowEnd, out var error)) + return error; + + var range = new XLSheetRange(rowStart, colStart, rowEnd, colEnd); + return new Reference(XLRangeAddress.FromSheetRange(context.Worksheet, range)); + + static bool TryGetTable(CalcContext context, string? tableName, [NotNullWhen(true)] out XLTable? table) + { + // table-less references are allowed only in a table area. Excel doesn't allow + // to set it in GUI, but interprets such situation as #REF!. + if (tableName is not null) + { + return context.Workbook.TryGetTable(tableName, out table); + } + + // Avoid LINQ allocation. + var formulaPoint = context.FormulaSheetPoint; + foreach (var sheetTable in context.Worksheet.Tables) + { + if (sheetTable.Area.Contains(formulaPoint)) + { + table = sheetTable; + return true; + } + } + + table = null; + return false; + } + + static bool TryGetColumn(XLTable table, string? column, int defaultColumn, out int columnNo) + { + if (column is null) + { + columnNo = defaultColumn; + return true; + } + + if (!table.FieldNames.TryGetValue(column, out var field)) + { + columnNo = default; + return false; + } + + columnNo = field.Index + table.Area.LeftColumn; + return true; + } + + static bool TryGetRows(CalcContext context, XLTable table, StructuredReferenceArea tableArea, + out int rowStartNo, out int rowEndNo, out XLError error) + { + var area = table.Area; + var dataEndRowNo = table.ShowTotalsRow ? area.BottomRow - 1 : area.BottomRow; + switch (tableArea) + { + case StructuredReferenceArea.None: + case StructuredReferenceArea.Data: + rowStartNo = area.TopRow + 1; + rowEndNo = dataEndRowNo; + break; + case StructuredReferenceArea.Headers: + rowStartNo = area.TopRow; + rowEndNo = area.TopRow; + break; + case StructuredReferenceArea.Headers | StructuredReferenceArea.Data: + rowStartNo = area.TopRow; + rowEndNo = dataEndRowNo; + break; + case StructuredReferenceArea.Totals: + var hasTotals = table.ShowTotalsRow; + if (!hasTotals) + { + rowStartNo = rowEndNo = default; + error = XLError.CellReference; + return false; + } + + rowStartNo = area.BottomRow; + rowEndNo = area.BottomRow; + break; + case StructuredReferenceArea.Totals | StructuredReferenceArea.Data: + rowStartNo = area.TopRow + 1; + rowEndNo = area.BottomRow; + break; + case StructuredReferenceArea.All: + rowStartNo = area.TopRow; + rowEndNo = area.BottomRow; + break; + case StructuredReferenceArea.ThisRow: + var thisRow = context.FormulaSheetPoint.Row; + if (area.TopRow >= thisRow || dataEndRowNo < thisRow) + { + rowStartNo = rowEndNo = default; + error = XLError.IncompatibleValue; + return false; + } + + rowStartNo = thisRow; + rowEndNo = thisRow; + break; + default: + throw new NotSupportedException($"Unexpected value {tableArea}."); + } + + error = default; + return true; + } + } public AnyValue Visit(CalcContext context, PrefixNode node) => throw new InvalidOperationException("Node should never be visited."); diff --git a/ClosedXML/Excel/CalcEngine/CellRangeReference.cs b/ClosedXML/Excel/CalcEngine/CellRangeReference.cs deleted file mode 100644 index 92f86e44c..000000000 --- a/ClosedXML/Excel/CalcEngine/CellRangeReference.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections; - -namespace ClosedXML.Excel.CalcEngine -{ - internal class CellRangeReference : IValueObject, IEnumerable - { - public CellRangeReference(IXLRange range) - { - Range = range; - } - - public IXLRange Range { get; } - - // ** IValueObject - public object GetValue() - { - return GetValue(Range.FirstCell()); - } - - // ** IEnumerable - public IEnumerator GetEnumerator() - { - if (Range.Worksheet.IsEmpty(XLCellsUsedOptions.AllContents)) - yield break; - - var lastCellAddress = Range.Worksheet.LastCellUsed().Address; - var maxRow = Math.Min(Range.RangeAddress.LastAddress.RowNumber, lastCellAddress.RowNumber); - var maxColumn = Math.Min(Range.RangeAddress.LastAddress.ColumnNumber, lastCellAddress.ColumnNumber); - - var trimmedRange = (XLRangeBase)Range.Worksheet - .Range( - Range.FirstCell().Address, - new XLAddress(maxRow, maxColumn, fixedRow: false, fixedColumn: false) - ); - - foreach (var c in trimmedRange.CellValues()) - yield return c; - } - - private Boolean _evaluating; - - // ** implementation - private object GetValue(IXLCell cell) - { - if (_evaluating || (cell as XLCell).IsEvaluating) - { - throw new InvalidOperationException($"Circular Reference occured during evaluation. Cell: {cell.Address.ToString(XLReferenceStyle.Default, true)}"); - } - try - { - _evaluating = true; - var f = cell.FormulaA1; - if (String.IsNullOrWhiteSpace(f)) - return cell.Value; - else - { - return (cell as XLCell).Evaluate(); - } - } - finally - { - _evaluating = false; - } - } - } -} diff --git a/ClosedXML/Excel/CalcEngine/DateTimeParser.cs b/ClosedXML/Excel/CalcEngine/DateTimeParser.cs new file mode 100644 index 000000000..8bf90ba29 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/DateTimeParser.cs @@ -0,0 +1,71 @@ +#nullable disable + +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace ClosedXML.Excel.CalcEngine +{ + internal static class DateTimeParser + { + private const DateTimeStyles Style = DateTimeStyles.NoCurrentDateDefault | DateTimeStyles.AllowInnerWhite | DateTimeStyles.AllowTrailingWhite; + + // It's highly likely that Excel has its own database of culture specific patterns for parsing. + // Excel has it's own parser (that accepts 1900-02-29 ^_^), never seems to parse name of a day, + // values of hours can be up to 9999 and safely overflow... + // Although for displaying, Excel takes a cue from region setting pattern, not so for parsing (at least + // couldn't produce observable difference by changing setting of a culture in region dialogue). + // .NET Core and .NET Framework also produce different patterns for GetAllDateTimePatterns. + // This is not a perfect solution by any means, but best we can do in absence of knowledge + // what patterns Excel uses for which cultures. + private static readonly ConcurrentDictionary CultureSpecificPatterns = new(); + + private static readonly string[] TimeOfDayPatterns = { "h:m tt", "h:m t", "h:m:s tt", "h:m:s t" }; + + public static bool TryParseCultureDate(string s, CultureInfo culture, out DateTime date) + { + var datePatterns = CultureSpecificPatterns.GetOrAdd(culture, static ci => + { + // Patterns that look for exactly two MM/dd that aren't part of longer sequence of MMM/ddd. + // The MM/dd matches only two digit month/day and date recognition should be more fuzzy, it + // should recognize month/day even without leading zero. Many cultures return only MM/dd + // (NOT M/d) on GetAllDateTimePatterns. + const string leadingZeroMonthPattern = @"(? !pattern.Contains("dddd")) // It doesn't seem that Excel parser is capable of parsing day names in any culture + .Select(pattern => Regex.Replace(pattern, leadingZeroMonthPattern, "M")) // Recognize months even without leading zero + .Select(pattern => Regex.Replace(pattern, leadingZeroDayPattern, "d")) // Recognize days even without leading zero + .Distinct().ToArray(); + + // Not sure about this, but reasonably close. Hours pattern is probably generated (e.g. 'as-IN' culture + // has AM designator before hours in patterns, but Excel requires it to be at the end). There most likely + // isn't a pattern to just use. Example: for en-US, Excel type coercion can transform "aug 10, 2022 14:10", + // but every single format from CultureInfo.DateTimeFormat requires AM/PM. and two digits for minutes (thus + // the input couldn't match in any format => excel has likely it's own logic, independent of region setting). + var timePatterns = new[] { "h:m tt", "H:m", "h:m" }; + var longDatePatterns = shortDatePatterns + .SelectMany(datePattern => timePatterns.Select(timePattern => FormattableString.Invariant($"{datePattern} {timePattern}"))); + + // ISO8601 should be parseable in all cultures, not sure if Excel does. Be more forgiving, M,d instead MM,dd. + return shortDatePatterns.Concat(longDatePatterns).Concat(new[] { "yyyy-M-d" }).Distinct().ToArray(); + }); + + return DateTime.TryParseExact(s, datePatterns, culture, Style, out date); + } + + public static bool TryParseTimeOfDay(string s, CultureInfo c, out DateTime timeOfDay) + { + if (DateTime.TryParseExact(s, TimeOfDayPatterns, c, Style, out timeOfDay)) + return true; + + if (DateTime.TryParseExact(s, TimeOfDayPatterns, CultureInfo.InvariantCulture, Style, out timeOfDay)) + return true; + + return false; + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/DefaultFormulaVisitor.cs b/ClosedXML/Excel/CalcEngine/DefaultFormulaVisitor.cs deleted file mode 100644 index 4ca952265..000000000 --- a/ClosedXML/Excel/CalcEngine/DefaultFormulaVisitor.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Linq; - -namespace ClosedXML.Excel.CalcEngine -{ - /// - /// A default visitor that copies a formula. - /// - internal class DefaultFormulaVisitor : IFormulaVisitor - { - public virtual AstNode Visit(TContext context, UnaryNode node) - { - var acceptedArgument = (ValueNode)node.Expression.Accept(context, this); - return !ReferenceEquals(acceptedArgument, node.Expression) - ? new UnaryNode(node.Operation, acceptedArgument) - : node; - } - - public virtual AstNode Visit(TContext context, BinaryNode node) - { - var acceptedLeftArgument = (ValueNode)node.LeftExpression.Accept(context, this); - var acceptedRightArgument = (ValueNode)node.RightExpression.Accept(context, this); - return !ReferenceEquals(acceptedLeftArgument, node.LeftExpression) || !ReferenceEquals(acceptedRightArgument, node.RightExpression) - ? new BinaryNode(node.Operation, acceptedLeftArgument, acceptedRightArgument) - : node; - } - - public virtual AstNode Visit(TContext context, FunctionNode node) - { - var acceptedParameters = node.Parameters.Select(p => p.Accept(context, this)).Cast().ToList(); - return node.Parameters.Zip(acceptedParameters, (param, acceptedParam) => !ReferenceEquals(param, acceptedParam)).Any() - ? new FunctionNode(node.Prefix, node.Name, acceptedParameters) - : node; - } - - public virtual AstNode Visit(TContext context, ScalarNode node) => node; - - public virtual AstNode Visit(TContext context, NotSupportedNode node) => node; - - public virtual AstNode Visit(TContext context, ReferenceNode referenceNode) - { - var acceptedPrefix = referenceNode.Prefix?.Accept(context, this); - return !ReferenceEquals(acceptedPrefix, referenceNode.Prefix) - ? new ReferenceNode((PrefixNode)acceptedPrefix, referenceNode.Type, referenceNode.Address) - : referenceNode; - } - - public virtual AstNode Visit(TContext context, NameNode nameNode) - { - var acceptedPrefix = nameNode.Prefix?.Accept(context, this); - return !ReferenceEquals(acceptedPrefix, nameNode.Prefix) - ? new NameNode((PrefixNode)acceptedPrefix, nameNode.Name) - : nameNode; - } - - public virtual AstNode Visit(TContext context, StructuredReferenceNode node) => node; - - public virtual AstNode Visit(TContext context, PrefixNode prefix) - { - var acceptedFile = prefix.File?.Accept(context, this); - return !ReferenceEquals(acceptedFile, prefix.File) - ? new PrefixNode((FileNode)acceptedFile, prefix.Sheet, prefix.FirstSheet, prefix.LastSheet) - : prefix; - } - - public virtual AstNode Visit(TContext context, FileNode node) => node; - } -} diff --git a/ClosedXML/Excel/CalcEngine/DependenciesContext.cs b/ClosedXML/Excel/CalcEngine/DependenciesContext.cs new file mode 100644 index 000000000..cbd3a76ea --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/DependenciesContext.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace ClosedXML.Excel.CalcEngine +{ + /// + /// Context for , it is used + /// to collect all objects a formula depends on during calculation. + /// + internal class DependenciesContext + { + internal DependenciesContext(XLBookArea formulaArea, XLWorkbook workbook) + { + FormulaArea = formulaArea; + Workbook = workbook; + } + + /// + /// An area of a formula, in most cases just one cell, for array formulas area of cells. + /// + internal XLBookArea FormulaArea { get; } + + internal XLWorkbook Workbook { get; } + + /// + /// The result. Visitor adds all areas/names formula depends on to this. + /// + internal FormulaDependencies Dependencies { get; } = new(); + + /// + /// Add areas to a list of areas the formula depends on. Disregards duplicate entries. + /// + internal void AddAreas(List sheetAreas) => Dependencies.AddAreas(sheetAreas); + + /// + /// Add name to a list of names the formula depends on. Disregards duplicate entries. + /// + internal void AddName(XLName name) => Dependencies.AddName(name); + } +} diff --git a/ClosedXML/Excel/CalcEngine/DependenciesVisitor.cs b/ClosedXML/Excel/CalcEngine/DependenciesVisitor.cs new file mode 100644 index 000000000..a88019578 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/DependenciesVisitor.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClosedXML.Extensions; + +namespace ClosedXML.Excel.CalcEngine +{ + /// + /// + /// Visit each node and determine all ranges that might affect the formula. + /// It uses concrete values (e.g. actual range for structured references) and + /// should be refreshed when structured reference or name is changed in a workbook. + /// + /// + /// The areas found by the visitor shouldn't change when data on a worksheet changes, + /// so the output is a superset of areas, if necessary. + /// + /// + /// Precedents visitor is not completely accurate, in case of uncertainty, it uses + /// a larger area. At worst the end result is unnecessary recalculation. For simple + /// cases, it works fine and freaks like A1:IF(Other!B5,B7,Different!G3) + /// will be marked as dirty more often than strictly necessary. + /// + /// + /// Each node visitor evaluates, if the output is a reference or a value/array. If + /// the result is an array, it propagates to upper nodes, where can be things like + /// range operator. + /// + /// + internal class DependenciesVisitor : IFormulaVisitor?> + { + public List? Visit(DependenciesContext context, ScalarNode node) + { + // Scalar node can't contain sub-nodes or references. + return null; + } + + public List? Visit(DependenciesContext context, ArrayNode node) + { + // Array node can't contain sub-nodes or references. + return null; + } + + public List? Visit(DependenciesContext context, UnaryNode node) + { + var sheetAreas = node.Expression.Accept(context, this); + + // If the operand of unary node is not a reference -> end immediately, + // operator can't modify non-reference into a reference. + if (sheetAreas is null) + return null; + + // Operand is a reference + if (node.Operation is UnaryOp.ImplicitIntersection or UnaryOp.SpillRange) + { + // Both operators are ignored for now, because *spill* operators + // is part of dynamic arrays (=ignore until they are implemented) + // and *Implicit intersection* only makes area smaller and is pretty + // rare operators = also skip for now (won't affect correctness). + + // The reference must be propagated upward, because there could be + // a range operator (e.g. `B7:@A1:A5`) + return sheetAreas; + } + + // Some other operator is applied to the reference -> reference is converted + // to an array + context.AddAreas(sheetAreas); + return null; + } + + public List? Visit(DependenciesContext context, BinaryNode node) + { + // Only range operations transform ranges + var leftAreas = node.LeftExpression.Accept(context, this); + var rightAreas = node.RightExpression.Accept(context, this); + + // Reference operation only makes sense, if both sides are references. + // Otherwise reference operation results into an error. + if (leftAreas is not null && rightAreas is not null) + { + // Both sides are references - calculate new ranges and propagate + if (node.Operation == BinaryOp.Union) + { + leftAreas.AddRange(rightAreas); + return leftAreas; + } + + if (node.Operation == BinaryOp.Range) + { + var rangeResult = new List(); + + // Create a new range from both operands. It must deal with + // situation where there are multiple sheets for both operands, + // e.g. `IF(G4,Sheet1!A1,Sheet2!A2):IF(H3,Sheet2!C4,Sheet1!C5)` + // that creates a valid range. + var sheetGroups = leftAreas.Concat(rightAreas) + .GroupBy(area => area.Name, XLHelper.SheetComparer); + + // There is no simple way to go through all paths, so try to find + // largest possible ranges that could be the result. For normal + // operands(A1:B2:C3), it will work fine and for freaks, it will + // find largest possible range that is a superset of actual result. + foreach (var sheetGroup in sheetGroups) + { + var sheetAreas = sheetGroup.ToList(); + if (sheetAreas.Count == 1) + continue; + + var rangeArea = sheetAreas[0].Area; + for (var i = 1; i < sheetAreas.Count; ++i) + rangeArea = rangeArea.Range(sheetAreas[i].Area); + + rangeResult.Add(new XLBookArea(sheetGroup.Key, rangeArea)); + } + + // It's enough to return result of range operation. Operands can + // be discarded, because they are included in the result. + return rangeResult; + } + + if (node.Operation == BinaryOp.Intersection) + { + // Intersection makes range smaller, so it's rather hard to optimize + // areas. We make a special case for the most frequent case. + if (leftAreas.Count == 1 && rightAreas.Count == 1) + { + var leftArea = leftAreas[0]; + var rightArea = rightAreas[0]; + var intersection = leftArea.Intersect(rightArea); + + // Propagate only the intersection, not operands. Even if operands + // change, it doesn't affect the formula, because cells outside + // intersection are never used. + if (intersection is not null) + return new List { intersection.Value }; + + return null; + } + + // Anything else is too complicated and thus just propagate all references. + leftAreas.AddRange(rightAreas); + return leftAreas; + } + + // Operand is not a reference one, so reference is turned to array of values. + context.AddAreas(leftAreas); + context.AddAreas(rightAreas); + return null; + } + + // Both children aren't references or only one is -> binary operation transforms it + // to a non-reference, either value or #REF! + if (leftAreas is not null) + context.AddAreas(leftAreas); + + if (rightAreas is not null) + context.AddAreas(rightAreas); + + return null; + } + + public List? Visit(DependenciesContext context, FunctionNode node) + { + // According to grammar, ref functions are: CHOOSE, IF, INDEX, INDIRECT, OFFSET + // Only these functions are allowed to return references, per grammar. + // However, OFFSET and INDIRECT are volatile function that always have to be + // recalculated (=are always marked dirty). + + if (XLHelper.FunctionComparer.Equals(node.Name, "IF")) + { + // Tested value is not propagated, it's evaluated as an argument + var testReference = node.Parameters[0].Accept(context, this); + if (testReference is not null) + context.AddAreas(testReference); + + // If argument is reference and test is evaluated to TRUE, + // the reference is returned => propagate. + var valueIfTrueReference = node.Parameters[1].Accept(context, this); + var valueIfFalseReference = node.Parameters.Count == 3 + ? node.Parameters[2].Accept(context, this) + : null; + + if (valueIfFalseReference is not null && valueIfTrueReference is not null) + { + valueIfTrueReference.AddRange(valueIfFalseReference); + return valueIfTrueReference; + } + + return valueIfFalseReference ?? valueIfTrueReference; + } + + if (XLHelper.FunctionComparer.Equals(node.Name, "INDEX")) + { + // Add argument references, INDEX can have 2 or 3 arguments + for (var i = 1; i < node.Parameters.Count; ++i) + { + var argReference = node.Parameters[i].Accept(context, this); + if (argReference is not null) + context.AddAreas(argReference); + } + + // If INDEX function indexes into an area, it returns reference, + // not a value. Either way, return whole reference that is indexed, + // even though it's larger than actual function result. + var arrayReference = node.Parameters[0].Accept(context, this); + return arrayReference; + } + + if (XLHelper.FunctionComparer.Equals(node.Name, "CHOOSE")) + { + // Index argument is used to select value, so don't propagate + var indexReference = node.Parameters[0].Accept(context, this); + if (indexReference is not null) + context.AddAreas(indexReference); + + // Any of arguments can be propagated -> propagate all. + // Initialize list as null to reduce allocations + List? parametersReference = null; + for (var i = 1; i < node.Parameters.Count; ++i) + { + var parameterReference = node.Parameters[i].Accept(context, this); + if (parameterReference is null) + continue; + + if (parametersReference is not null) + parametersReference.AddRange(parameterReference); + else + parametersReference = parameterReference; + } + + return parametersReference; + } + + // All other functions can have references as arguments, but not as an output value. + foreach (var parameterNode in node.Parameters) + { + var paramReference = parameterNode.Accept(context, this); + if (paramReference is not null) + context.AddAreas(paramReference); + } + + return null; + } + + public List? Visit(DependenciesContext context, NotSupportedNode node) + { + return null; + } + + public List? Visit(DependenciesContext context, ReferenceNode node) + { + var prefix = node.Prefix; + string sheetName; + if (prefix is not null) + { + // We don't support external references, so there is no way to depend on something + // in different workbook at the moment. + if (prefix.File is not null) + return null; + + // 3D references are not supported yet, so don't propagate anything. + if (prefix.FirstSheet is not null || prefix.LastSheet is not null) + return null; + + sheetName = prefix.Sheet ?? throw new InvalidOperationException("Prefix doesn't contain sheet."); + } + else + { + sheetName = context.FormulaArea.Name; + } + + var anchor = context.FormulaArea.Area.FirstPoint; + var sheetRange = node.ReferenceArea.ToSheetRange(anchor); + return new List { new(sheetName, sheetRange) }; + } + + public List? Visit(DependenciesContext context, NameNode node) + { + // External references are not supported for names + if (node.Prefix?.File is not null) + return null; + + var name = node.Prefix?.Sheet is { } sheetName + ? new XLName(sheetName, node.Name) + : new XLName(node.Name); + context.AddName(name); + + // First, try to interpret name as a sheet scoped name. + sheetName = node.Prefix?.Sheet ?? context.FormulaArea.Name; + if (context.Workbook.TryGetWorksheet(sheetName, out XLWorksheet sheet) && + sheet.DefinedNames.TryGetScopedValue(node.Name, out var sheetDefinedName)) + { + return VisitName(sheetDefinedName); + } + + // Name is not a sheet scoped one, try workbook scoped one + if (context.Workbook.DefinedNamesInternal.TryGetScopedValue(node.Name, out var bookNamedRange)) + { + return VisitName(bookNamedRange!); + } + + // Name is not found in the workbook + return null; + + List? VisitName(XLDefinedName definedName) + { + // The named range is stored as A1 and thus parsed as A1, but should be interpreted as R1C1 + var namedFormula = definedName.RefersTo; + var ast = context.Workbook.CalcEngine.Parse(namedFormula); + var nameReferences = ast.AstRoot.Accept(context, this); + + // If the formula returned a reference, propagate it, rather + // than add to the context (required for `A1:name` ). + return nameReferences; + } + } + + public List? Visit(DependenciesContext context, StructuredReferenceNode node) + { + // TODO: Structured reference should be evaluated into a reference and propagated. + return null; + } + + public List Visit(DependenciesContext context, PrefixNode node) + { + throw new InvalidOperationException("Should never be called."); + } + + public List Visit(DependenciesContext context, FileNode node) + { + throw new InvalidOperationException("Should never be called."); + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/DependencyTree.cs b/ClosedXML/Excel/CalcEngine/DependencyTree.cs new file mode 100644 index 000000000..38cba54b0 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/DependencyTree.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RBush; + +namespace ClosedXML.Excel.CalcEngine +{ + /// + /// + /// A dependency tree structure to hold all formulas of the workbook and reference + /// objects they depend on. The key feature of dependency tree is to propagate + /// dirty flag across formulas. + /// + /// + /// When a data in a cell changes, all formulas that depend on it should be marked + /// as dirty, but it is hard to find which cells are affected - that is what + /// dependency tree does. + /// + /// + /// Dependency tree must be updated, when structure of a workbook is updated: + /// + /// Sheet is added, renamed or deleted. + /// Name is added or deleted. + /// Table is resized, renamed, added or deleted. + /// + /// Any such action changes what cells formula depends on and + /// the formula dependencies must be updated. + /// + /// + internal class DependencyTree + { + /// + /// The source of the truth, a storage of formula dependencies. The dependency tree is + /// constructed from this collection. + /// + private readonly Dictionary _dependencies = new(); + + /// + /// Visitor to extract precedents of formulas. + /// + private readonly DependenciesVisitor _visitor; + + /// + /// A dependency tree for each sheet (key is sheet name). + /// + private readonly Dictionary _sheetTrees = new(XLHelper.SheetComparer); + + public DependencyTree() + { + _visitor = new DependenciesVisitor(); + } + + internal bool IsEmpty => _sheetTrees.All(sheetTree => sheetTree.Value.IsEmpty) && _dependencies.Count == 0; + + internal static DependencyTree CreateFrom(XLWorkbook workbook) + { + var tree = new DependencyTree(); + + // Add tree before adding formulas, because formula can reference any sheet. + foreach (var sheet in workbook.WorksheetsInternal) + tree.AddSheetTree(sheet); + + foreach (var sheet in workbook.WorksheetsInternal) + { + using var enumerator = sheet.Internals.CellsCollection.FormulaSlice.GetForwardEnumerator(XLSheetRange.Full); + while (enumerator.MoveNext()) + { + var formula = enumerator.Current; + var point = enumerator.Point; + if (formula.Type == FormulaType.Normal) + { + var bookArea = new XLBookArea(sheet.Name, new XLSheetRange(point, point)); + tree.AddFormula(bookArea, formula, workbook); + } + else if (formula.Type == FormulaType.Array) + { + // Ignore all non-master cells + var isMasterCell = formula.Range.FirstPoint == point; + if (isMasterCell) + { + var bookArea = new XLBookArea(sheet.Name, formula.Range); + tree.AddFormula(bookArea, formula, workbook); + } + } + else + { + // TODO: Implement other formulas. Don't throw on data table or shared formulas. + } + } + } + + return tree; + } + + /// + /// Add a formula to the dependency tree. + /// + /// Area of a formula, for normal cells 1x1, for array can be larger. + /// The cell formula. + /// Workbook that is used to find precedents (names ect.). + /// Added cell formula dependencies. + /// Formula already is in the tree. + internal FormulaDependencies AddFormula(XLBookArea formulaArea, XLCellFormula formula, XLWorkbook workbook) + { + var precedents = GetFormulaPrecedents(formulaArea, formula, workbook); + + _dependencies.Add(formula, precedents); + + foreach (var precedentArea in precedents.Areas) + { + // Add dependency to its sheet dependency tree. The formula might contain + // a dependency for a sheet that doesn't exist in a workbook. Such dependencies + // are ignored, until sheet is added. + if (_sheetTrees.TryGetValue(precedentArea.Name, out var sheetTree)) + { + // Dependent worksheet exists + var dependent = new Dependent(formulaArea, formula); + sheetTree.AddDependent(precedentArea.Area, dependent); + } + } + + return precedents; + } + + /// + /// Remove formula from the dependency tree. + /// + /// Formula to remove. + internal void RemoveFormula(XLCellFormula formula) + { + if (!_dependencies.TryGetValue(formula, out var dependencies)) + return; + + _dependencies.Remove(formula); + foreach (var precedentArea in dependencies.Areas) + { + if (!_sheetTrees.TryGetValue(precedentArea.Name, out var sheetTree)) + throw new InvalidOperationException($"Dependency tree for sheet '{precedentArea.Name}' not found."); + + sheetTree.RemoveDependent(precedentArea.Area, formula); + } + } + + internal void AddSheetTree(IXLWorksheet sheet) + { + _sheetTrees.Add(sheet.Name, new SheetDependencyTree()); + } + + internal void RenameSheet(string oldSheetName, string newSheetName) + { + foreach (var formulaDependencies in _dependencies.Values) + formulaDependencies.RenameSheet(oldSheetName, newSheetName); + + var renamedSheetTree = _sheetTrees[oldSheetName]; + _sheetTrees.Remove(oldSheetName); + _sheetTrees.Add(newSheetName, renamedSheetTree); + + foreach (var sheetTree in _sheetTrees.Values) + sheetTree.RenameSheet(oldSheetName, newSheetName); + } + + /// + /// Mark all formulas that depend (directly or transitively) on the area as dirty. + /// + internal void MarkDirty(XLBookArea dirtyArea) + { + // BFS vs DFS: Although the longest chain found in the wild is 1000 + // formulas long, attacker could supply malicious excel with recursion + // leading to stack overflow => use queue even with extra allocation cost. + var queue = new Queue(); + queue.Enqueue(dirtyArea); + while (queue.Count > 0) + { + var affectedArea = queue.Dequeue(); + var sheetTree = _sheetTrees[affectedArea.Name]; + foreach (var area in sheetTree.FindDependentsAreas(affectedArea.Area)) + { + foreach (var dependent in area.Dependents) + { + // Ensure we don't end up in an infinite cycle + if (dependent.IsDirty) + continue; + + dependent.MarkDirty(); + queue.Enqueue(dependent.FormulaArea); + } + } + } + } + + private FormulaDependencies GetFormulaPrecedents(XLBookArea formulaArea, XLCellFormula formula, XLWorkbook workbook) + { + var ast = formula.GetAst(workbook.CalcEngine); + var context = new DependenciesContext(formulaArea, workbook); + var rootReference = ast.AstRoot.Accept(context, _visitor); + + // If formula references are propagated to the root, make sure to add them. + if (rootReference is not null) + context.AddAreas(rootReference); + + return context.Dependencies; + } + + /// + /// An area that is referred by formulas in different cells, i.e. it + /// contains precedent cells for a formula. If anything in the area + /// potentially changes, all dependents might also change. + /// + private class AreaDependents : ISpatialData + { + /// + /// An area in a sheet that is used by formulas, converted to RBush envelope. + /// All RBush double coordinates are whole numbers. + /// + private readonly Envelope _area; + + private readonly List _dependents; + + internal AreaDependents(in Envelope area, Dependent firstDependent) + { + _area = area; + _dependents = new List { firstDependent }; + } + + /// + /// The area in a sheet on which some formulas depend on. + /// + /// SIN(A4) depends on A4:A4 area.. + public ref readonly Envelope Envelope => ref _area; + + /// + /// List of formulas that depend on the range, always at least one. + /// + internal IReadOnlyList Dependents => _dependents; + + internal void AddDependent(Dependent dependent) + { + _dependents.Add(dependent); + } + + internal void RemoveDependent(XLCellFormula formula) + { + for (var i = 0; i < _dependents.Count; ++i) + { + var dependent = _dependents[i]; + + // several different formulas can depend on same area, + // remove only dependent of the formula. + if (dependent.Formula != formula) + continue; + + // Remove from list by moving the last element to the removed + // element place and decrease capacity. + _dependents[i] = _dependents[_dependents.Count - 1]; + + // Remove last item, capacity is unchanged, only list size is updated. + _dependents.RemoveAt(_dependents.Count - 1); + } + } + + internal void RenameSheet(string oldSheetName, string newSheetName) + { + for (var i = 0; i < _dependents.Count; ++i) + { + var dependent = _dependents[i]; + if (XLHelper.SheetComparer.Equals(dependent.FormulaArea.Name, oldSheetName)) + { + var renamedArea = new XLBookArea(newSheetName, dependent.FormulaArea.Area); + _dependents[i] = new Dependent(renamedArea, dependent.Formula); + } + } + } + } + + /// + /// A dependent on a precedent area. If the precedent area changes, + /// the dependent might also now be invalid. + /// + private readonly struct Dependent + { + /// + /// Area that is invalidated, when precedent area is marked as + /// dirty. Generally, it is an area of formula (1x1 for normal + /// formulas), larger for array formulas. Cell formula by itself + /// doesn't contain it's address to make it easier add/delete + /// rows/cols. + /// + internal readonly XLBookArea FormulaArea; + + internal Dependent(XLBookArea formulaArea, XLCellFormula formula) + { + FormulaArea = formulaArea; + Formula = formula; + } + + /// + /// The formula that is affected by changes in precedent area. + /// + internal XLCellFormula Formula { get; } + + internal bool IsDirty => Formula.IsDirty; + + internal bool MarkDirty() => Formula.IsDirty = true; + } + + /// + /// A dependency tree for a single worksheet. + /// + private class SheetDependencyTree + { + /// + /// The precedent areas are not duplicated, though two areas might overlap. + /// + private readonly RBush _tree; + + /// + /// All precedent areas in the sheet for all formulas in the workbook. + /// + /// + /// Not sure extra memory (at least 32 bytes per formula) is worth less CPU: O(1) vs O(log N).... + /// + private readonly Dictionary _precedentAreas; + + internal SheetDependencyTree() + { + _tree = new RBush(); + _precedentAreas = new Dictionary(); + } + + internal bool IsEmpty => _tree.Count == 0; + + internal void AddDependent(XLSheetRange precedentRange, Dependent dependent) + { + if (!_precedentAreas.TryGetValue(precedentRange, out var precedentArea)) + { + precedentArea = new AreaDependents(ToEnvelope(precedentRange), dependent); + _precedentAreas.Add(precedentRange, precedentArea); + _tree.Insert(precedentArea); + } + else + { + precedentArea.AddDependent(dependent); + } + } + + internal IReadOnlyList FindDependentsAreas(XLSheetRange dirtyRange) + { + return _tree.Search(ToEnvelope(dirtyRange)); + } + + /// + /// Remove a dependency of on a + /// from the sheet dependency tree. + /// + /// A precedent area in the sheet. + /// Formula depending on the . + internal void RemoveDependent(XLSheetRange precedentRange, XLCellFormula formula) + { + if (!_precedentAreas.TryGetValue(precedentRange, out var precedentArea)) + return; + + precedentArea.RemoveDependent(formula); + if (precedentArea.Dependents.Count == 0) + { + _tree.Delete(precedentArea); + _precedentAreas.Remove(precedentRange); + } + } + + internal void RenameSheet(string oldSheetName, string newSheetName) + { + // Area dependents instances are shared among _precedentAreas and _tree, so it is + // enough to change _precedentAreas. + foreach (var areaDependents in _precedentAreas.Values) + areaDependents.RenameSheet(oldSheetName, newSheetName); + } + + private static Envelope ToEnvelope(XLSheetRange range) + { + return new Envelope(range.LeftColumn, range.TopRow, range.RightColumn, range.BottomRow); + } + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/CalcEngineException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/CalcEngineException.cs deleted file mode 100644 index 1e7f1f7b9..000000000 --- a/ClosedXML/Excel/CalcEngine/Exceptions/CalcEngineException.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace ClosedXML.Excel.CalcEngine.Exceptions -{ - public abstract class CalcEngineException : ArgumentException - { - protected CalcEngineException() - : base() - { } - protected CalcEngineException(string message) - : base(message) - { } - - protected CalcEngineException(string message, Exception innerException) - : base(message, innerException) - { } - } -} diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/CellReferenceException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/CellReferenceException.cs deleted file mode 100644 index 85da194c6..000000000 --- a/ClosedXML/Excel/CalcEngine/Exceptions/CellReferenceException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; - -namespace ClosedXML.Excel.CalcEngine.Exceptions -{ - /// - /// This error occurs when you delete a cell referred to in the - /// formula or if you paste cells over the ones referred to in the - /// formula. - /// Corresponds to the #REF! error in Excel - /// - public class CellReferenceException : CalcEngineException - { - internal CellReferenceException() - : base() - { } - - internal CellReferenceException(string message) - : base(message) - { } - - internal CellReferenceException(string message, Exception innerException) - : base(message, innerException) - { } - - } -} diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/CellValueException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/CellValueException.cs deleted file mode 100644 index ad129dcf4..000000000 --- a/ClosedXML/Excel/CalcEngine/Exceptions/CellValueException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; - -namespace ClosedXML.Excel.CalcEngine.Exceptions -{ - /// - /// This error is most often the result of specifying a - /// mathematical operation with one or more cells that contain - /// text. - /// Corresponds to the #VALUE! error in Excel - /// - /// - public class CellValueException : CalcEngineException - { - internal CellValueException() - : base() - { } - - internal CellValueException(string message) - : base(message) - { } - - internal CellValueException(string message, Exception innerException) - : base(message, innerException) - { } - } -} diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/DivisionByZeroException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/DivisionByZeroException.cs deleted file mode 100644 index 8bc6b7b0d..000000000 --- a/ClosedXML/Excel/CalcEngine/Exceptions/DivisionByZeroException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; - -namespace ClosedXML.Excel.CalcEngine.Exceptions -{ - /// - /// The division operation in your formula refers to a cell that - /// contains the value 0 or is blank. - /// Corresponds to the #DIV/0! error in Excel - /// - /// - public class DivisionByZeroException : CalcEngineException - { - internal DivisionByZeroException() - : base() - { } - - internal DivisionByZeroException(string message) - : base(message) - { } - - internal DivisionByZeroException(string message, Exception innerException) - : base(message, innerException) - { } - - } -} diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/GettingDataException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/GettingDataException.cs new file mode 100644 index 000000000..9671087e9 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Exceptions/GettingDataException.cs @@ -0,0 +1,18 @@ +using System; + +namespace ClosedXML.Excel.CalcEngine.Exceptions +{ + /// + /// Exception that happens when formula in a cell depends on other cells, + /// but the supporting formulas are still dirty. + /// + internal class GettingDataException : Exception + { + public GettingDataException(XLBookPoint point) + { + Point = point; + } + + public XLBookPoint Point { get; } + } +} diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/MissingContextException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/MissingContextException.cs index d6edbb295..2bfa52630 100644 --- a/ClosedXML/Excel/CalcEngine/Exceptions/MissingContextException.cs +++ b/ClosedXML/Excel/CalcEngine/Exceptions/MissingContextException.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel.CalcEngine.Exceptions { diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/NameNotRecognizedException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/NameNotRecognizedException.cs deleted file mode 100644 index 7a9307511..000000000 --- a/ClosedXML/Excel/CalcEngine/Exceptions/NameNotRecognizedException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; - -namespace ClosedXML.Excel.CalcEngine.Exceptions -{ - /// - /// This error value appears when you incorrectly type the range - /// name, refer to a deleted range name, or forget to put quotation - /// marks around a text string in a formula. - /// Corresponds to the #NAME? error in Excel - /// - /// - public class NameNotRecognizedException : CalcEngineException - { - internal NameNotRecognizedException() - : base() - { } - - internal NameNotRecognizedException(string message) - : base(message) - { } - - internal NameNotRecognizedException(string message, Exception innerException) - : base(message, innerException) - { } - - } -} diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/NoValueAvailableException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/NoValueAvailableException.cs deleted file mode 100644 index 318fb9ae0..000000000 --- a/ClosedXML/Excel/CalcEngine/Exceptions/NoValueAvailableException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; - -namespace ClosedXML.Excel.CalcEngine.Exceptions -{ - /// - /// Technically, this is not an error value but a special value - /// that you can manually enter into a cell to indicate that you - /// don’t yet have a necessary value. - /// Corresponds to the #N/A error in Excel. - /// - /// - public class NoValueAvailableException : CalcEngineException - { - internal NoValueAvailableException() - : base() - { } - - internal NoValueAvailableException(string message) - : base(message) - { } - - internal NoValueAvailableException(string message, Exception innerException) - : base(message, innerException) - { } - - } -} diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/NullValueException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/NullValueException.cs deleted file mode 100644 index 5cc74bc3a..000000000 --- a/ClosedXML/Excel/CalcEngine/Exceptions/NullValueException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; - -namespace ClosedXML.Excel.CalcEngine.Exceptions -{ - /// - /// Because a space indicates an intersection, this error will - /// occur if you insert a space instead of a comma(the union operator) - /// between ranges used in function arguments. - /// Corresponds to the #NULL! error in Excel - /// - /// - public class NullValueException : CalcEngineException - { - internal NullValueException() - : base() - { } - - internal NullValueException(string message) - : base(message) - { } - - internal NullValueException(string message, Exception innerException) - : base(message, innerException) - { } - - } -} diff --git a/ClosedXML/Excel/CalcEngine/Exceptions/NumberException.cs b/ClosedXML/Excel/CalcEngine/Exceptions/NumberException.cs deleted file mode 100644 index 7d248f535..000000000 --- a/ClosedXML/Excel/CalcEngine/Exceptions/NumberException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; - -namespace ClosedXML.Excel.CalcEngine.Exceptions -{ - /// - /// This error can be caused by an invalid argument in an Excel - /// function or a formula that produces a number too large or too small - /// to be represented in the worksheet. - /// Corresponds to the #NUM! error in Excel - /// - /// - public class NumberException : CalcEngineException - { - internal NumberException() - : base() - { } - - internal NumberException(string message) - : base(message) - { } - - internal NumberException(string message, Exception innerException) - : base(message, innerException) - { } - - } -} diff --git a/ClosedXML/Excel/CalcEngine/Expression.cs b/ClosedXML/Excel/CalcEngine/Expression.cs deleted file mode 100644 index 9b7037a5e..000000000 --- a/ClosedXML/Excel/CalcEngine/Expression.cs +++ /dev/null @@ -1,303 +0,0 @@ -using ClosedXML.Excel.CalcEngine.Exceptions; -using System; -using System.Collections; -using System.Globalization; -using System.Threading; - -namespace ClosedXML.Excel.CalcEngine -{ - #region Compatibility object for legacy calculation - - /// - /// An adapter for legacy function implementations. - /// - internal class Expression : IComparable - { - private readonly object _value; - - public Expression(object value) - { - _value = value; - } - - public virtual object Evaluate() - { - if (_value is XLError error) - ThrowApplicableException(error); - return _value; - } - - - //--------------------------------------------------------------------------- - - #region ** implicit converters - - public static implicit operator string(Expression x) - { - if (x._value is XLError error) - ThrowApplicableException(error); - - var v = x.Evaluate(); - - if (v == null) - return string.Empty; - - if (v is bool b) - return b.ToString().ToUpper(); - - return v.ToString(); - } - - public static implicit operator double(Expression x) - { - if (x._value is XLError error) - ThrowApplicableException(error); - - // evaluate - var v = x.Evaluate(); - - // handle doubles - if (v is double dbl) - { - return dbl; - } - - // handle booleans - if (v is bool b) - { - return b ? 1 : 0; - } - - // handle dates - if (v is DateTime dt) - { - return dt.ToOADate(); - } - - if (v is TimeSpan ts) - { - return ts.TotalDays; - } - - // handle string - if (v is string s && double.TryParse(s, out var doubleValue)) - { - return doubleValue; - } - - // handle nulls - if (v == null || v is string) - { - return 0; - } - - // handle everything else - CultureInfo _ci = Thread.CurrentThread.CurrentCulture; - return (double)Convert.ChangeType(v, typeof(double), _ci); - } - - public static implicit operator bool(Expression x) - { - if (x._value is XLError error) - ThrowApplicableException(error); - - // evaluate - var v = x.Evaluate(); - - // handle booleans - if (v is bool b) - { - return b; - } - - // handle nulls - if (v == null) - { - return false; - } - - // handle doubles - if (v is double dbl) - { - return dbl != 0; - } - - // handle everything else - return (double)Convert.ChangeType(v, typeof(double)) != 0; - } - - public static implicit operator DateTime(Expression x) - { - if (x._value is XLError error) - ThrowApplicableException(error); - - // evaluate - var v = x.Evaluate(); - - // handle dates - if (v is DateTime dt) - { - return dt; - } - - if (v is TimeSpan ts) - { - return new DateTime().Add(ts); - } - - // handle numbers - if (v.IsNumber()) - { - return DateTime.FromOADate((double)x); - } - - // handle everything else - CultureInfo _ci = Thread.CurrentThread.CurrentCulture; - return (DateTime)Convert.ChangeType(v, typeof(DateTime), _ci); - } - - #endregion ** implicit converters - - //--------------------------------------------------------------------------- - - #region ** IComparable - - public int CompareTo(Expression other) - { - // get both values - var c1 = this.Evaluate() as IComparable; - var c2 = other.Evaluate() as IComparable; - - // handle nulls - if (c1 == null && c2 == null) - { - return 0; - } - if (c2 == null) - { - return -1; - } - if (c1 == null) - { - return +1; - } - - // make sure types are the same - if (c1.GetType() != c2.GetType()) - { - try - { - if (c1 is DateTime) - c2 = ((DateTime)other); - else if (c2 is DateTime) - c1 = ((DateTime)this); - else - c2 = Convert.ChangeType(c2, c1.GetType()) as IComparable; - } - catch (InvalidCastException) { return -1; } - catch (FormatException) { return -1; } - catch (OverflowException) { return -1; } - catch (ArgumentNullException) { return -1; } - } - - // String comparisons should be case insensitive - if (c1 is string s1 && c2 is string s2) - return StringComparer.OrdinalIgnoreCase.Compare(s1, s2); - else - return c1.CompareTo(c2); - } - - #endregion ** IComparable - - - private static void ThrowApplicableException(XLError errorType) - { - switch (errorType) - { - case XLError.CellReference: - throw new CellReferenceException(); - case XLError.IncompatibleValue: - throw new CellValueException(); - case XLError.DivisionByZero: - throw new DivisionByZeroException(); - case XLError.NameNotRecognized: - throw new NameNotRecognizedException(); - case XLError.NoValueAvailable: - throw new NoValueAvailableException(); - case XLError.NullValue: - throw new NullValueException(); - case XLError.NumberInvalid: - throw new NumberException(); - } - } - } - - /// - /// Expression that represents an external object. - /// - internal class XObjectExpression : Expression, IEnumerable - { - private readonly object _value; - - // ** ctor - internal XObjectExpression(object value) : base(value) - { - _value = value; - } - - public object Value { get { return _value; } } - - // ** object model - public override object Evaluate() - { - // use IValueObject if available - var iv = _value as IValueObject; - if (iv != null) - { - return iv.GetValue(); - } - - // return raw object - return _value; - } - - public IEnumerator GetEnumerator() - { - if (_value is string s) - { - yield return s; - } - else if (_value is IEnumerable ie) - { - foreach (var o in ie) - yield return o; - } - else - { - yield return _value; - } - } - } - - /// - /// Expression that represents an omitted parameter. - /// - internal class EmptyValueExpression : Expression - { - public EmptyValueExpression() : base(null) - { - } - } - - /// - /// Interface supported by external objects that have to return a value - /// other than themselves (e.g. a cell range object should return the - /// cell content instead of the range itself). - /// - public interface IValueObject - { - object GetValue(); - } - - #endregion -} diff --git a/ClosedXML/Excel/CalcEngine/ExpressionCache.cs b/ClosedXML/Excel/CalcEngine/ExpressionCache.cs deleted file mode 100644 index e63fa923e..000000000 --- a/ClosedXML/Excel/CalcEngine/ExpressionCache.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ClosedXML.Excel.CalcEngine -{ - /// - /// Caches expressions based on their string representation. - /// This saves parsing time. - /// - /// - /// Uses weak references to avoid accumulating unused expressions. - /// - class ExpressionCache - { - Dictionary _dct; - CalcEngine _ce; - int _hitCount; - - public ExpressionCache(CalcEngine ce) - { - _ce = ce; - _dct = new Dictionary(); - } - - // gets the parsed version of a string expression - public Formula this[string expression] - { - get - { - Formula x; - if (_dct.TryGetValue(expression, out WeakReference wr) && wr.IsAlive) - { - x = wr.Target as Formula; - } - else - { - // remove all dead references from dictionary - if (wr != null && _dct.Count > 100 && _hitCount++ > 100) - { - RemoveDeadReferences(); - _hitCount = 0; - } - - // store this expression - x = _ce.Parse(expression); - _dct[expression] = new WeakReference(x); - } - return x; - } - } - - // remove all dead references from the cache - void RemoveDeadReferences() - { - for (bool done = false; !done; ) - { - done = true; - foreach (var k in _dct.Keys) - { - if (!_dct[k].IsAlive) - { - _dct.Remove(k); - done = false; - break; - } - } - } - } - } -} diff --git a/ClosedXML/Excel/CalcEngine/ExpressionParseException.cs b/ClosedXML/Excel/CalcEngine/ExpressionParseException.cs index cf7d5174f..e8f974fe3 100644 --- a/ClosedXML/Excel/CalcEngine/ExpressionParseException.cs +++ b/ClosedXML/Excel/CalcEngine/ExpressionParseException.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace ClosedXML.Excel.CalcEngine { diff --git a/ClosedXML/Excel/CalcEngine/Formula.cs b/ClosedXML/Excel/CalcEngine/Formula.cs index e8b8af533..c65a91529 100644 --- a/ClosedXML/Excel/CalcEngine/Formula.cs +++ b/ClosedXML/Excel/CalcEngine/Formula.cs @@ -1,20 +1,19 @@ -namespace ClosedXML.Excel.CalcEngine +namespace ClosedXML.Excel.CalcEngine { - /// A non-state representation of a formula that can be used by many cells. + /// + /// A non-state representation of a formula that can be used by many cells. + /// internal class Formula { - public Formula(string text, ValueNode root, FormulaFlags flags) + public Formula(string text, ValueNode root) { AstRoot = root; Text = text; - Flags = flags; } /// Text of the formula. public string Text { get; } public ValueNode AstRoot { get; } - - public FormulaFlags Flags { get; } } } diff --git a/ClosedXML/Excel/CalcEngine/FormulaDependencies.cs b/ClosedXML/Excel/CalcEngine/FormulaDependencies.cs new file mode 100644 index 000000000..c41021c2c --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/FormulaDependencies.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; + +namespace ClosedXML.Excel.CalcEngine +{ + /// + /// A list of objects a cell formula depends on. If one of them changes, + /// the formula value might no longer be accurate and needs to be recalculated. + /// + internal class FormulaDependencies + { + private readonly HashSet _areas = new(); + private readonly HashSet _names = new(); + + /// + /// List of areas the formula depends on. It is likely a superset of accurate + /// result for unusual formulas, but if a value in an areas changes, the dependent + /// formula should be marked as dirty. + /// + public IReadOnlyCollection Areas => _areas; + + /// + /// A collection of names in the formula. If a name changes (added, deleted), + /// the formula dependencies should be refreshed, because new name might refer to + /// different references (e.g. a name previously referred to A5 and is redefined + /// to B7 or just value 7 => formula no longer depends on A5). + /// + public IReadOnlyCollection Names => _names; + + internal void AddAreas(List sheetAreas) + { + _areas.UnionWith(sheetAreas); + } + + internal void AddName(XLName name) + { + _names.Add(name); + } + + internal void RenameSheet(string oldSheetName, string newSheetName) + { + // The renaming is done for every formula, so only allocate when needed. + List<(XLBookArea Original, XLBookArea Replacement)>? areasToRename = null; + foreach (var areaInFormula in _areas) + { + if (XLHelper.SheetComparer.Equals(areaInFormula.Name, oldSheetName)) + { + var renamedArea = new XLBookArea(newSheetName, areaInFormula.Area); + areasToRename ??= new List<(XLBookArea Original, XLBookArea Replacement)>(); + areasToRename.Add((areaInFormula, renamedArea)); + } + } + + if (areasToRename is not null) + { + foreach (var (original, replacement) in areasToRename) + { + _areas.Remove(original); + _areas.Add(replacement); + } + } + + List<(XLName Original, XLName Replacement)>? namesToRename = null; + foreach (var nameInFormula in _names) + { + if (nameInFormula.SheetName is not null && + XLHelper.SheetComparer.Equals(nameInFormula.SheetName, oldSheetName)) + { + var renamedName = new XLName(newSheetName, nameInFormula.Name); + namesToRename ??= new List<(XLName Original, XLName Replacement)>(); + namesToRename.Add((nameInFormula, renamedName)); + } + } + + if (namesToRename is not null) + { + foreach (var (original, replacement) in namesToRename) + { + _names.Remove(original); + _names.Add(replacement); + } + } + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/FormulaFlags.cs b/ClosedXML/Excel/CalcEngine/FormulaFlags.cs deleted file mode 100644 index 4f0133eab..000000000 --- a/ClosedXML/Excel/CalcEngine/FormulaFlags.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; - -namespace ClosedXML.Excel.CalcEngine -{ - /// - /// Flags that contain some useful information about a formula (mostly derived from flags in functions). - /// - [Flags] - internal enum FormulaFlags : byte - { - /// - /// Basic formula that takes an input and returns output that is determined solely by the input. No side effects. - /// - None = 0, - - /// - /// Formula contains a function whose value can be different each time the function is called, even if arguments and workbook are same. - /// - /// TODAY, RAND - Volatile = 1, - - /// - /// Formula that has a side effects beside returning the value. - /// - /// HYPERLINK changes the content of a cell in a workbook. - SideEffect = 2, - - /// - /// Formula contains a reference to the SUBTOTAL function. - /// - /// Performance optimization, so a formula with SUBTOTAL doesn't have to check each dependent cell each time it is evaluated. - HasSubtotal = 4 - } -} diff --git a/ClosedXML/Excel/CalcEngine/FormulaParser.cs b/ClosedXML/Excel/CalcEngine/FormulaParser.cs index 31830e38b..802b12f21 100644 --- a/ClosedXML/Excel/CalcEngine/FormulaParser.cs +++ b/ClosedXML/Excel/CalcEngine/FormulaParser.cs @@ -1,616 +1,320 @@ -using ClosedXML.Excel.CalcEngine.Exceptions; -using Irony.Ast; -using Irony.Parsing; using System; using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using XLParser; +using ClosedXML.Extensions; +using ClosedXML.Parser; namespace ClosedXML.Excel.CalcEngine { - /// - /// A parser that takes a string and parses it into concrete syntax tree through XLParser and then - /// to abstract syntax tree that is used to evaluate the formula. - /// internal class FormulaParser { - /// - /// A prefix is that is used for functions that are present in a version of Excel, but aren't present in older versions. - /// - /// - /// If you write CONCAT(A1,B1) in Excel 2021 (not present in Excel 2013), it is saved to the worksheet file as - /// _xlfn.CONCAT(A1,B1), but the Excel GUI will show only CONCAT(A1,B1), without the _xlfn. - /// - private const string DefaultFunctionNameSpace = "_xlfn"; - - // Names for unary/binary op terms don't have a const names in the grammar - private static readonly Dictionary PrefixOpMap = new(StringComparer.Ordinal) - { - { "+", UnaryOp.Add }, - { "-", UnaryOp.Subtract }, - { "@", UnaryOp.ImplicitIntersection } - }; - - private static readonly Dictionary BinaryOpMap = new(StringComparer.Ordinal) - { - { "^", BinaryOp.Exp }, - { "*", BinaryOp.Mult }, - { "/", BinaryOp.Div }, - { "+", BinaryOp.Add }, - { "-", BinaryOp.Sub }, - { "&", BinaryOp.Concat }, - { ">", BinaryOp.Gt }, - { "=", BinaryOp.Eq }, - { "<", BinaryOp.Lt }, - { "<>", BinaryOp.Neq }, - { ">=", BinaryOp.Gte }, - { "<=", BinaryOp.Lte }, - }; - - private static readonly Dictionary ErrorMap = new(StringComparer.OrdinalIgnoreCase) - { - ["#REF!"] = XLError.CellReference, - ["#VALUE!"] = XLError.IncompatibleValue, - ["#DIV/0!"] = XLError.DivisionByZero, - ["#NAME?"] = XLError.NameNotRecognized, - ["#N/A"] = XLError.NoValueAvailable, - ["#NULL!"] = XLError.NullValue, - ["#NUM!"] = XLError.NumberInvalid - }; - - private static readonly Dictionary RangeTermMap = new(StringComparer.Ordinal) - { - { GrammarNames.Cell, ReferenceItemType.Cell }, - { GrammarNames.VerticalRange, ReferenceItemType.VRange }, - { GrammarNames.HorizontalRange, ReferenceItemType.HRange } - }; - - private static readonly Func IsErrorNode = static node => node is ScalarNode scalarNode && scalarNode.Value.TryPickError(out _); - - private readonly Parser _parser; - private readonly FunctionRegistry _fnTbl; + private readonly AstFactory _nodeFactoryA1; + private readonly AstFactory _nodeFactoryR1C1; public FormulaParser(FunctionRegistry functionRegistry) { - _parser = new Parser(GetGrammar()); - _fnTbl = functionRegistry; + _nodeFactoryA1 = new AstFactory(functionRegistry, true); + _nodeFactoryR1C1 = new AstFactory(functionRegistry, false); } - internal ParseTree ParseCst(string formulaText) + /// + /// Parse a formula into an abstract syntax tree. + /// + public Formula GetAst(string formula, bool isA1) { + // Equality sign at the beginning of formula is only visualization in the GUI, real formulas don't have it. + if (formula.Length > 0 && formula[0] == '=') + formula = formula.Substring(1); + try { - return _parser.Parse(formulaText); + var root = isA1 + ? FormulaParser.CellFormulaA1(formula, formula, _nodeFactoryA1) + : FormulaParser.CellFormulaR1C1(formula, formula, _nodeFactoryR1C1); + return new Formula(formula, root); } - catch (NullReferenceException ex) when (ex.StackTrace.StartsWith(" at Irony.Ast.AstBuilder.BuildAst(ParseTreeNode parseNode)")) + catch (ParsingException ex) { - throw new InvalidOperationException($"Unable to parse formula '{formulaText}'. Some Irony grammar term is missing AST configuration."); + throw new ExpressionParseException(ex.Message); } } /// - /// Parse a tree into a CSt that also has AST. + /// Factory to create abstract syntax tree for a formula in A1 notation. /// - public Formula ConvertToAst(ParseTree cst) - { - var astContext = new AstContext(_parser.Language); - if (cst.HasErrors()) - throw new ExpressionParseException($"Unable to parse formula '{cst.SourceText}':\n" + string.Join("\n", cst.ParserMessages.Select(c => $"Location {c.Location.Line}:{c.Location.Column} - {c.Message}"))); - - var astBuilder = new AstBuilder(astContext); - astBuilder.BuildAst(cst); - var root = (ValueNode)cst.Root.AstNode ?? throw new InvalidOperationException("Formula doesn't have AST root."); - - var flags = astContext.Values.ContainsKey(FormulaFlags.HasSubtotal) - ? FormulaFlags.HasSubtotal - : FormulaFlags.None; - return new Formula(cst.SourceText, root, flags); - } - - private ExcelFormulaGrammar GetGrammar() + private sealed class AstFactory : IAstFactory { - var grammar = new ExcelFormulaGrammar(); - grammar.FormulaWithEq.AstConfig.NodeCreator = CreateCopyNode(1); - grammar.Formula.AstConfig.NodeCreator = CreateCopyNode(0); - grammar.ArrayFormula.AstConfig.NodeCreator = CreateNotImplementedNode("array formula"); - grammar.ArrayFormula.SetFlag(TermFlags.AstDelayChildren); - grammar.ReservedName.AstConfig.NodeCreator = CreateNotImplementedNode("reserved name"); - grammar.ReservedName.SetFlag(TermFlags.AstDelayChildren); - - grammar.MultiRangeFormula.AstConfig.NodeCreator = CreateCopyNode(1); - grammar.Union.AstConfig.NodeCreator = CreateUnionNode; - grammar.intersectop.SetFlag(TermFlags.NoAstNode); - - grammar.Constant.AstConfig.NodeCreator = CreateCopyNode(0); - grammar.Number.AstConfig.NodeCreator = CreateNumberNode; - grammar.Number.SetFlag(TermFlags.AstDelayChildren); - grammar.Bool.AstConfig.NodeCreator = CreateBoolNode; - grammar.Bool.SetFlag(TermFlags.AstDelayChildren); - grammar.Text.AstConfig.NodeCreator = CreateTextNode; - grammar.Text.SetFlag(TermFlags.AstDelayChildren); - grammar.Error.AstConfig.NodeCreator = CreateErrorNode; - grammar.Error.SetFlag(TermFlags.AstDelayChildren); - grammar.RefError.AstConfig.NodeCreator = CreateErrorNode; - grammar.RefError.SetFlag(TermFlags.AstDelayChildren); - grammar.ConstantArray.AstConfig.NodeCreator = CreateNotImplementedNode("constant array"); - grammar.ConstantArray.SetFlag(TermFlags.AstDelayChildren); - - grammar.FunctionCall.AstConfig.NodeCreator = GetFunctionCallNodeFactory(); - grammar.FunctionName.SetFlag(TermFlags.NoAstNode); - grammar.Arguments.AstConfig.NodeCreator = (_, _) => { }; // Irony shouldn't throw if no factory exist, but it does = use empty factory. - grammar.Argument.AstConfig.NodeCreator = CreateCopyNode(0); - grammar.EmptyArgument.AstConfig.NodeCreator = CreateEmptyArgumentNode; - grammar.EmptyArgument.SetFlag(TermFlags.AstDelayChildren); - - grammar.Reference.AstConfig.NodeCreator = CreateReferenceNodeFactory(); - - // ReferenceItem term is transient - ReferenceNode will create AST nodes for Cell..HRange. - grammar.Cell.SetFlag(TermFlags.NoAstNode); - grammar.NamedRange.SetFlag(TermFlags.NoAstNode); - grammar.VRange.SetFlag(TermFlags.NoAstNode); - grammar.HRange.SetFlag(TermFlags.NoAstNode); - grammar.UDFunctionCall.AstConfig.NodeCreator = CreateUDFunctionNode; - grammar.UDFName.SetFlag(TermFlags.NoAstNode); - grammar.StructuredReference.AstConfig.NodeCreator = CreateStructuredReferenceNode; - grammar.StructuredReference.SetFlag(TermFlags.AstDelayChildren); - - grammar.ReferenceFunctionCall.AstConfig.NodeCreator = CreateReferenceFunctionCallNodeFactory(); - grammar.RefFunctionName.SetFlag(TermFlags.NoAstNode); - - // DDE formula parsing in XLParser seems to be buggy. It can't parse any 'in-the-wild' examples I have found. - grammar.DynamicDataExchange.AstConfig.NodeCreator = CreateNotImplementedNode("dynamic data exchange"); - grammar.DynamicDataExchange.SetFlag(TermFlags.AstDelayChildren); - - grammar.Prefix.AstConfig.NodeCreator = GetPrefixNodeCreator(); - grammar.SheetToken.SetFlag(TermFlags.NoAstNode); - grammar.SheetQuotedToken.SetFlag(TermFlags.NoAstNode); - grammar.MultipleSheetsToken.SetFlag(TermFlags.NoAstNode); - grammar.MultipleSheetsQuotedToken.SetFlag(TermFlags.NoAstNode); - - grammar.File.AstConfig.NodeCreator = CreateFileNodeFactory(); - grammar.File.SetFlag(TermFlags.AstDelayChildren); - - return grammar; - } - - private void CreateNumberNode(AstContext context, ParseTreeNode parseNode) - { - var value = parseNode.ChildNodes.Single().Token.Value; - parseNode.AstNode = new ScalarNode(value is int intValue ? (double)intValue : (double)value); - } + /// + /// A prefix for so-called future functions. Excel can add functions, but to avoid name collisions, + /// it prefixes names of function with this prefix. The prefix is omitted from GUI. + /// + /// + /// If you write CONCAT(A1,B1) in Excel 2021 (not present in Excel 2013), it is saved to the + /// worksheet file as _xlfn.CONCAT(A1,B1), but the Excel GUI will show only CONCAT(A1,B1), + /// without the _xlfn. + /// + private const string DefaultFunctionNameSpace = "_xlfn"; + + private readonly FunctionRegistry _functionRegistry; + private readonly bool _isA1; + + internal AstFactory(FunctionRegistry functionRegistry, bool isA1) + { + _functionRegistry = functionRegistry; + _isA1 = isA1; + } - private void CreateBoolNode(AstContext context, ParseTreeNode parseNode) - { - var boolValue = string.Equals(parseNode.ChildNodes.Single().Token.Text, "TRUE", StringComparison.OrdinalIgnoreCase); - parseNode.AstNode = new ScalarNode(boolValue); - } + public ScalarValue LogicalValue(string context, SymbolRange range, bool logical) => logical; - private void CreateTextNode(AstContext context, ParseTreeNode parseNode) - { - parseNode.AstNode = new ScalarNode(parseNode.ChildNodes.Single().Token.ValueString); - } + public ScalarValue NumberValue(string context, SymbolRange range, double number) => number; - private void CreateErrorNode(AstContext context, ParseTreeNode parseNode) - { - var errorType = ErrorMap[parseNode.ChildNodes.Single().Token.Text]; - parseNode.AstNode = new ScalarNode(errorType); - } + public ScalarValue TextValue(string context, SymbolRange range, string text) => text; - private AstNodeFactory GetFunctionCallNodeFactory() - { - return new() + public ScalarValue ErrorValue(string context, SymbolRange range, ReadOnlySpan errorText) { - { - For(PrefixOpMap.Keys.ToArray(), GrammarNames.Formula), - node => new UnaryNode(PrefixOpMap[node.ChildNodes[0].Term.Name], (ValueNode)node.ChildNodes[1].AstNode) - }, - { - For(GrammarNames.Formula, "%"), - node => new UnaryNode(UnaryOp.Percentage, (ValueNode)node.ChildNodes[0].AstNode) - }, - { - For(GrammarNames.FunctionName, GrammarNames.Arguments), - (node, ctx) => CreateExcelFunctionCallExpression(ctx, node.ChildNodes[0], node.ChildNodes[1]) - }, - { - For(GrammarNames.Formula, BinaryOpMap.Keys.ToArray(), GrammarNames.Formula), - node => new BinaryNode(BinaryOpMap[node.ChildNodes[1].Term.Name], (ValueNode)node.ChildNodes[0].AstNode, (ValueNode)node.ChildNodes[2].AstNode) - } - }; - } + return GetErrorValue(errorText); + } - /// - /// Reference AST node is significantly different from CST node. It takes Reference, ReferenceFunctionCall and ReferenceItem terms into a reference value - /// that represent an area of a workbook (ReferenceNode, StructuredReferenceNode) and operations over these areas (BinaryOperation, UnaryOperation, FunctionExpression). - /// - private AstNodeFactory CreateReferenceNodeFactory() - { - return new() + public ValueNode ArrayNode(string context, SymbolRange range, int rows, int columns, + IReadOnlyList elements) { - { - // ReferenceItem is transient, so its rules are basically merged with Reference - Cell, NamedRange, VRange, HRange - For(new[] { GrammarNames.Cell, GrammarNames.VerticalRange, GrammarNames.HorizontalRange }), - node => new ReferenceNode(null, RangeTermMap[node.ChildNodes[0].Term.Name], node.ChildNodes[0].ChildNodes.Single().Token.Text) - }, - { - // Named range can be NameToken or NamedRangeCombinationToken. The combination token is there only to detect names like A1A1. - For(GrammarNames.NamedRange), - node => new NameNode(null, node.ChildNodes[0].ChildNodes.Single().Token.Text) - }, - { - // ReferenceItem:RefError. #REF! error is not grouped with other errors, but is a part of Reference term. - For(IsErrorNode), - node => (ScalarNode)node.ChildNodes[0].AstNode - }, - { - // ReferenceItem:UDFunctionCall - For(GrammarNames.UDFunctionCall), - node => - { - var fn = (FunctionNode)node.ChildNodes[0].AstNode; - return new FunctionNode(null, fn.Name, fn.Parameters); - } - }, - { - // ReferenceItem:StructuredReference. TODO: Copy structured reference once implemented - For(GrammarNames.StructuredReference), - node => new StructuredReferenceNode(null) - }, - { - // ReferenceFunctionCall - Reference + colon + Reference - // ReferenceFunctionCall - Reference + intersectop + Reference - // ReferenceFunctionCall - Reference + Union + Reference - For(typeof(BinaryNode)), - node => (BinaryNode)node.ChildNodes[0].AstNode - }, - { - // ReferenceFunctionCall - RefFunctionName + Arguments + CloseParen - For(typeof(FunctionNode)), - node => (FunctionNode)node.ChildNodes[0].AstNode - }, - { - // ReferenceFunctionCall - Reference + hash - For(typeof(UnaryNode)), - node => (UnaryNode)node.ChildNodes[0].AstNode - }, - { - // OpenParen + Reference + CloseParen - For(typeof(ReferenceNode)), - node => (ReferenceNode)node.ChildNodes[0].AstNode - }, - { - // Prefix + ReferenceItem:Cell|NamedRange|VRange|HRange - // Split into two branches, because named range is not actually range, but an alias for a formula - For(typeof(PrefixNode), new[] { GrammarNames.Cell, GrammarNames.VerticalRange, GrammarNames.HorizontalRange }), - node => new ReferenceNode((PrefixNode)node.ChildNodes[0].AstNode, RangeTermMap[node.ChildNodes[1].Term.Name], node.ChildNodes[1].ChildNodes.Single().Token.Text) - }, - { - // Prefix + ReferenceItem:Cell|NamedRange|VRange|HRange - For(typeof(PrefixNode), GrammarNames.NamedRange), - node => new NameNode((PrefixNode)node.ChildNodes[0].AstNode, node.ChildNodes[1].ChildNodes.Single().Token.Text) - }, - { - // Prefix + ReferenceItem:RefError - For(typeof(PrefixNode), IsErrorNode), - node => - { - // I think =#REF!#REF! was evaluated to #REF! in Excel 2021. - return (ScalarNode)node.ChildNodes[1].AstNode; - } - }, - { - // Prefix + ReferenceItem:UDFunctionCall - For(typeof(PrefixNode), GrammarNames.UDFunctionCall), - node => - { - var prefix = (PrefixNode)node.ChildNodes[0].AstNode; - var fn = (FunctionNode)node.ChildNodes[1].AstNode; - return new FunctionNode(prefix, fn.Name, fn.Parameters); - } - }, - { - // Prefix + ReferenceItem:StructuredReference. TODO: Copy structured reference once implemented - For(typeof(PrefixNode), GrammarNames.StructuredReference), - node => new StructuredReferenceNode(null) - }, - { - For(GrammarNames.DynamicDataExchange), - node => new NotSupportedNode("dynamic data exchange") - } - }; - } + var array = new LiteralArray(rows, columns, elements); + return new ArrayNode(array); + } - // AST node created by this factory is mostly just copied upwards in the ReferenceNode factory. - private AstNodeFactory CreateReferenceFunctionCallNodeFactory() - { - return new() + public ValueNode BlankNode(string context, SymbolRange range) { - { - For(GrammarNames.Reference, ":", GrammarNames.Reference), - node => new BinaryNode(BinaryOp.Range, (ValueNode)node.ChildNodes[0].AstNode, (ValueNode)node.ChildNodes[2].AstNode) - }, - { - For(GrammarNames.Reference, GrammarNames.TokenIntersect, GrammarNames.Reference), - node => new BinaryNode(BinaryOp.Intersection, (ValueNode)node.ChildNodes[0].AstNode, (ValueNode)node.ChildNodes[2].AstNode) - }, - { - For(GrammarNames.Union), - node => (ValueNode)node.ChildNodes.Single().AstNode - }, - { - For(GrammarNames.RefFunctionName, GrammarNames.Arguments), - (node, ctx) => CreateExcelFunctionCallExpression(ctx, node.ChildNodes[0], node.ChildNodes[1]) - }, - { - For(GrammarNames.Reference, "#"), - node => new UnaryNode(UnaryOp.SpillRange, (ValueNode)node.ChildNodes[0].AstNode) - } - }; - } + return new ScalarNode(ScalarValue.Blank); + } - private AstNodeFactory GetPrefixNodeCreator() - { - return new() + public ValueNode LogicalNode(string context, SymbolRange range, bool logical) { - { - For(GrammarNames.TokenSheet), - node => - { - var sheetName = RemoveExclamationMark(node.ChildNodes[0].Token.Text); - return new PrefixNode(null, sheetName, null, null); - } - }, - { - For("'", GrammarNames.TokenSheetQuoted), - node => - { - var quotedSheetName = RemoveExclamationMark("'" + node.ChildNodes[1].Token.Text); - return new PrefixNode(null, quotedSheetName.UnescapeSheetName(), null, null); - } - }, - { - For(typeof(FileNode), GrammarNames.TokenSheet), - node => - { - var fileNode = (FileNode)node.ChildNodes[0].AstNode; - var sheetName = RemoveExclamationMark(node.ChildNodes[1].Token.Text); - return new PrefixNode(fileNode, sheetName, null, null); - } - }, - { - For("'", typeof(FileNode), GrammarNames.TokenSheetQuoted), - node => - { - var fileNode = (FileNode)node.ChildNodes[1].AstNode; - var quotedSheetName = RemoveExclamationMark("'" + node.ChildNodes[2].Token.Text); - return new PrefixNode(fileNode, quotedSheetName.UnescapeSheetName(), null, null); - } - }, - { - For(typeof(FileNode), "!"), - node => - { - var fileNode = (FileNode)node.ChildNodes[0].AstNode; - return new PrefixNode(fileNode, null, null, null); - } - }, - { - For(GrammarNames.TokenMultipleSheets), - node => - { - var normalSheets = RemoveExclamationMark(node.ChildNodes[0].Token.Text).Split(':'); - return new PrefixNode(null, null, normalSheets[0], normalSheets[1]); - } - }, - { - For("'", GrammarNames.TokenMultipleSheetsQuoted), - node => - { - var quotedSheets = RemoveExclamationMark(("'" + node.ChildNodes[1].Token.Text).UnescapeSheetName()).Split(':'); - return new PrefixNode(null, null, quotedSheets[0], quotedSheets[1]); - } - }, - { - For(typeof(FileNode), GrammarNames.TokenMultipleSheets), - node => - { - var fileNode = (FileNode)node.ChildNodes[0].AstNode; - var normalSheets = RemoveExclamationMark(node.ChildNodes[1].Token.Text).Split(':'); - return new PrefixNode(fileNode, null, normalSheets[0], normalSheets[1]); - } - }, - { - For("'", typeof(FileNode), GrammarNames.TokenMultipleSheetsQuoted), - node => - { - var fileNode = (FileNode)node.ChildNodes[1].AstNode; - var quotedSheets = RemoveExclamationMark(("'" + node.ChildNodes[2].Token.Text).UnescapeSheetName()).Split(':'); - return new PrefixNode(fileNode, null, quotedSheets[0], quotedSheets[1]); - } - }, - { - For(GrammarNames.TokenRefError), - node => - { - // #REF! is a valid sheet name, Token.ValueString is lower case for some reason. - return new PrefixNode(null, RemoveExclamationMark(node.ChildNodes[0].Token.Text), null, null); - } - } - }; - } + return new ScalarNode(logical); + } - private AstNodeFactory CreateFileNodeFactory() - { - return new() + public ValueNode ErrorNode(string context, SymbolRange range, ReadOnlySpan errorText) { - { - For(GrammarNames.TokenFileNameNumeric), - node => - { - var numberInBrackets = node.ChildNodes[0].Token.Text; - var fileNumericIndex = int.Parse(StripBrackets(numberInBrackets), NumberStyles.None); - return new FileNode(fileNumericIndex); - } - }, - { - For(GrammarNames.TokenFileNameEnclosedInBrackets), - node => new FileNode(node.ChildNodes[0].Token.Text) - }, - { - For(GrammarNames.TokenFilePath, GrammarNames.TokenFileNameEnclosedInBrackets), - node => - { - var filePath = node.ChildNodes[0].Token.Text; - var fileName = node.ChildNodes[1].Token.Text; - return new FileNode(System.IO.Path.Combine(filePath, StripBrackets(fileName))); - } - }, - { - For(GrammarNames.TokenFilePath, GrammarNames.TokenFileName), - node => - { - var filePath = node.ChildNodes[0].Token.Text; - var fileName = node.ChildNodes[1].Token.Text; - return new FileNode(System.IO.Path.Combine(filePath, fileName)); - } - } - }; - } + var error = GetErrorValue(errorText); + return new ScalarNode(error); + } - private void CreateUDFunctionNode(AstContext context, ParseTreeNode parseNode) - { - var functionName = parseNode.ChildNodes[0].ChildNodes.Single().Token.Text.WithoutLast(1); + public ValueNode NumberNode(string context, SymbolRange range, double number) + { + return new ScalarNode(number); + } - if (functionName.StartsWith($"{DefaultFunctionNameSpace}.")) + public ValueNode TextNode(string context, SymbolRange range, string text) { - parseNode.AstNode = CreateExcelFunctionCallExpression(context, parseNode.ChildNodes[0], parseNode.ChildNodes[1]); - return; + return new ScalarNode(text); } - var arguments = parseNode.ChildNodes[1].ChildNodes.Select(treeNode => treeNode.AstNode).Cast().ToList(); - parseNode.AstNode = new FunctionNode(functionName, arguments); ; - } + public ValueNode Reference(string context, SymbolRange range, ReferenceArea area) + { + return new ReferenceNode(null, area, _isA1); + } - private FunctionNode CreateExcelFunctionCallExpression(AstContext ctx, ParseTreeNode nameNode, ParseTreeNode argumentsNode) - { - var functionName = nameNode.ChildNodes.Single().Token.Text.WithoutLast(1); - var foundFunction = _fnTbl.TryGetFunc(functionName, out var parmMin, out var parmMax); - if (!foundFunction && functionName.StartsWith($"{DefaultFunctionNameSpace}.")) + public ValueNode SheetReference(string context, SymbolRange range, string sheet, ReferenceArea area) { - functionName = functionName.Substring(DefaultFunctionNameSpace.Length + 1); - foundFunction = _fnTbl.TryGetFunc(functionName, out parmMin, out parmMax); + var prefixNode = new PrefixNode(null, sheet, null, null); + return new ReferenceNode(prefixNode, area, _isA1); } - if (!foundFunction) - throw new NameNotRecognizedException($"The function `{functionName}` was not recognised."); + public ValueNode BangReference(string context, SymbolRange range, ReferenceArea reference) + { + return new NotSupportedNode("Bang reference"); + } - var arguments = argumentsNode.ChildNodes.Select(treeNode => treeNode.AstNode).Cast().ToList(); - if (parmMin != -1 && arguments.Count < parmMin) - throw new ExpressionParseException($"Too few parameters for function '{functionName}'. Expected a minimum of {parmMin} and a maximum of {parmMax}."); + public ValueNode Reference3D(string context, SymbolRange range, string firstSheet, string lastSheet, + ReferenceArea area) + { + var prefixNode = new PrefixNode(null, null, firstSheet, lastSheet); + return new ReferenceNode(prefixNode, area, _isA1); + } - if (parmMax != -1 && arguments.Count > parmMax) - throw new ExpressionParseException($"Too many parameters for function '{functionName}'.Expected a minimum of {parmMin} and a maximum of {parmMax}."); + public ValueNode ExternalSheetReference(string context, SymbolRange range, int workbookIndex, string sheet, + ReferenceArea area) + { + var fileNode = new FileNode(workbookIndex); + var prefixNode = new PrefixNode(fileNode, sheet, null, null); + return new ReferenceNode(prefixNode, area, _isA1); + } - if (string.Equals(functionName, @"SUBTOTAL", StringComparison.OrdinalIgnoreCase)) - ctx.Values[FormulaFlags.HasSubtotal] = true; + public ValueNode ExternalReference3D(string context, SymbolRange range, int workbookIndex, string firstSheet, + string lastSheet, ReferenceArea area) + { + var fileNode = new FileNode(workbookIndex); + var prefixNode = new PrefixNode(fileNode, null, firstSheet, lastSheet); + return new ReferenceNode(prefixNode, area, _isA1); + } - return new FunctionNode(functionName, arguments); - } + public ValueNode Function(string context, SymbolRange range, ReadOnlySpan name, + IReadOnlyList args) + { + var functionName = name.ToString(); + return GetFunctionNode(null, functionName, args); + } - private static AstNodeCreator CreateCopyNode(int childIndex) - { - return (context, parseNode) => + public ValueNode Function(string context, SymbolRange range, string sheetName, ReadOnlySpan name, + IReadOnlyList args) { - var copyNode = parseNode.ChildNodes[childIndex]; - parseNode.AstNode = copyNode.AstNode; - }; - } + var prefixNode = new PrefixNode(null, sheetName, null, null); + return GetFunctionNode(prefixNode, name.ToString(), args); + } - private static AstNodeCreator CreateNotImplementedNode(string featureText) - { - return (_, parseNode) => parseNode.AstNode = new NotSupportedNode(featureText); - } + public ValueNode ExternalFunction(string context, SymbolRange range, int workbookIndex, string sheet, + ReadOnlySpan name, IReadOnlyList args) + { + var prefixNode = new PrefixNode(new FileNode(workbookIndex), sheet, null, null); + return GetFunctionNode(prefixNode, name.ToString(), args); + } - private void CreateUnionNode(AstContext context, ParseTreeNode parseNode) - { - var unionRangeNode = (ValueNode)parseNode.ChildNodes[0].AstNode; - foreach (var referenceNode in parseNode.ChildNodes.Skip(1)) - unionRangeNode = new BinaryNode(BinaryOp.Union, unionRangeNode, (ValueNode)referenceNode.AstNode); - parseNode.AstNode = unionRangeNode; - } + public ValueNode ExternalFunction(string context, SymbolRange range, int workbookIndex, ReadOnlySpan name, + IReadOnlyList args) + { + var prefixNode = new PrefixNode(new FileNode(workbookIndex), null, null, null); + return GetFunctionNode(prefixNode, name.ToString(), args); + } - private void CreateEmptyArgumentNode(AstContext context, ParseTreeNode parseNode) - { - parseNode.AstNode = new ScalarNode(ScalarValue.Blank); - } + public ValueNode CellFunction(string context, SymbolRange range, RowCol cell, + IReadOnlyList args) + { + // Grammar technically allows to evaluate a function from a different cell. The intended + // usage is likely for lambda functions. Excel (as of 2022) doesn't do that, so use preference + // as LOG10. Parser doesn't know about names of functions, so names such as LOG10 will always end up + // here. + var functionName = context.Substring(range.Start, context.IndexOf('(', range.Start) - range.Start); + if (_functionRegistry.TryGetFunc(functionName, out _, out _)) + return new FunctionNode(functionName, args); + + // Nonexistent function is evaluated to #NAME?, but cell function should be evaluated to #REF! + return new ScalarNode(XLError.CellReference); + } - public void CreateStructuredReferenceNode(AstContext context, ParseTreeNode parseNode) - { - parseNode.AstNode = new StructuredReferenceNode(null); - } + public ValueNode StructureReference(string context, SymbolRange range, StructuredReferenceArea area, + string? firstColumn, string? lastColumn) + { + return new StructuredReferenceNode(null, null, area, firstColumn, lastColumn); + } - private static string RemoveExclamationMark(string sheetName) - { - if (!sheetName.EndsWith("!")) - throw new ArgumentException($"'{sheetName}' doesn't end with !", nameof(sheetName)); + public ValueNode StructureReference(string context, SymbolRange range, string table, StructuredReferenceArea area, + string? firstColumn, string? lastColumn) + { + return new StructuredReferenceNode(null, table, area, firstColumn, lastColumn); + } - return sheetName.Substring(0, sheetName.Length - 1); - } + public ValueNode ExternalStructureReference(string context, SymbolRange range, int workbookIndex, string table, + StructuredReferenceArea area, string? firstColumn, string? lastColumn) + { + return new StructuredReferenceNode(new PrefixNode(new FileNode(workbookIndex), null, null, null), table, + area, firstColumn, lastColumn); + } - private string StripBrackets(string fileName) - { - if (!fileName.StartsWith("[") || !fileName.EndsWith("]")) - throw new ArgumentException($"'{fileName}' isn't a text in []", nameof(fileName)); + public ValueNode Name(string context, SymbolRange range, string name) + { + return new NameNode(null, name); + } - return fileName.Substring(1, fileName.Length - 2); - } + public ValueNode SheetName(string context, SymbolRange range, string sheet, string name) + { + var prefixNode = new PrefixNode(null, sheet, null, null); + return new NameNode(prefixNode, name); + } - private static NodePredicate[] For(params NodePredicate[] conditions) => conditions; + public ValueNode BangName(string context, SymbolRange range, string name) + { + return new NotSupportedNode("Bang name"); + } - private class AstNodeFactory : System.Collections.IEnumerable - { - private readonly List>> _factories = new(); + public ValueNode ExternalName(string context, SymbolRange range, int workbookIndex, string name) + { + var prefixNode = new PrefixNode(new FileNode(workbookIndex), null, null, null); + return new NameNode(prefixNode, name); + } - public void Add(NodePredicate[] cstNodeConditions, Func astNodeFactory) - => _factories.Add(new KeyValuePair>(cstNodeConditions, (node, _) => astNodeFactory(node))); + public ValueNode ExternalSheetName(string context, SymbolRange range, int workbookIndex, string sheet, string name) + { + var prefixNode = new PrefixNode(new FileNode(workbookIndex), sheet, null, null); + return new NameNode(prefixNode, name); + } - public void Add(NodePredicate[] cstNodeConditions, Func astNodeFactory) - => _factories.Add(new KeyValuePair>(cstNodeConditions, astNodeFactory)); + public ValueNode BinaryNode(string context, SymbolRange range, BinaryOperation operation, ValueNode leftNode, + ValueNode rightNode) + { + var op = operation switch + { + BinaryOperation.Concat => BinaryOp.Concat, + BinaryOperation.GreaterOrEqualThan => BinaryOp.Gte, + BinaryOperation.LessOrEqualThan => BinaryOp.Lte, + BinaryOperation.LessThan => BinaryOp.Lt, + BinaryOperation.GreaterThan => BinaryOp.Gt, + BinaryOperation.NotEqual => BinaryOp.Neq, + BinaryOperation.Equal => BinaryOp.Eq, + BinaryOperation.Addition => BinaryOp.Add, + BinaryOperation.Subtraction => BinaryOp.Sub, + BinaryOperation.Multiplication => BinaryOp.Mult, + BinaryOperation.Division => BinaryOp.Div, + BinaryOperation.Power => BinaryOp.Exp, + BinaryOperation.Union => BinaryOp.Union, + BinaryOperation.Intersection => BinaryOp.Intersection, + BinaryOperation.Range => BinaryOp.Range, + _ => throw new NotSupportedException($"'{operation}' is not a binary operation.") + }; + + return new BinaryNode(op, leftNode, rightNode); + } - public System.Collections.IEnumerator GetEnumerator() => throw new NotSupportedException(); + public ValueNode Unary(string context, SymbolRange range, UnaryOperation operation, ValueNode node) + { + var op = operation switch + { + UnaryOperation.Plus => UnaryOp.Add, + UnaryOperation.Minus => UnaryOp.Subtract, + UnaryOperation.Percent => UnaryOp.Percentage, + UnaryOperation.ImplicitIntersection => UnaryOp.ImplicitIntersection, + UnaryOperation.SpillRange => UnaryOp.SpillRange, + _ => throw new NotSupportedException($"'{operation}' is not a unary operation.") + }; + return new UnaryNode(op, node); + } - public static implicit operator AstNodeCreator(AstNodeFactory factory) => factory.CreateNode; + public ValueNode Nested(string context, SymbolRange range, ValueNode node) + { + return node; + } - private void CreateNode(AstContext context, ParseTreeNode parseNode) + private FunctionNode GetFunctionNode(PrefixNode? prefixNode, string functionName, + IReadOnlyList argumentNodes) { - // Sequential conditions are slower than binary switch, but it is readable. - foreach (var factory in _factories) + var foundFunction = _functionRegistry.TryGetFunc(functionName, out var minParams, out var maxParams); + + // If function is a future function, strip the prefix because all registration of functions + // are without a prefix. That should change, but it's a reality for now. + if (!foundFunction && functionName.StartsWith($"{DefaultFunctionNameSpace}.")) { - var conditions = factory.Key; - var conditionsSatisfied = parseNode.ChildNodes.Count == conditions.Length - && parseNode.ChildNodes.Zip(conditions, (n, c) => c.Func(n)).All(x => x); - if (conditionsSatisfied) - { - parseNode.AstNode = factory.Value(parseNode, context); - return; - } + functionName = functionName.Substring(DefaultFunctionNameSpace.Length + 1); + foundFunction = _functionRegistry.TryGetFunc(functionName, out minParams, out maxParams); } - throw new InvalidOperationException($"Failed to convert CST to AST for term {parseNode.Term.Name}."); - } - } + // Even if we haven't found anything, don't crash. Missing function will be evaluated to `#NAME?` + if (!foundFunction) + return new FunctionNode(functionName, argumentNodes); - private class NodePredicate - { - private NodePredicate(Func func) => Func = func; + if (minParams != -1 && argumentNodes.Count < minParams) + throw new ExpressionParseException( + $"Too few parameters for function '{functionName}'. Expected a minimum of {minParams} and a maximum of {maxParams}."); - public Func Func { get; } + if (maxParams != -1 && argumentNodes.Count > maxParams) + throw new ExpressionParseException( + $"Too many parameters for function '{functionName}'.Expected a minimum of {minParams} and a maximum of {maxParams}."); - public static implicit operator NodePredicate(string termName) => new(x => x.Term.Name == termName); - public static implicit operator NodePredicate(string[] termNames) => new(x => termNames.Contains(x.Term.Name)); - public static implicit operator NodePredicate(Type astNodeType) => new(x => x.AstNode?.GetType() == astNodeType); - public static implicit operator NodePredicate(Func cond) => new(x => x.AstNode is ValueNode astNode && cond(astNode)); + return new FunctionNode(prefixNode, functionName, argumentNodes); + } + + private static XLError GetErrorValue(ReadOnlySpan error) + { + if (!XLErrorParser.TryParseError(error.ToString(), out var errorEnum)) + throw new InvalidOperationException($"'{error.ToString()}' is not error."); + return errorEnum; + } } } } diff --git a/ClosedXML/Excel/CalcEngine/FormulaVisitor.cs b/ClosedXML/Excel/CalcEngine/FormulaVisitor.cs index 2a7f4a312..1485d2e28 100644 --- a/ClosedXML/Excel/CalcEngine/FormulaVisitor.cs +++ b/ClosedXML/Excel/CalcEngine/FormulaVisitor.cs @@ -1,9 +1,11 @@ -namespace ClosedXML.Excel.CalcEngine +namespace ClosedXML.Excel.CalcEngine { internal interface IFormulaVisitor { public TResult Visit(TContext context, ScalarNode node); + public TResult Visit(TContext context, ArrayNode node); + public TResult Visit(TContext context, UnaryNode node); public TResult Visit(TContext context, BinaryNode node); diff --git a/ClosedXML/Excel/CalcEngine/FractionParser.cs b/ClosedXML/Excel/CalcEngine/FractionParser.cs new file mode 100644 index 000000000..474b4a976 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/FractionParser.cs @@ -0,0 +1,42 @@ +#nullable disable + +using System.Globalization; +using System.Text.RegularExpressions; + +namespace ClosedXML.Excel.CalcEngine +{ + /// + /// Parse a fraction for text-to-number type coercion. + /// + internal static class FractionParser + { + private static readonly Regex FractionRegex = new(@"^ *([+-]?) *([0-9]+) ([0-9]{1,5})/([0-9]{1,5}) *$", RegexOptions.CultureInvariant); + + public static bool TryParse(string s, out double result) + { + result = default; + var match = FractionRegex.Match(s); + if (!match.Success) + return false; + + var denominator = ParseInt(match.Groups[4]); + if (denominator == 0 || denominator > short.MaxValue) + return false; + + var numerator = ParseInt(match.Groups[3]); + if (numerator > short.MaxValue) + return false; + + var sign = match.Groups[1]; + var wholeNumber = ParseInt(match.Groups[2]); + + var fraction = wholeNumber + numerator / (double)denominator; + var hasNegativeSign = sign.Success && sign.Value.Length > 0 && sign.Value[0] == '-'; + result = hasNegativeSign ? -fraction : fraction; + return true; + + static int ParseInt(Capture capture) => + int.Parse(capture.Value, NumberStyles.None, CultureInfo.InvariantCulture); + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/FunctionDefinition.cs b/ClosedXML/Excel/CalcEngine/FunctionDefinition.cs index ab4cfa4f4..17d2cef00 100644 --- a/ClosedXML/Excel/CalcEngine/FunctionDefinition.cs +++ b/ClosedXML/Excel/CalcEngine/FunctionDefinition.cs @@ -9,16 +9,8 @@ namespace ClosedXML.Excel.CalcEngine /// internal class FunctionDefinition { - /// - /// Only the or is set. - /// private readonly CalcEngineFunction _function; - /// - /// Only the or is set. - /// - private readonly LegacyCalcEngineFunction _legacyFunction; - private readonly FunctionFlags _flags; private readonly AllowRange _allowRanges; @@ -42,18 +34,6 @@ public FunctionDefinition(int minParams, int maxParams, CalcEngineFunction funct _flags = flags; } - public FunctionDefinition(int minParams, int maxParams, LegacyCalcEngineFunction function, AllowRange allowRanges, IReadOnlyCollection markedParams) - { - if (allowRanges == AllowRange.None && markedParams.Any()) - throw new ArgumentException(nameof(markedParams)); - - MinParams = minParams; - MaxParams = maxParams; - _allowRanges = allowRanges; - _markedParams = markedParams; - _legacyFunction = function; - } - public int MinParams { get; } public int MaxParams { get; } @@ -63,18 +43,73 @@ public AnyValue CallFunction(CalcContext ctx, Span args) if (ctx.UseImplicitIntersection) IntersectArguments(ctx, args); - if (_legacyFunction is not null) + return _function(ctx, args); + } + + /// + /// Evaluate the function with array formula semantic. + /// + public AnyValue CallAsArray(CalcContext ctx, Span args) + { + if (_flags.HasFlag(FunctionFlags.ReturnsArray) && _allowRanges == AllowRange.All) + { + return _function!(ctx, args); + } + + // Step 1: For scalar parameters of function, determine maximum size of scalar + // parameters from argument arrays + var (totalRows, totalColumns) = GetScalarArgsMaxSize(args); + + // Step 2: Normalize arguments. Single params are converted to array of same size, multi params are converted from scalars + for (var i = 0; i < args.Length; ++i) + { + ref var arg = ref args[i]; + var argIsSingle = arg.TryPickSingleOrMultiValue(out var single, out var multi, ctx); + if (IsParameterSingleValue(i)) + { + arg = argIsSingle + ? new ScalarArray(single, totalColumns, totalRows) + : multi.Broadcast(totalRows, totalColumns); + } + else + { + // 18.17.2.4 When a function expects a multi-valued argument but a single-valued + // expression is passed, that single-valued argument is treated as a 1x1 array. + // If there is an error as a single value, e.g. reference to a single cell, the SUMIF behaves + // as it was converted to 1x1 array and doesn't return error, just because it found an error. + // Ergo: for ranges, we don't immediately return error, just because range parameter contains an error + arg = argIsSingle + ? new ScalarArray(single, 1, 1) + : multi; + } + } + + // Step 3: For each item in total array, calculate function + var result = new ScalarValue[totalRows, totalColumns]; + for (var row = 0; row < totalRows; ++row) { - // This creates a some of overhead, but all legacy functions will be migrated in near future - var adaptedArgs = new List(args.Length); - foreach (var arg in args) - adaptedArgs.Add(ConvertAnyValueToLegacyExpression(ctx, arg)); + for (var column = 0; column < totalColumns; ++column) + { + var itemArg = new AnyValue[args.Length]; + for (var i = 0; i < itemArg.Length; ++i) + { + ref var arg = ref args[i]; + itemArg[i] = IsParameterSingleValue(i) + ? arg.GetArray()[row, column].ToAnyValue() + : arg; + } + + var itemResult = _function(ctx, args); - var result = _legacyFunction(adaptedArgs); - return ConvertLegacyFormulaValueToAnyValue(result); + // Even if function returns an array, only the top-left value of array is used + // as a result for the item, per tests with FILTERXML. + result[row, column] = itemResult.TryPickSingleOrMultiValue(out var scalarResult, out var arrayResult, ctx) + ? scalarResult + : arrayResult[0, 0]; + } } - return _function(ctx, args); + return new ConstArray(result); } private void IntersectArguments(CalcContext ctx, Span args) @@ -94,58 +129,35 @@ private void IntersectArguments(CalcContext ctx, Span args) } } - public static AnyValue ConvertLegacyFormulaValueToAnyValue(object result) + private (int Rows, int Columns) GetScalarArgsMaxSize(Span args) { - return result switch + var maxRows = 1; + var maxColumns = 1; + for (var i = 0; i < args.Length; ++i) { - bool logic => AnyValue.From(logic), - double number => AnyValue.From(number), - string text => AnyValue.From(text), - int number => AnyValue.From(number), /* int represents a date in most cases (legacy functions, e.g. SECOND), number are double */ - long number => AnyValue.From(number), - DateTime date => AnyValue.From(date.ToOADate()), - TimeSpan time => AnyValue.From(time.ToSerialDateTime()), - XLError errorType => AnyValue.From(errorType), - double[,] array => AnyValue.From(new NumberArray(array)), - _ => throw new NotImplementedException($"Got a result from some function type {result?.GetType().Name ?? "null"} with value {result}.") - }; + ref var arg = ref args[i]; + if (IsParameterSingleValue(i)) + { + var (argRows, argColumns) = arg.GetArraySize(); + maxRows = Math.Max(maxRows, argRows); + maxColumns = Math.Max(maxColumns, argColumns); + } + } + + return (maxRows, maxColumns); } - private static Expression ConvertAnyValueToLegacyExpression(CalcContext context, AnyValue arg) + private bool IsParameterSingleValue(int paramIndex) { - return arg.Match( - () => new EmptyValueExpression(), - logical => new Expression(logical), - number => new Expression(number), - text => new Expression(text), - error => new Expression(error), - array => - { - var convertedArray = new double[array.Height, array.Width]; - for (var row = 0; row < array.Height; ++row) - for (var col = 0; col < array.Width; ++col) - convertedArray[row, col] = array[row, col].Match( - () => 0.0, - logical => logical ? 1.0 : 0.0, - number => number, - text => throw new NotImplementedException(), - error => throw new NotImplementedException()); - - return new XObjectExpression(convertedArray); - }, - range => - { - if (range.Areas.Count != 1) - { - var references = range.Areas.Select(area => - new CellRangeReference((area.Worksheet ?? context.Worksheet).Range(area))).ToList(); - return new XObjectExpression(references); - } - - var area = range.Areas.Single(); - var ws = area.Worksheet ?? context.Worksheet; - return new XObjectExpression(new CellRangeReference(ws.Range(area))); - }); + var paramAllowsMultiValues = _allowRanges switch + { + AllowRange.None => false, + AllowRange.Except => !_markedParams.Contains(paramIndex), + AllowRange.Only => _markedParams.Contains(paramIndex), + AllowRange.All => true, + _ => throw new NotSupportedException($"Unexpected value {_allowRanges}") + }; + return !paramAllowsMultiValues; } } } diff --git a/ClosedXML/Excel/CalcEngine/FunctionFlags.cs b/ClosedXML/Excel/CalcEngine/FunctionFlags.cs index d31b5a2a1..572d261f1 100644 --- a/ClosedXML/Excel/CalcEngine/FunctionFlags.cs +++ b/ClosedXML/Excel/CalcEngine/FunctionFlags.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace ClosedXML.Excel.CalcEngine { @@ -25,6 +25,27 @@ internal enum FunctionFlags /// Function has side effects, e.g. it changes something. /// /// HYPERLINK - SideEffect = 2 + SideEffect = 2, + + /// + /// Function returns array. Functions without this flag return a scalar value. + /// CalcEngine treats such functions differently for array formulas. + /// + ReturnsArray = 4, + + /// + /// Function is not deterministic. + /// + /// RAND(), DATE() + Volatile = 8, + + /// + /// The function is a future function (i.e. functions not present in Excel 2007). Future + /// functions are displayed to the user with a name (e.g SEC), but are actually + /// stored in the workbook with a prefix _xlfn (e.g. _xlfn.SEC). + /// The prefix is there for backwards compatibility, to not clash with user defined + /// functions and other such reasons. See [MS-XLSX] 2.3.3 for complete list. + /// + Future = 16 } } diff --git a/ClosedXML/Excel/CalcEngine/FunctionRegistry.cs b/ClosedXML/Excel/CalcEngine/FunctionRegistry.cs index ea92fffbe..12f19c0c1 100644 --- a/ClosedXML/Excel/CalcEngine/FunctionRegistry.cs +++ b/ClosedXML/Excel/CalcEngine/FunctionRegistry.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.CalcEngine @@ -28,21 +30,21 @@ public bool TryGetFunc(string name, out FunctionDefinition func) return _func.TryGetValue(name, out func); } + /// + /// Add a function to the registry. + /// + /// Name of function in formulas. + /// Minimum number of parameters. + /// Maximum number of parameters. + /// A delegate of a function that will be called when function is supposed to be evaluated. + /// Flags that indicate some additional info about function. + /// Which parameters allow ranges to be argument. Useful for array formulas. + /// Index of parameter that is marked, start from 0 public void RegisterFunction(string functionName, int minParams, int maxParams, CalcEngineFunction fn, FunctionFlags flags, AllowRange allowRanges = AllowRange.None, params int[] markedParams) { _func.Add(functionName, new FunctionDefinition(minParams, maxParams, fn, flags, allowRanges, markedParams)); } - public void RegisterFunction(string functionName, int paramCount, LegacyCalcEngineFunction fn, AllowRange allowRanges = AllowRange.None, params int[] markedParams) - { - RegisterFunction(functionName, paramCount, paramCount, fn, allowRanges, markedParams); - } - - public void RegisterFunction(string functionName, int minParams, int maxParams, LegacyCalcEngineFunction fn, AllowRange allowRanges = AllowRange.None, params int[] markedParams) - { - _func.Add(functionName, new FunctionDefinition(minParams, maxParams, fn, allowRanges, markedParams)); - } - public bool TryGetFunc(string name, out int paramMin, out int paramMax) { if (_func.TryGetValue(name, out var func)) diff --git a/ClosedXML/Excel/CalcEngine/Functions/ArgumentsExtensions.cs b/ClosedXML/Excel/CalcEngine/Functions/ArgumentsExtensions.cs new file mode 100644 index 000000000..10a31d5f3 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Functions/ArgumentsExtensions.cs @@ -0,0 +1,91 @@ +using System; + +namespace ClosedXML.Excel.CalcEngine.Functions +{ + /// + /// An extension methods + /// + internal static class ArgumentsExtensions + { + /// + /// Aggregate all values in the arguments of a function into a single value. If any value is error, return the error. + /// + /// + /// A lot of functions take all argument values and aggregate the values to a different value. + /// These aggregation functions apply aggregation on each argument and if the argument is + /// a collection (array/reference), the aggregation function is also applied to each element of + /// the array/reference (e.g. SUM({1, 2}, 3) applies sum on each element of an array + /// {1,2} and thus result is 1+2+3). + /// + /// Type of the value that is being aggregated. + /// Arguments of a function. Method goes over all elements of the arguments. + /// Calculation context.> + /// + /// Initial value of the accumulator. It is used as an input into the first call of . + /// + /// + /// What should be the result of aggregation, if there are no elements. Common choices are + /// or the . + /// + /// + /// The aggregation function. First parameter is the accumulator, second parameter is the value of + /// current element taken from . Make sure the method is static lambda to + /// avoid useless allocations. + /// + /// + /// A function that converts a scalar value of an element into the or + /// an error if it can't be converted. Make sure the method is static lambda to avoid useless allocations. + /// + /// + /// Some functions skip elements in a array/reference that would be accepted as an argument, + /// e.g. SUM("1", {2,"4"}) is 3 - it converts string "3" to a number 3 + /// in for root arguments, but omits element "4" in the array. This is a function that + /// determines which elements to include and which to skip. If null, all elements of array are included and + /// all values are treated same. Make sure the method is static lambda to avoid useless allocations. + /// + public static OneOf Aggregate( + this Span args, + CalcContext ctx, + TValue initialValue, + OneOf noElementsResult, + Func aggregate, + Func> convert, + Func? collectionFilter = null) + { + var result = initialValue; + var hasElement = false; + foreach (var arg in args) + { + if (arg.TryPickScalar(out var scalar, out var collection)) + { + var conversionResult = convert(scalar, ctx); + if (!conversionResult.TryPickT0(out var elementValue, out var elementError)) + return elementError; + + hasElement = true; + result = aggregate(result, elementValue!); + } + else + { + var valuesIterator = collection.TryPickT0(out var array, out var reference) + ? array! + : reference!.GetCellsValues(ctx); + foreach (var value in valuesIterator) + { + if (collectionFilter is not null && !collectionFilter(value)) + continue; + + var conversionResult = convert(value, ctx); + if (!conversionResult.TryPickT0(out var elementValue, out var elementError)) + return elementError; + + hasElement = true; + result = aggregate(result, elementValue!); + } + } + } + + return hasElement ? result : noElementsResult; + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/Functions/Criteria.cs b/ClosedXML/Excel/CalcEngine/Functions/Criteria.cs new file mode 100644 index 000000000..321f47fbf --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Functions/Criteria.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; + +namespace ClosedXML.Excel.CalcEngine.Functions; + +/// +/// A representation of selection criteria used in IFs functions {SUM,AVERAGE,COUNT}{IF,IFS} +/// and database functions (D{AVERAGE,COUNT,COUNTA,...}). +/// +internal class Criteria +{ + // Values are ordered by length of a prefix. The longer ones are before sorter ones. + private static readonly List<(string Prefix, Comparison Comparison)> AllComparisons = new() + { + ("<>", Comparison.NotEqual), + (">=", Comparison.GreaterOrEqualTo), + ("<=", Comparison.LessOrEqualTo), + ("=", Comparison.Equal), + (">", Comparison.GreaterThan), + ("<", Comparison.LessThan), + }; + + private readonly Comparison _comparison; + private readonly ScalarValue _value; + private readonly CultureInfo _culture; + + private Criteria(Comparison comparison, ScalarValue value, CultureInfo culture) + { + _comparison = comparison; + _value = value; + _culture = culture; + } + + /// + /// Can a blank value match the criteria? + /// + internal bool CanBlankValueMatch + { + get + { + // Criteria accepts only values equal to blank (it's either blank or empty text). + // Therefore blank values must be included, because blank is equal to blank. + if (_comparison is Comparison.Equal or Comparison.None && _value.IsBlank) + return true; + + // Criteria accepts only values that are not a concrete value. Blank values + // are not concrete values and therefore must be included. + if (_comparison == Comparison.NotEqual && !_value.IsBlank) + return true; + + return false; + } + } + + internal static Criteria Create(ScalarValue criteria, CultureInfo culture) + { + if (criteria.IsText) + { + // Criteria as a text is the most common type. Text can be either comparison + // with a value (e.g. ">7,5") or just value ("7,5"). Comparison must start + // at the very first char, otherwise it's not interpreted as a comparison. + var criteriaText = criteria.GetText(); + var (prefix, comparison) = GetComparison(criteriaText); + var operandText = criteriaText[prefix.Length..]; + var operand = ScalarValue.Parse(operandText, culture); + return new Criteria(comparison, operand, culture); + } + + // If criteria is real blank (either through cell reference or IF(TRUE,)) + // it is interpreted as number 0. + if (criteria.IsBlank) + return new Criteria(Comparison.Equal, 0, culture); + + return new Criteria(Comparison.None, criteria, culture); + + static (string Prefix, Comparison Comparison) GetComparison(string criteriaText) + { + foreach (var (prefix, prefixComparison) in AllComparisons) + { + if (criteriaText.StartsWith(prefix)) + return (prefix, prefixComparison); + } + + return (string.Empty, Comparison.None); + } + } + + internal bool Match(ScalarValue value) + { + return _value switch + { + { IsBlank: true } => CompareBlank(value), + { IsLogical: true } => CompareLogical(value, _value.GetLogical()), + { IsNumber: true } => CompareNumber(value, _value.GetNumber()), + { IsText: true } => CompareText(value, _value.GetText()), + { IsError: true } => CompareError(value, _value.GetError()), + _ => throw new UnreachableException(), + }; + } + + private bool CompareBlank(ScalarValue value) + { + // This path can one be achieved when criteria was empty string (e.g. "") + // or some comparison and empty string (e.g. "="). If the value was real + // blank, it is interpreted as "=0" + + // Passed criteria is "". That is true only for empty string or blank + if (_comparison == Comparison.None) + return value.IsBlank || (value.IsText && value.GetText().Length == 0); + + // Passed criteria is "=". That is true only for blank + if (_comparison == Comparison.Equal) + return value.IsBlank; + + // Passed criteria is "<>". That is true only when argument is not blank. + if (_comparison == Comparison.NotEqual) + return !value.IsBlank; + + // Only sortable comparisons are left (>, <, >=, <=). That never makes + // sense for blanks or other types is thus always false. + return false; + } + + private bool CompareLogical(ScalarValue value, bool actual) + { + if (!value.IsLogical) + return _comparison == Comparison.NotEqual; + + return Compare(value.GetLogical().CompareTo(actual)); + } + + private bool CompareNumber(ScalarValue value, double actual) + { + double number; + if (value.IsNumber) + { + number = value.GetNumber(); + } + else if (value.IsText && ScalarValue.TextToNumber(value.GetText(), _culture).TryPickT0(out var parsedNumber, out _)) + { + number = parsedNumber; + } + else + { + return _comparison == Comparison.NotEqual; + } + + return Compare(number.CompareTo(actual)); + } + + private bool CompareText(ScalarValue value, string actual) + { + if (!value.IsText) + return _comparison == Comparison.NotEqual; + + return _comparison switch + { + Comparison.Equal or Comparison.None => new Wildcard(actual).Matches(value.GetText().AsSpan()), + Comparison.NotEqual => !new Wildcard(actual).Matches(value.GetText().AsSpan()), + Comparison.LessThan => _culture.CompareInfo.Compare(value.GetText(), actual) < 0, + Comparison.LessOrEqualTo => _culture.CompareInfo.Compare(value.GetText(), actual) <= 0, + Comparison.GreaterThan => _culture.CompareInfo.Compare(value.GetText(), actual) > 0, + Comparison.GreaterOrEqualTo => _culture.CompareInfo.Compare(value.GetText(), actual) >= 0, + _ => throw new UnreachableException() + }; + } + + private bool CompareError(ScalarValue value, XLError actual) + { + if (!value.IsError) + return _comparison == Comparison.NotEqual; + + return Compare(value.GetError().CompareTo(actual)); + } + + private bool Compare(int cmp) + { + return _comparison switch + { + Comparison.Equal or Comparison.None => cmp == 0, + Comparison.NotEqual => cmp != 0, + Comparison.LessThan => cmp < 0, + Comparison.LessOrEqualTo => cmp <= 0, + Comparison.GreaterThan => cmp > 0, + Comparison.GreaterOrEqualTo => cmp >= 0, + _ => throw new UnreachableException() + }; + } + + private enum Comparison + { + /// + /// There has to be a None comparison, because criteria empty string ("") + /// matches blank and empty string. That is not same as "=" or actual + /// blank value. Thus it can't be reduced to equal with some operand + /// and has to have a special case. + /// + None, + Equal, + NotEqual, + LessThan, + LessOrEqualTo, + GreaterThan, + GreaterOrEqualTo, + } +} + diff --git a/ClosedXML/Excel/CalcEngine/Functions/Database.cs b/ClosedXML/Excel/CalcEngine/Functions/Database.cs index c4a6af87e..f506ae343 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/Database.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/Database.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - namespace ClosedXML.Excel.CalcEngine.Functions { internal static class Database @@ -22,15 +17,5 @@ public static void Register(FunctionRegistry ce) //ce.RegisterFunction("DVAR", 1, Dvar); // Estimates variance based on a sample from selected database entries //ce.RegisterFunction("DVARP", 1, Dvarp); // Calculates variance based on the entire population of selected database entries } - - static object Daverage(List p) - { - var b = true; - foreach (var v in p) - { - b = b && (bool)v; - } - return b; - } } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/DateAndTime.cs b/ClosedXML/Excel/CalcEngine/Functions/DateAndTime.cs index 0898b37ab..df6ff933e 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/DateAndTime.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/DateAndTime.cs @@ -1,404 +1,862 @@ -using ClosedXML.Excel.CalcEngine.Exceptions; using System; using System.Collections.Generic; -using System.Globalization; -using System.Linq; +using System.Diagnostics; +using static ClosedXML.Excel.CalcEngine.Functions.SignatureAdapter; namespace ClosedXML.Excel.CalcEngine.Functions { internal static class DateAndTime { + /// + /// Serial date of 9999-12-31. Date is generally considered invalid, if above that or below 0. + /// + private const int Year10K = 2958465; + public static void Register(FunctionRegistry ce) { - ce.RegisterFunction("DATE", 3, Date); // Returns the serial number of a particular date - ce.RegisterFunction("DATEDIF", 3, Datedif); // Calculates the number of days, months, or years between two dates - ce.RegisterFunction("DATEVALUE", 1, Datevalue); // Converts a date in the form of text to a serial number - ce.RegisterFunction("DAY", 1, Day); // Converts a serial number to a day of the month - ce.RegisterFunction("DAYS", 2, Days); // Returns the number of days between two dates. - ce.RegisterFunction("DAYS360", 2, 3, Days360); // Calculates the number of days between two dates based on a 360-day year - ce.RegisterFunction("EDATE", 2, Edate); // Returns the serial number of the date that is the indicated number of months before or after the start date - ce.RegisterFunction("EOMONTH", 2, Eomonth); // Returns the serial number of the last day of the month before or after a specified number of months - ce.RegisterFunction("HOUR", 1, Hour); // Converts a serial number to an hour - ce.RegisterFunction("ISOWEEKNUM", 1, IsoWeekNum); // Returns number of the ISO week number of the year for a given date. - ce.RegisterFunction("MINUTE", 1, Minute); // Converts a serial number to a minute - ce.RegisterFunction("MONTH", 1, Month); // Converts a serial number to a month - ce.RegisterFunction("NETWORKDAYS", 2, 3, Networkdays, AllowRange.Only, 2); // Returns the number of whole workdays between two dates - ce.RegisterFunction("NOW", 0, Now); // Returns the serial number of the current date and time - ce.RegisterFunction("SECOND", 1, Second); // Converts a serial number to a second - ce.RegisterFunction("TIME", 3, Time); // Returns the serial number of a particular time - ce.RegisterFunction("TIMEVALUE", 1, Timevalue); // Converts a time in the form of text to a serial number - ce.RegisterFunction("TODAY", 0, Today); // Returns the serial number of today's date - ce.RegisterFunction("WEEKDAY", 1, 2, Weekday); // Converts a serial number to a day of the week - ce.RegisterFunction("WEEKNUM", 1, 2, Weeknum); // Converts a serial number to a number representing where the week falls numerically with a year - ce.RegisterFunction("WORKDAY", 2, 3, Workday, AllowRange.Only, 2); // Returns the serial number of the date before or after a specified number of workdays - ce.RegisterFunction("YEAR", 1, Year); // Converts a serial number to a year - ce.RegisterFunction("YEARFRAC", 2, 3, Yearfrac); // Returns the year fraction representing the number of whole days between start_date and end_date + var dateValue = DateValue; + var timeValue = TimeValue; + ce.RegisterFunction("DATE", 3, 3, Adapt(Date), FunctionFlags.Scalar); // Returns the serial number of a particular date + ce.RegisterFunction("DATEDIF", 3, 3, Adapt(DateDif), FunctionFlags.Scalar); // Calculates the number of days, months, or years between two dates + ce.RegisterFunction("DATEVALUE", 1, 1, Adapt(dateValue), FunctionFlags.Scalar); // Converts a date in the form of text to a serial number + ce.RegisterFunction("DAY", 1, 1, Adapt(Day), FunctionFlags.Scalar); // Converts a serial number to a day of the month + ce.RegisterFunction("DAYS", 2, 2, Adapt(Days), FunctionFlags.Scalar | FunctionFlags.Future); // Returns the number of days between two dates. + ce.RegisterFunction("DAYS360", 2, 3, AdaptLastOptional(Days360, false), FunctionFlags.Scalar); // Calculates the number of days between two dates based on a 360-day year + ce.RegisterFunction("EDATE", 2, 2, Adapt(EDate), FunctionFlags.Scalar); // Returns the serial number of the date that is the indicated number of months before or after the start date + ce.RegisterFunction("EOMONTH", 2, 2, Adapt(Eomonth), FunctionFlags.Scalar); // Returns the serial number of the last day of the month before or after a specified number of months + ce.RegisterFunction("HOUR", 1, 1, Adapt(Hour), FunctionFlags.Scalar); // Converts a serial number to an hour + ce.RegisterFunction("ISOWEEKNUM", 1, 1, Adapt(IsoWeekNum), FunctionFlags.Scalar | FunctionFlags.Future); // Returns number of the ISO week number of the year for a given date. + ce.RegisterFunction("MINUTE", 1, 1, Adapt(Minute), FunctionFlags.Scalar); // Converts a serial number to a minute + ce.RegisterFunction("MONTH", 1, 1, Adapt(Month), FunctionFlags.Scalar); // Converts a serial number to a month + ce.RegisterFunction("NETWORKDAYS", 2, 3, AdaptLastOptional(NetWorkDays), FunctionFlags.Range, AllowRange.Only, 2); // Returns the number of whole workdays between two dates + ce.RegisterFunction("NOW", 0, 0, Adapt(Now), FunctionFlags.Scalar | FunctionFlags.Volatile); // Returns the serial number of the current date and time + ce.RegisterFunction("SECOND", 1, 1, Adapt(Second), FunctionFlags.Scalar); // Converts a serial number to a second + ce.RegisterFunction("TIME", 3, 3, Adapt(Time), FunctionFlags.Scalar); // Returns the serial number of a particular time + ce.RegisterFunction("TIMEVALUE", 1, 1, Adapt(timeValue), FunctionFlags.Scalar); // Converts a time in the form of text to a serial number + ce.RegisterFunction("TODAY", 0, 0, Adapt(Today), FunctionFlags.Scalar | FunctionFlags.Volatile); // Returns the serial number of today's date + ce.RegisterFunction("WEEKDAY", 1, 2, AdaptLastOptional(Weekday), FunctionFlags.Scalar); // Converts a serial number to a day of the week + ce.RegisterFunction("WEEKNUM", 1, 2, AdaptLastOptional(WeekNum, 1), FunctionFlags.Scalar); // Converts a serial number to a number representing where the week falls numerically with a year + ce.RegisterFunction("WORKDAY", 2, 3, AdaptLastOptional(Workday), FunctionFlags.Range, AllowRange.Only, 2); // Returns the serial number of the date before or after a specified number of workdays + ce.RegisterFunction("YEAR", 1, 1, Adapt(Year), FunctionFlags.Scalar); // Converts a serial number to a year + ce.RegisterFunction("YEARFRAC", 2, 3, AdaptLastOptional(YearFrac, 0), FunctionFlags.Scalar); // Returns the year fraction representing the number of whole days between start_date and end_date } - /// - /// Calculates number of business days, taking into account: - /// - weekends (Saturdays and Sundays) - /// - bank holidays in the middle of the week - /// - /// First day in the time interval - /// Last day in the time interval - /// List of bank holidays excluding weekends - /// Number of business days during the 'span' - private static int BusinessDaysUntil(DateTime firstDay, DateTime lastDay, IEnumerable bankHolidays) - { - firstDay = firstDay.Date; - lastDay = lastDay.Date; + private static int BusinessDaysUntil(int firstDay, int lastDay, ICollection distinctHolidays) + { if (firstDay > lastDay) - return -BusinessDaysUntil(lastDay, firstDay, bankHolidays); + return -BusinessDaysUntil(lastDay, firstDay, distinctHolidays); + + var workDays = lastDay - firstDay + 1; + var fullWeekCount = Math.DivRem(workDays, 7, out var remainingDays); - TimeSpan span = lastDay - firstDay; - int businessDays = span.Days + 1; - int fullWeekCount = businessDays / 7; // find out if there are weekends during the time exceeding the full weeks - if (businessDays > fullWeekCount * 7) - { - // we are here to find out if there is a 1-day or 2-days weekend - // in the time interval remaining after subtracting the complete weeks - var firstDayOfWeek = (int)firstDay.DayOfWeek; - var lastDayOfWeek = (int)lastDay.DayOfWeek; - if (lastDayOfWeek < firstDayOfWeek) - lastDayOfWeek += 7; - if (firstDayOfWeek <= 6) - { - if (lastDayOfWeek >= 7)// Both Saturday and Sunday are in the remaining time interval - businessDays -= 2; - else if (lastDayOfWeek >= 6)// Only Saturday is in the remaining time interval - businessDays -= 1; - } - else if (firstDayOfWeek <= 7 && lastDayOfWeek >= 7)// Only Sunday is in the remaining time interval - businessDays -= 1; + for (var day = lastDay - remainingDays + 1; day <= lastDay; ++day) + { + if (IsWeekend(day)) + workDays--; } // subtract the weekends during the full weeks in the interval - businessDays -= fullWeekCount + fullWeekCount; + workDays -= fullWeekCount * 2; // subtract the number of bank holidays during the time interval - foreach (var bh in bankHolidays) + foreach (var holidayDate in distinctHolidays) { - if (firstDay <= bh && bh <= lastDay) - --businessDays; + if (firstDay <= holidayDate && holidayDate <= lastDay) + { + if (!IsWeekend(holidayDate)) + --workDays; + } } - return businessDays; + return workDays; } - private static object Date(List p) + private static ScalarValue Date(CalcContext ctx, double year, double month, double day) { - var year = (int)p[0]; - var month = (int)p[1]; - var day = (int)p[2]; + // Unlike most functions, values are floored - not truncated. + year = Math.Floor(year); + month = Math.Floor(month); + day = Math.Floor(day); + + if (month is < -short.MaxValue or >= short.MaxValue) + return XLError.NumberInvalid; + + // Excel behaves out of spec. Spec says 0-99 are interpreted as year + 1900, + // but reality is that anything below 1900 is interpreted as year + 1900. + // That seems to be true for both 1900 and 1904 date systems. + if (year < 1900) + year += 1900; + + // Excel buggy implementation :) Should probably return error, + // but silently changes the result instead. + day = Math.Min(day, short.MaxValue); + if (day < short.MinValue) + day = short.MaxValue; + + if (year > 10000) + year = 10000; + + // Excel allows months and days outside the normal range, and adjusts the date + // accordingly. + var yearAdjustment = Math.Floor((month - 1d) / 12.0); + year += yearAdjustment; + month -= yearAdjustment * 12; + + // Year 1 is earliest allowable in both date system. Also avoid the double + // to int conversion problems when double is too small. + if (year < 1) + return XLError.NumberInvalid; + + var startOfMonth = new DateTime((int)year, (int)month, 1).ToSerialDateTime(); + var serialDate = startOfMonth + day - 1; + if (serialDate < 0 || serialDate >= ctx.DateSystemUpperLimit) + return XLError.NumberInvalid; + + return serialDate; + } + + private static ScalarValue DateDif(CalcContext ctx, double startDateTime, double endDateTime, string unit) + { + if (!TryGetDate(ctx, startDateTime, out var startSerialDate)) + return XLError.NumberInvalid; + + if (!TryGetDate(ctx, endDateTime, out var endSerialDate)) + return XLError.NumberInvalid; + + if (startSerialDate > endSerialDate) + return XLError.NumberInvalid; + + var startDate = DateParts.From(ctx, startSerialDate); + var endDate = DateParts.From(ctx, endSerialDate); + unit = unit.ToUpperInvariant(); - // Excel allows months and days outside the normal range, and adjusts the date accordingly - if (month > 12 || month < 1) + if (unit == "Y") { - year += (int)Math.Floor((double)(month - 1d) / 12.0); - month -= (int)Math.Floor((double)(month - 1d) / 12.0) * 12; + // Calculate number of complete years the end date is from the start date + var isLastYearComplete = endDate.Month > startDate.Month || + (endDate.Month == startDate.Month && endDate.Day >= startDate.Day); + return endDate.Year - startDate.Year - (isLastYearComplete ? 0 : 1); } - int daysAdjustment = 0; - if (day > DateTime.DaysInMonth(year, month)) + if (unit == "M") { - daysAdjustment = day - DateTime.DaysInMonth(year, month); - day = DateTime.DaysInMonth(year, month); + // Calculate number of complete months the end date is from start date + var isLastMonthComplete = endDate.Day >= startDate.Day; + return (endDate.Year - startDate.Year) * 12 + endDate.Month - startDate.Month - (isLastMonthComplete ? 0 : 1); } - else if (day < 1) + + if (unit == "D") { - daysAdjustment = day - 1; - day = 1; + return endSerialDate - startSerialDate; } - return (int)Math.Floor(new DateTime(year, month, day).AddDays(daysAdjustment).ToOADate()); - } + if (unit == "MD") + { + // The difference between the days in startDate and endDate, ignore year and month + // of startDate, only days are used + if (endDate.Day >= startDate.Day) + return endDate.Day - startDate.Day; - private static object Datedif(List p) - { - DateTime startDate = p[0]; - DateTime endDate = p[1]; - string unit = p[2]; + var adjacentStartDate = startDate with + { + Month = (endDate.Month - 2 + 12) % 12 + 1, + Year = endDate.Month > 1 ? endDate.Year : endDate.Year - 1 + }; + return endSerialDate - adjacentStartDate.SerialDate; + } - if (startDate > endDate) - throw new NumberException("The start date is greater than the end date"); + if (unit == "YM") + { + // The difference between the months in start-date and end-date. Add 12 and then + // modulo, so result is always positive + var isLastMonthComplete = endDate.Day >= startDate.Day; + return (endDate.Month + 12 - startDate.Month - (isLastMonthComplete ? 0 : 1)) % 12; + } - return (unit.ToUpper()) switch + if (unit == "YD") { - "Y" => endDate.Year - startDate.Year - (new DateTime(startDate.Year, endDate.Month, endDate.Day) < startDate ? 1 : 0), - "M" => Math.Truncate((endDate.Year - startDate.Year) * 12d + endDate.Month - startDate.Month - (endDate.Day < startDate.Day ? 1 : 0)), - "D" => Math.Truncate(endDate.Date.Subtract(startDate.Date).TotalDays), + var endFollowsStart = endDate.Month > startDate.Month || (endDate.Month == startDate.Month && endDate.Day >= startDate.Day); + var newEndYear = startDate.Year + (endFollowsStart ? 0 : 1); + var newEndDate = endDate with { Year = newEndYear }; + var daysDiff = newEndDate.SerialDate - startSerialDate; - // Microsoft discourages the use of the MD parameter - // https://support.microsoft.com/en-us/office/datedif-function-25dba1a4-2812-480b-84dd-8b32a451b35c - "MD" => (endDate.Day - startDate.Day + DateTime.DaysInMonth(startDate.Year, startDate.Month)) % DateTime.DaysInMonth(startDate.Year, startDate.Month), + // If start date is in 1900 jan/feb, there are sometimes errors. I couldn't decipher actual logic, + // only condition when it happens. Based on the Excel vs ClosedXML comparisons, it seems to work. + if (startSerialDate <= 60 && endDate is { Year: > 1900, Month: 3 } && endDate.Day < startDate.Day) + daysDiff--; - "YM" => (endDate.Month - startDate.Month + 12) % 12 - (endDate.Day < startDate.Day ? 1 : 0), - "YD" => Math.Truncate(new DateTime(startDate.Year + (new DateTime(startDate.Year, endDate.Month, endDate.Day) < startDate ? 1 : 0), endDate.Month, endDate.Day).Subtract(startDate).TotalDays), - _ => throw new NumberException(), - }; + return daysDiff; + } + + return XLError.NumberInvalid; } - private static object Datevalue(List p) + private static ScalarValue DateValue(CalcContext ctx, ScalarValue value) { - var date = (string)p[0]; + if (!value.TryPickText(out var text, out var error)) + return error; + + if (!ScalarValue.ToSerialDateTime(text, ctx.Culture, out var serialDateTime)) + return XLError.IncompatibleValue; - return (int)Math.Floor(DateTime.Parse(date).ToOADate()); + return Math.Truncate(serialDateTime); } - private static object Day(List p) + private static ScalarValue Day(CalcContext ctx, double serialDateTime) { - var date = (DateTime)p[0]; + if (!TryGetDate(ctx, serialDateTime, out var serialDate)) + return XLError.NumberInvalid; - return date.Day; + return DateParts.From(ctx, serialDate).Day; } - private static object Days(List p) + private static ScalarValue Days(CalcContext ctx, double endSerialDate, double startSerialDate) { - int end_date; - var endDateValue = p[0].Evaluate(); - if (endDateValue is string) - end_date = (int)Datevalue(new List() { p[0] }); - else - end_date = (int)p[0]; + if (!TryGetDate(ctx, startSerialDate, out var startDate)) + return XLError.NumberInvalid; - int start_date; - var startDateValue = p[1].Evaluate(); - if (startDateValue is string) - start_date = (int)Datevalue(new List() { p[1] }); - else - start_date = (int)p[1]; + if (!TryGetDate(ctx, endSerialDate, out var endDate)) + return XLError.NumberInvalid; - return end_date - start_date; + return endDate - startDate; } - private static object Days360(List p) + private static ScalarValue Days360(CalcContext ctx, double startDateTime, double endDateTime, bool isEuropean) { - var date1 = (DateTime)p[0]; - var date2 = (DateTime)p[1]; - var isEuropean = p.Count == 3 ? p[2] : false; + if (!TryGetDate(ctx, startDateTime, out var startDate)) + return XLError.NumberInvalid; + + if (!TryGetDate(ctx, endDateTime, out var endDate)) + return XLError.NumberInvalid; - return Days360(date1, date2, isEuropean); + return Days360(ctx, startDate, endDate, isEuropean); } - private static Int32 Days360(DateTime date1, DateTime date2, Boolean isEuropean) + private static int Days360(CalcContext ctx, int startSerialDate, int endSerialDate, bool isEuropean) { - var d1 = date1.Day; - var m1 = date1.Month; - var y1 = date1.Year; - var d2 = date2.Day; - var m2 = date2.Month; - var y2 = date2.Year; + var startDate = DateParts.From(ctx, startSerialDate); + var (startYear, startMonth, startDay) = startDate; + var (endYear, endMonth, endDay) = DateParts.From(ctx, endSerialDate); if (isEuropean) { - if (d1 == 31) d1 = 30; - if (d2 == 31) d2 = 30; + if (startDay == 31) + startDay = 30; + + if (endDay == 31) + endDay = 30; } else { - if (d1 == 31) d1 = 30; - if (d2 == 31 && d1 == 30) d2 = 30; + // There are several descriptions of the US algorithm: spec, wikipedia, function help, + // ODF. Out of these, only ODF is correct (rest is incomplete/has different results). + if (startDate.IsLastDayOfMonth()) + startDay = 30; + + if (endDay == 31 && startDay == 30) + endDay = 30; } - return 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1); + return 360 * (endYear - startYear) + 30 * (endMonth - startMonth) + (endDay - startDay); } - private static object Edate(List p) + private static ScalarValue EDate(CalcContext ctx, double startSerialDate, double monthOffset) { - var date = (DateTime)p[0]; - var mod = (int)p[1]; + if (!TryGetDate(ctx, startSerialDate, out var startSerial)) + return XLError.NumberInvalid; + + if (!TryGetMonthsOffset(monthOffset, out var months)) + return XLError.NumberInvalid; - var retDate = date.AddMonths(mod); - return retDate; + var startDate = DateParts.From(ctx, startSerial); + var endDate = startDate.AddMonths(months); + return endDate.ToSerialDate() + .Match(d => d, e => e); } - private static object Eomonth(List p) + private static ScalarValue Eomonth(CalcContext ctx, double startSerialDate, double monthOffset) { - var start_date = (DateTime)p[0]; - var months = (int)p[1]; + if (!TryGetDate(ctx, startSerialDate, out var startSerial)) + return XLError.NumberInvalid; - var retDate = start_date.AddMonths(months); - return new DateTime(retDate.Year, retDate.Month, DateTime.DaysInMonth(retDate.Year, retDate.Month)); + if (!TryGetMonthsOffset(monthOffset, out var months)) + return XLError.NumberInvalid; + + var startDate = DateParts.From(ctx, startSerial); + var endDate = startDate.AddMonths(months); + var endOfMonth = endDate.EndOfMonth(); + return endOfMonth.ToSerialDate() + .Match(d => d, e => e); } - private static Double GetYearAverage(DateTime date1, DateTime date2) + private static ScalarValue Hour(CalcContext ctx, double serialTime) { - var daysInYears = new List(); - for (int year = date1.Year; year <= date2.Year; year++) - daysInYears.Add(DateTime.IsLeapYear(year) ? 366 : 365); - return daysInYears.Average(); + return GetTimeComponent(ctx, serialTime, static d => d.Hour); } - private static object Hour(List p) + private static ScalarValue IsoWeekNum(CalcContext ctx, double serialDateTime) { - var date = (DateTime)p[0]; + // Uses ISO week algorithm from Wikipedia + if (!TryGetDate(ctx, serialDateTime, out var serialDate)) + return XLError.NumberInvalid; - return date.Hour; - } + var date = DateParts.From(ctx, serialDate); - // http://stackoverflow.com/questions/11154673/get-the-correct-week-number-of-a-given-date - private static object IsoWeekNum(List p) - { - var date = (DateTime)p[0]; + // Normalized to Monday = 1, Sunday = 7 + var dayOfWeek = ((int)date.DayOfWeek + 6) % 7 + 1; + var week = (10 + date.DayOfYear - dayOfWeek) / 7; + + if (week < 1) + return Weeks(date.Year - 1); + + if (week > Weeks(date.Year)) + return 1; + + return week; - // Seriously cheat. If its Monday, Tuesday or Wednesday, then it'll - // be the same week# as whatever Thursday, Friday or Saturday are, - // and we always get those right - DayOfWeek day = CultureInfo.InvariantCulture.Calendar.GetDayOfWeek(date); - if (day >= DayOfWeek.Monday && day <= DayOfWeek.Wednesday) + // Returns day of a week on the last day of a year - Dec 31 + static int DayOfWeekDec31(int year) { - date = date.AddDays(3); + return (year + year / 4 - year / 100 + year / 400) % 7; } - // Return the week of our adjusted day - return CultureInfo.InvariantCulture.Calendar.GetWeekOfYear(date, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); + static int Weeks(int year) + { + // Year ends on Thursday + if (DayOfWeekDec31(year) == 4) + return 53; + + // Previous year ends on Wednesday + if (DayOfWeekDec31(year - 1) == 3) + return 53; + + return 52; + } } - private static object Minute(List p) + private static bool IsWeekend(int date) { - var date = (DateTime)p[0]; + return WeekdayCalc(date) is 1 or 7; + } - return date.Minute; + private static ScalarValue Minute(CalcContext ctx, double serialTime) + { + return GetTimeComponent(ctx, serialTime, static d => d.Minute); } - private static object Month(List p) + private static ScalarValue Month(CalcContext ctx, double serialDateTime) { - var date = (DateTime)p[0]; + if (!TryGetDate(ctx, serialDateTime, out var serialDate)) + return XLError.NumberInvalid; - return date.Month; + return DateParts.From(ctx, serialDate).Month; } - private static object Networkdays(List p) + private static ScalarValue NetWorkDays(CalcContext ctx, ScalarValue startDate, ScalarValue endDate, AnyValue holidays) { - var date1 = (DateTime)p[0]; - var date2 = (DateTime)p[1]; - var bankHolidays = new List(); - if (p.Count == 3) + if (!TryGetDate(ctx, startDate, out var startSerialDate, out var startDateError)) + return startDateError; + + if (!TryGetDate(ctx, endDate, out var endSerialDate, out var endDateError)) + return endDateError; + + // Use set to skip duplicate values + var allHolidays = new HashSet(); + foreach (var holidayValue in ctx.GetNonBlankValues(holidays)) { - var t = new Tally { p[2] }; + if (!TryGetDate(ctx, holidayValue, out var holidayDate, out var error)) + return error; - bankHolidays.AddRange(t.Select(XLHelper.GetDate)); + allHolidays.Add(holidayDate); } - return BusinessDaysUntil(date1, date2, bankHolidays); + return BusinessDaysUntil(startSerialDate, endSerialDate, allHolidays); + } + + private static ScalarValue Now() + { + return DateTime.Now.ToSerialDateTime(); } - private static object Now(List p) + private static ScalarValue Second(CalcContext ctx, double serialDate) { - return DateTime.Now; + return GetTimeComponent(ctx, serialDate, static d => d.Second); } - private static object Second(List p) + private static ScalarValue Time(CalcContext ctx, double hour, double minute, double second) { - var date = (DateTime)p[0]; + if (!TryGetComponent(hour, out var hourFloored)) + return XLError.NumberInvalid; + + if (!TryGetComponent(minute, out var minuteFloored)) + return XLError.NumberInvalid; + + if (!TryGetComponent(second, out var secondFloored)) + return XLError.NumberInvalid; + + var serialDate = new TimeSpan(hourFloored, minuteFloored, secondFloored).ToSerialDateTime(); + return serialDate % 1.0; + + static bool TryGetComponent(double value, out int truncated) + { + value = Math.Floor(value); + if (value is < 0 or > 32767) + { + truncated = default; + return false; + } - return date.Second; + truncated = checked((int)value); + return true; + } } - private static object Time(List p) + private static ScalarValue TimeValue(CalcContext ctx, ScalarValue value) { - var hour = (int)p[0]; - var minute = (int)p[1]; - var second = (int)p[2]; + if (!value.TryPickText(out var text, out var error)) + return error; + + if (!ScalarValue.ToSerialDateTime(text, ctx.Culture, out var serialDateTime)) + return XLError.IncompatibleValue; - return new TimeSpan(0, hour, minute, second); + return serialDateTime % 1.0; + } + + private static ScalarValue Today() + { + return DateTime.Today.ToSerialDateTime(); } - private static object Timevalue(List p) + private static ScalarValue Weekday(CalcContext ctx, ScalarValue date, ScalarValue flag) { - var date = (DateTime)p[0]; + if (!TryGetDate(ctx, date, out var serialDate, out var dateError, acceptLogical: true)) + return dateError; - return (DateTime.MinValue + date.TimeOfDay).ToOADate(); + var flagValue = 1d; + if (!flag.IsBlank) + { + // Caller provided a value for optional parameter + if (!flag.ToNumber(ctx.Culture).TryPickT0(out flagValue, out var flagError)) + return flagError; + } + + var result = Weekday(serialDate, (int)Math.Truncate(flagValue)); + + if (!result.TryPickT0(out var weekday, out var weekdayError)) + return weekdayError; + + return weekday; } - private static object Today(List p) + private static OneOf Weekday(int serialDate, int startFlag) { - return DateTime.Today; + // There are two offsets: + // - what is the starting day + // - how are days numbered (0-6, 1-7 ...) + int? weekStartOffset = startFlag switch + { + 1 => 0, // Sun + 2 => 6, // Mon + 3 => 6, // Mon + 11 => 6, // Mon + 12 => 5, // Tue + 13 => 4, // Wed + 14 => 3, // Thu + 15 => 2, // Fri + 16 => 1, // Sat + 17 => 0, // Sunday + _ => null, + }; + if (weekStartOffset is null) + return XLError.NumberInvalid; + + var numberOffset = startFlag == 3 ? 0 : 1; + + // Because we don't go below 1900, there is no need to deal with UTC vs Gregorian calendar. + // It is affected by 1900 bug, so no accurate weekdays before 1900-02-29. It was Wednesday BTW :) + var weekday = WeekdayCalc(serialDate, weekStartOffset.Value, numberOffset); + return weekday; + } + + /// + /// Calculate week day. No checks. The default form is form 3 (week starts at Sun, range 1..7). + /// + private static int WeekdayCalc(int serialDate, int weekStartOffset = 0, int numberOffset = 1) + { + return (serialDate + 6 + weekStartOffset) % 7 + numberOffset; } - private static object Weekday(List p) + private static ScalarValue WeekNum(CalcContext ctx, double serialDateTime, double weekStartFlag = 1) { - var dayOfWeek = (int)((DateTime)p[0]).DayOfWeek; - var retType = p.Count == 2 ? (int)p[1] : 1; + if (!TryGetDate(ctx, serialDateTime, out var serialDate)) + return XLError.NumberInvalid; + + var flag = (int)weekStartFlag; + var firstDayOfWeek = flag switch + { + 1 => DayOfWeek.Sunday, + 2 => DayOfWeek.Monday, + 11 => DayOfWeek.Monday, + 12 => DayOfWeek.Tuesday, + 13 => DayOfWeek.Wednesday, + 14 => DayOfWeek.Thursday, + 15 => DayOfWeek.Friday, + 16 => DayOfWeek.Saturday, + 17 => DayOfWeek.Sunday, + 21 => DayOfWeek.Monday, + _ => (DayOfWeek)(-1), + }; + + if (firstDayOfWeek < 0) + return XLError.NumberInvalid; - if (retType == 2) return dayOfWeek; - if (retType == 1) return dayOfWeek + 1; + // Use existing function + if (flag == 21) + return IsoWeekNum(ctx, serialDateTime); - return dayOfWeek - 1; + // When checking all values against Excel, there were two cases when week is 0 + if (serialDate == 0 && firstDayOfWeek == DayOfWeek.Sunday) + return 0; + + var date = DateParts.From(ctx, serialDate); + var startOfYearDate = (int)(new DateTime(date.Year, 1, 1).ToSerialDateTime()); + var startOfYearDayOfWeek = (DayOfWeek)((startOfYearDate + 6) % 7); + var startOfWeekAdjust = firstDayOfWeek - startOfYearDayOfWeek; + + // In 1-17 flags, the start of a week must be at Jan 1st or in few last days of + // previous year. Otherwise some first days of this year wouldn't belong to first + // week, but last week of previous year (that is how ISO behaves). + if (startOfWeekAdjust > 0) + startOfWeekAdjust -= 7; + + var firstWeekStartDate = startOfYearDate + startOfWeekAdjust; + var weekNum = (serialDate - firstWeekStartDate) / 7; + return weekNum + 1; } - private static object Weeknum(List p) + private static ScalarValue Workday(CalcContext ctx, ScalarValue startDateScalar, ScalarValue dayOffsetValue, AnyValue holidays) { - var date = (DateTime)p[0]; - var retType = p.Count == 2 ? (int)p[1] : 1; + if (!TryGetDate(ctx, startDateScalar, out var startDate, out var startDateError)) + return startDateError; + + if (!dayOffsetValue.ToNumber(ctx.Culture).TryPickT0(out var dayOffsetDouble, out var dayOffsetError)) + return dayOffsetError; + + var dayOffset = (int)Math.Truncate(dayOffsetDouble); + + // When offset is zero, return the startDate, regardless if it is Saturday or Sunday. + if (dayOffset == 0) + return startDate; + + var cmp = dayOffset > 0 ? Comparer.Default : Comparer.Create(static (x, y) => y.CompareTo(x)); + var oneDay = dayOffset > 0 ? 1 : -1; // One day in a specified direction + + if (!GetHolidays(cmp).TryPickT0(out var orderedHolidays, out var holidaysError)) + return holidaysError; + + // The algorithm should count workdays for each segment between holiday days + // and sum them up. + + // A date up to which we have counted workdays. It is inclusive, so if + // the lastDateSoFar is a workday, it is already counted in workdaysSoFar. + var lastDateSoFar = startDate; + + // Number of workdays that have already been processed from startDate up to + // the lastDateSoFar (inclusive). + var workdaysSoFar = 0; + var startIsHoliday = orderedHolidays.Count > 0 && orderedHolidays[0] == startDate; + for (var i = startIsHoliday ? 1 : 0; i < orderedHolidays.Count; ++i) + { + var holidayDate = orderedHolidays[i]; + + // Because workdays up to and including lastDateSoFar has already been counted, we add + 1. + // The holidayDate is not a Saturday or Sunday (it has been filtered out). + // Because we know there is no holiday between lastDateSoFar (which might have been + // a holiday or not) + 1 (by adding 1, we are sure it's not counted). + // We are counting up to and including holidayDate. We can't use `holidayDate-1`, because + // if two holidays were next to each other, the `holidayDate-1` might be *before* `lastDateSoFar+1`. + // When days are same, BusinessDaysUntil returns 1 regardless of direction, so add a condition. + var segmentWorkdays = lastDateSoFar + oneDay != holidayDate + ? BusinessDaysUntil(lastDateSoFar + oneDay, holidayDate, System.Array.Empty()) + : oneDay; + + if (cmp.Compare(workdaysSoFar + segmentWorkdays, dayOffset) > 0) + { + // We know that the target day for desired dayOffset is in this segment. + // Possibly at the start, in the middle or at the end. Because of it, + // we know that there are no holidays from `lastDateSoFar..{resultDate}`. + break; + } + + // The segment workdays include holidayDate as a workday, use -1 so it is not counted. + workdaysSoFar += segmentWorkdays - oneDay; + lastDateSoFar = holidayDate; + } + + // At this point, we can just have to find the target date without any interference from holidays. + var remainingWorkdays = dayOffset - workdaysSoFar; + var weekCount = Math.DivRem(remainingWorkdays, 5, out var remaining); + + // When we start on Sunday and want 5 dayOffset, ensure that we end up on friday of same week, not Sunday. + if (remaining == 0) + { + // We know that weekCount is at least 1, so decreasing one won't go negative. + weekCount -= oneDay; + remaining += oneDay * 5; + } + + var workday = lastDateSoFar + weekCount * 7; + while (remaining != 0) + { + do + { + workday += oneDay; + } while (IsWeekend(workday)); + remaining -= oneDay; + } + + return workday; + + OneOf, XLError> GetHolidays(IComparer comparer) + { + // Use set to skip duplicate values + var distinctHolidays = new HashSet(); + foreach (var holidayValue in ctx.GetNonBlankValues(holidays)) + { + if (!TryGetDate(ctx, holidayValue, out var holidayDate, out var error)) + return error; + + if (comparer.Compare(holidayDate, startDate) < 0) + continue; - DayOfWeek dayOfWeek = retType == 1 ? DayOfWeek.Sunday : DayOfWeek.Monday; - var cal = new GregorianCalendar(GregorianCalendarTypes.Localized); - var val = cal.GetWeekOfYear(date, CalendarWeekRule.FirstDay, dayOfWeek); + if (IsWeekend(holidayDate)) + continue; - return val; + distinctHolidays.Add(holidayDate); + } + + // Distinct, ordered holidays during a workweek + var sortedHolidays = new List(distinctHolidays); + sortedHolidays.Sort(comparer); + return sortedHolidays; + } } - private static object Workday(List p) + private static ScalarValue Year(CalcContext ctx, double serialDateTime) { - var startDate = (DateTime)p[0]; - var daysRequired = (int)p[1]; + if (!TryGetDate(ctx, serialDateTime, out var serialDate)) + return XLError.NumberInvalid; - if (daysRequired == 0) return startDate; + return DateParts.From(ctx, serialDate).Year; + } + + private static ScalarValue YearFrac(CalcContext ctx, double startDateTime, double endDateTime, double basis = 0) + { + if (!TryGetDate(ctx, startDateTime, out var startDate)) + return XLError.NumberInvalid; - var bankHolidays = new List(); - if (p.Count == 3) + if (!TryGetDate(ctx, endDateTime, out var endDate)) + return XLError.NumberInvalid; + + if (basis is < 0 or >= 5) + return XLError.NumberInvalid; + + var option = checked((int)Math.Truncate(basis)); + var yearFrac = option switch + { + 0 => Days360(ctx, startDate, endDate, false) / 360.0, // US 30/360 + 1 => (endDate - startDate) / GetYearAverage(ctx, startDate, endDate), // Actual/Actual + 2 => (endDate - startDate) / 360.0, // Actual/360 + 3 => (endDate - startDate) / 365.0, // Actual/365 + _ => Days360(ctx, startDate, endDate, true) / 360.0, // EU 30/360 + }; + + return Math.Abs(yearFrac); + + static double GetYearAverage(CalcContext ctx, int startDate, int endDate) { - var t = new Tally { p[2] }; + var startYear = DateParts.From(ctx, startDate).Year; + var endYear = DateParts.From(ctx, endDate).Year; + var totalDays = 0; + for (var year = startYear; year <= endYear; year++) + { + // For purposes of average year, 1900 is not counted as a leap year + totalDays += DateTime.IsLeapYear(year) ? 366 : 365; + } - bankHolidays.AddRange(t.Select(XLHelper.GetDate)); + return totalDays / (double)(endYear - startYear + 1); } - var testDate = startDate.AddDays(((daysRequired / 7) + 2) * 7 * Math.Sign(daysRequired)); - var return_date = Workday(startDate, testDate, daysRequired, bankHolidays); - if (Math.Sign(daysRequired) == 1) - return_date = return_date.NextWorkday(bankHolidays); - else - return_date = return_date.PreviousWorkDay(bankHolidays); + } - return return_date; + private static bool TryGetDate(CalcContext ctx, ScalarValue value, out int serialDate, out XLError error, bool acceptLogical = false) + { + // For some reason, Excel dislikes logical for things that are "date" in functions. + // Someone likely though it was a good idea 40yrs ago. + if (value.IsLogical && !acceptLogical) + { + serialDate = default; + error = XLError.IncompatibleValue; + return false; + } + + if (!value.ToNumber(ctx.Culture).TryPickT0(out var serialDateTime, out error)) + { + serialDate = default; + return false; + } + + if (serialDateTime is < 0 or > Year10K) + { + serialDate = default; + error = XLError.NumberInvalid; + return false; + } + + serialDate = (int)Math.Truncate(serialDateTime); + error = default; + return true; } - private static DateTime Workday(DateTime startDate, DateTime testDate, int daysRequired, IEnumerable bankHolidays) + private static bool TryGetMonthsOffset(double monthsOffset, out int months) { - var businessDays = BusinessDaysUntil(startDate, testDate, bankHolidays); - if (businessDays == daysRequired) - return testDate; + // Limit enough so integer math won't overflow when added to a date + if (monthsOffset is < -9999 * 12 or > 9999 * 12) + { + months = default; + return false; + } + + months = checked((int)monthsOffset); + return true; + } - int days = businessDays > daysRequired ? -1 : 1; + private static bool TryGetDate(CalcContext ctx, double serialDateTime, out int serialDate) + { + if (serialDateTime < 0 || serialDateTime >= ctx.DateSystemUpperLimit) + { + serialDate = default; + return false; + } - return Workday(startDate, testDate.AddDays(days), daysRequired, bankHolidays); + serialDate = checked((int)Math.Truncate(serialDateTime)); + return true; } - private static object Year(List p) + private static ScalarValue GetTimeComponent(CalcContext ctx, double serialTime, Func component) { - var date = (DateTime)p[0]; + if (serialTime < 0 || serialTime >= ctx.DateSystemUpperLimit) + return XLError.NumberInvalid; - return date.Year; + return component(DateTime.FromOADate(serialTime)); } - private static object Yearfrac(List p) + /// + /// A date type unconstrained by DateTime limitations (1900-01-00 or 1900-02-29). + /// Has some similar methods as DateTime, but without limit checks. + /// + private readonly record struct DateParts(int Year, int Month, int Day) { - var date1 = (DateTime)p[0]; - var date2 = (DateTime)p[1]; - var option = p.Count == 3 ? (int)p[2] : 0; + private static readonly int[] DaysToMonth365 = { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 }; + + private static readonly int[] DaysToMonth366 = { 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366 }; + + private static readonly DateParts Epoch1900 = new(1900, 1, 0); + + private static readonly DateParts Feb29 = new(1900, 2, 29); + + internal int SerialDate => (int)(new DateTime(Year, Month, 1).ToSerialDateTime()) + Day - 1; + + public DayOfWeek DayOfWeek => (DayOfWeek)((WeekdayCalc(SerialDate) + 7 - 1) % 7); + + /// + /// Return day of year, starting from 1 to 365/366. Counts 1900 as 366 leap year. + /// + public int DayOfYear + { + get + { + var startOfYear = new DateParts(Year, 1, 1); + return SerialDate - startOfYear.SerialDate + 1; + } + } + + internal static DateParts From(CalcContext ctx, int serialDate) + { + if (ctx.Use1904DateSystem) + { + var date1904 = DateTime.FromOADate(serialDate + 1462); + return From(date1904); + } + + // Return value for 1900-01-00 + if (serialDate == 0) + return Epoch1900; - if (option == 0) - return Days360(date1, date2, false) / 360.0; - if (option == 1) - return Math.Floor((date2 - date1).TotalDays) / GetYearAverage(date1, date2); - if (option == 2) - return Math.Floor((date2 - date1).TotalDays) / 360.0; - if (option == 3) - return Math.Floor((date2 - date1).TotalDays) / 365.0; + // Everyone loves 29th Feb 1900 + if (serialDate == 60) + return Feb29; + + // January and February 1900. Because of non-existent feb29, adjust by one day + if (serialDate < 60) + serialDate++; + + return From(DateTime.FromOADate(serialDate)); + } - return Days360(date1, date2, true) / 360.0; + private static int DaysInMonth(int year, int month) + { + Debug.Assert(month is >= 1 and <= 12); + var daysToMonth = IsLeapYear(year) ? DaysToMonth366 : DaysToMonth365; + return daysToMonth[month] - daysToMonth[month - 1]; + } + + private static bool IsLeapYear(int year) + { + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + } + + private static DateParts From(DateTime date) + { + return new DateParts(date.Year, date.Month, date.Day); + } + + internal bool IsLastDayOfMonth() + { + // 1900-02-29 is last day of a month per Excel, thus we have: + // * return true for that date + if (this == Feb29) + return true; + + // * can't return true for real end of month + if (Year == 1900 && Month == 2 && Day == 28) + return false; + + return Day == DateTime.DaysInMonth(Year, Month); + } + + internal DateParts AddMonths(int months) + { + var shiftedMonth = Month + months - 1; + var adjustYear = (int)Math.Floor(shiftedMonth / 12.0); + var year = Year + adjustYear; + var month = shiftedMonth - adjustYear * 12 + 1; + var day = Math.Min(Day, DaysInMonth(year, month)); // Uses real Feb28 + return new DateParts(year, month, day); + } + + internal DateParts EndOfMonth() + { + return this with { Day = DaysInMonth(Year, Month) }; + } + + internal OneOf ToSerialDate() + { + if (Year is > 9999 or < 1900) + return XLError.NumberInvalid; + + return SerialDate; + } } } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/Engineering.cs b/ClosedXML/Excel/CalcEngine/Functions/Engineering.cs index 4b9d1ce35..8e1b64e4b 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/Engineering.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/Engineering.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace ClosedXML.Excel.CalcEngine { internal static class Engineering diff --git a/ClosedXML/Excel/CalcEngine/Functions/Financial.cs b/ClosedXML/Excel/CalcEngine/Functions/Financial.cs index 141d1faad..234c65a6e 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/Financial.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/Financial.cs @@ -1,6 +1,5 @@ -using ClosedXML.Excel.CalcEngine.Exceptions; using System; -using System.Collections.Generic; +using static ClosedXML.Excel.CalcEngine.Functions.SignatureAdapter; namespace ClosedXML.Excel.CalcEngine { @@ -27,10 +26,10 @@ public static void Register(FunctionRegistry ce) // DOLLARFR Converts a dollar price, expressed as a decimal number, into a dollar price, expressed as a fraction // DURATION Returns the annual duration of a security with periodic interest payments // EFFECT Returns the effective annual interest rate - // FV Returns the future value of an investment + ce.RegisterFunction("FV", 3, 5, AdaptLastTwoOptional(Fv, 0, 0), FunctionFlags.Scalar); // Returns the future value of an investment // FVSCHEDULE Returns the future value of an initial principal after applying a series of compound interest rates // INTRATE Returns the interest rate for a fully invested security - // IPMT Returns the interest payment for an investment for a given period + ce.RegisterFunction("IPMT", 4, 6, AdaptLastTwoOptional(Ipmt, 0, 0), FunctionFlags.Scalar); // Returns the interest payment for an investment for a given period // IRR Returns the internal rate of return for a series of cash flows // ISPMT Calculates the interest paid during a specific period of an investment // MDURATION Returns the Macauley modified duration for a security with an assumed par value of $100 @@ -43,7 +42,7 @@ public static void Register(FunctionRegistry ce) // ODDLPRICE Returns the price per $100 face value of a security with an odd last period // ODDLYIELD Returns the yield of a security with an odd last period // PDURATION Returns the number of periods required by an investment to reach a specified value - ce.RegisterFunction("PMT", 3, 5, Pmt); // Returns the periodic payment for an annuity + ce.RegisterFunction("PMT", 3, 5, AdaptLastTwoOptional(Pmt, 0, 0), FunctionFlags.Scalar); // Returns the periodic payment for an annuity // PPMT Returns the payment on the principal for an investment for a given period // PRICE Returns the price per $100 face value of a security that pays periodic interest // PRICEDISC Returns the price per $100 face value of a discounted security @@ -65,24 +64,59 @@ public static void Register(FunctionRegistry ce) // YIELDMAT Returns the annual yield of a security that pays interest at maturity } - private static object Pmt(List p) + private static AnyValue Fv(double rate, double numberOfPayments, double pmt, double presentValue, double type) { - double rate = p[0]; - - double numberOfPayments = p[1]; if (numberOfPayments == 0) - throw new NumberException(); + return -presentValue; + + return FvInternal(rate, numberOfPayments, pmt, presentValue, type); + } + + private static double FvInternal(double rate, double numberOfPayments, double pmt, double presentValue, double type) + { + if (rate == 0.0) + return -(pmt * numberOfPayments + presentValue); + + if (type != 0.0) + pmt *= (1 + rate); + + return -(pmt * (Math.Pow(1 + rate, numberOfPayments) - 1) / rate + presentValue * Math.Pow(1 + rate, numberOfPayments)); + } + + private static AnyValue Ipmt(double rate, double period, double numberOfPayments, double presentValue, double futureValue, double type) + { + if (numberOfPayments <= 0 || rate <= -1) + return XLError.NumberInvalid; - double presentValue = p[2]; - double futureValue = p.Count > 3 ? p[3] : 0.0; + numberOfPayments = Math.Ceiling(numberOfPayments); + if (period < 1 || period > numberOfPayments) + return XLError.NumberInvalid; + + double ipmt = FvInternal(rate, period - 1, PmtInternal(rate, numberOfPayments, presentValue, futureValue, type), presentValue, type) * rate; + + if (type != 0.0) + ipmt /= (1 + rate); + + return ipmt; + } + + private static AnyValue Pmt(double rate, double numberOfPayments, double presentValue, double futureValue, double type) + { + if (numberOfPayments == 0 || rate <= -1) + return XLError.NumberInvalid; + + return PmtInternal(rate, numberOfPayments, presentValue, futureValue, type); + } + + private static double PmtInternal(double rate, double numberOfPayments, double presentValue, double futureValue, double type) + { if (rate == 0.0) return -(presentValue + futureValue) / numberOfPayments; const int paymentAtTheEndOfPeriod = 0; const int paymentAtTheBeginningOfPeriod = 1; - bool type = p.Count > 4 && p[4]; - var timingOffset = type ? paymentAtTheBeginningOfPeriod : paymentAtTheEndOfPeriod; + var timingOffset = type != 0.0 ? paymentAtTheBeginningOfPeriod : paymentAtTheEndOfPeriod; return (-futureValue - presentValue * Math.Pow(1.0 + rate, numberOfPayments)) / (1 + rate * timingOffset) / ((Math.Pow(1.0 + rate, numberOfPayments) - 1) / rate); diff --git a/ClosedXML/Excel/CalcEngine/Functions/ITally.cs b/ClosedXML/Excel/CalcEngine/Functions/ITally.cs new file mode 100644 index 000000000..076b42830 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Functions/ITally.cs @@ -0,0 +1,9 @@ +using System; + +namespace ClosedXML.Excel.CalcEngine.Functions; + +internal interface ITally +{ + OneOf Tally(CalcContext ctx, Span args, T initialState) + where T : ITallyState; +} diff --git a/ClosedXML/Excel/CalcEngine/Functions/ITallyState.cs b/ClosedXML/Excel/CalcEngine/Functions/ITallyState.cs new file mode 100644 index 000000000..8d8a49f06 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Functions/ITallyState.cs @@ -0,0 +1,6 @@ +namespace ClosedXML.Excel.CalcEngine.Functions; + +internal interface ITallyState +{ + TState Tally(double number); +} diff --git a/ClosedXML/Excel/CalcEngine/Functions/Information.cs b/ClosedXML/Excel/CalcEngine/Functions/Information.cs index bd291d0f9..6e883abdb 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/Information.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/Information.cs @@ -1,7 +1,8 @@ -using ClosedXML.Excel.CalcEngine.Exceptions; +#nullable disable + using System; -using System.Collections.Generic; -using System.Globalization; +using System.Linq; +using static ClosedXML.Excel.CalcEngine.Functions.SignatureAdapter; namespace ClosedXML.Excel.CalcEngine.Functions { @@ -9,216 +10,181 @@ internal static class Information { public static void Register(FunctionRegistry ce) { - //TODO: Add documentation - ce.RegisterFunction("ERRORTYPE", 1, ErrorType); - ce.RegisterFunction("ISBLANK", 1, int.MaxValue, IsBlank); - ce.RegisterFunction("ISERR", 1, int.MaxValue, IsErr); - ce.RegisterFunction("ISERROR", 1, int.MaxValue, IsError); - ce.RegisterFunction("ISEVEN", 1, IsEven); - ce.RegisterFunction("ISLOGICAL", 1, int.MaxValue, IsLogical); - ce.RegisterFunction("ISNA", 1, int.MaxValue, IsNa); - ce.RegisterFunction("ISNONTEXT", 1, int.MaxValue, IsNonText); - ce.RegisterFunction("ISNUMBER", 1, int.MaxValue, IsNumber); - ce.RegisterFunction("ISODD", 1, IsOdd); - ce.RegisterFunction("ISREF", 1, int.MaxValue, IsRef); - ce.RegisterFunction("ISTEXT", 1, int.MaxValue, IsText); - ce.RegisterFunction("N", 1, N); - ce.RegisterFunction("NA", 0, NA); - ce.RegisterFunction("TYPE", 1, Type); + //ce.RegisterFunction("CELL", 1, 1, Adapt(Cell), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("ERROR.TYPE", 1, 1, Adapt(ErrorType), FunctionFlags.Scalar); + //ce.RegisterFunction("INFO", 1, 1, Adapt(Info), FunctionFlags.Scalar); + ce.RegisterFunction("ISBLANK", 1, 1, Adapt(IsBlank), FunctionFlags.Scalar); + ce.RegisterFunction("ISERR", 1, 1, Adapt(IsErr), FunctionFlags.Scalar); + ce.RegisterFunction("ISERROR", 1, 1, Adapt(IsError), FunctionFlags.Scalar); + ce.RegisterFunction("ISEVEN", 1, 1, Adapt(IsEven), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("ISLOGICAL", 1, 1, Adapt(IsLogical), FunctionFlags.Scalar); + ce.RegisterFunction("ISNA", 1, 1, Adapt(IsNa), FunctionFlags.Scalar); + ce.RegisterFunction("ISNONTEXT", 1, 1, Adapt(IsNonText), FunctionFlags.Scalar); + ce.RegisterFunction("ISNUMBER", 1, 1, Adapt(IsNumber), FunctionFlags.Scalar); + ce.RegisterFunction("ISODD", 1, 1, Adapt(IsOdd), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("ISREF", 1, 1, Adapt(IsRef), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("ISTEXT", 1, 1, Adapt(IsText), FunctionFlags.Scalar); + ce.RegisterFunction("N", 1, 1, Adapt(N), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("NA", 0, 0, NA, FunctionFlags.Scalar); + ce.RegisterFunction("TYPE", 1, 1, Adapt(Type), FunctionFlags.Range, AllowRange.All); } - static IDictionary errorTypes = new Dictionary() - { - [XLError.NullValue] = 1, - [XLError.DivisionByZero] = 2, - [XLError.IncompatibleValue] = 3, - [XLError.CellReference] = 4, - [XLError.NameNotRecognized] = 5, - [XLError.NumberInvalid] = 6, - [XLError.NoValueAvailable] = 7 - }; - - static object ErrorType(List p) + private static AnyValue ErrorType(CalcContext ctx, ScalarValue value) { - var v = p[0].Evaluate(); + if (!value.TryPickError(out var error)) + return XLError.NoValueAvailable; - if (v is XLError error) - return errorTypes[error]; - else - throw new NoValueAvailableException(); + return (int)error + 1; } - static object IsBlank(List p) + private static AnyValue IsBlank(CalcContext ctx, ScalarValue value) { - var v = (string) p[0]; - var isBlank = string.IsNullOrEmpty(v); - - - if (isBlank && p.Count > 1) { - var sublist = p.GetRange(1, p.Count); - isBlank = (bool)IsBlank(sublist); - } - - return isBlank; + return value.IsBlank; } - static object IsErr(List p) + private static AnyValue IsErr(CalcContext ctx, ScalarValue value) { - var v = p[0].Evaluate(); - - return v is XLError error && error != XLError.NoValueAvailable; + return value.TryPickError(out var error) && error != XLError.NoValueAvailable; } - static object IsError(List p) + private static AnyValue IsError(CalcContext ctx, ScalarValue value) { - var v = p[0].Evaluate(); - - return v is XLError; + return value.TryPickError(out _); } - static object IsEven(List p) + private static AnyValue IsEven(CalcContext ctx, AnyValue value) { - var v = p[0].Evaluate(); - if (v is double) + return GetParity(ctx, value, static (scalar, ctx) => { - return Math.Abs((double) v%2) < 1; - } - //TODO: Error Exceptions - throw new ArgumentException("Expression doesn't evaluate to double"); - } + if (scalar.IsLogical) + return XLError.IncompatibleValue; - static object IsLogical(List p) - { - var v = p[0].Evaluate(); - var isLogical = v is bool; + if (!scalar.ToNumber(ctx.Culture).TryPickT0(out var number, out var error)) + return error; - if (isLogical && p.Count > 1) - { - var sublist = p.GetRange(1, p.Count); - isLogical = (bool) IsLogical(sublist); - } + return Math.Truncate(number) % 2 == 0; + }); + } - return isLogical; + private static AnyValue IsLogical(CalcContext ctx, ScalarValue value) + { + return value.IsLogical; } - static object IsNa(List p) + private static AnyValue IsNa(CalcContext ctx, ScalarValue value) { - try - { - var v = p[0].Evaluate(); - return v is XLError error && error == XLError.NoValueAvailable; - } - catch (NoValueAvailableException) - { - return true; - } - catch (CalcEngineException) - { - return false; - } + return value.TryPickError(out var error) && error == XLError.NoValueAvailable; } - static object IsNonText(List p) + private static AnyValue IsNonText(CalcContext ctx, ScalarValue value) { - return !(bool) IsText(p); + return !value.IsText; } - static object IsNumber(List p) + private static AnyValue IsNumber(CalcContext ctx, ScalarValue value) { - var v = p[0].Evaluate(); + return value.IsNumber; + } - var isNumber = v is double; //Normal number formatting - if (!isNumber) - { - isNumber = v is DateTime; //Handle DateTime Format - } - if (!isNumber) + private static AnyValue IsOdd(CalcContext ctx, AnyValue value) + { + return GetParity(ctx, value, static (scalar, ctx) => { - //Handle Number Styles - try - { - var stringValue = (string) v; - return double.TryParse(stringValue.TrimEnd('%', ' '), NumberStyles.Any, null, out double dv); - } - catch (Exception) - { - isNumber = false; - } - } + if (scalar.IsLogical) + return XLError.IncompatibleValue; - if (isNumber && p.Count > 1) - { - var sublist = p.GetRange(1, p.Count); - isNumber = (bool)IsNumber(sublist); - } + if (!scalar.ToNumber(ctx.Culture).TryPickT0(out var number, out var error)) + return error; - return isNumber; + return Math.Truncate(number) % 2 != 0; + }); } - static object IsOdd(List p) + private static AnyValue IsRef(CalcContext ctx, AnyValue value) { - return !(bool) IsEven(p); + return value.IsReference; } - static object IsRef(List p) + private static AnyValue IsText(CalcContext ctx, ScalarValue value) { - var oe = p[0] as XObjectExpression; - if (oe == null) - return false; - - var crr = oe.Value as CellRangeReference; - - return crr != null; + return value.IsText; } - static object IsText(List p) + private static AnyValue N(CalcContext ctx, AnyValue value) { - //Evaluate Expressions - var isText = !(bool) IsBlank(p); - if (isText) - { - isText = !(bool) IsNumber(p); - } - if (isText) + if (value.TryPickScalar(out var scalar, out var collection)) + return ToNumber(scalar).ToAnyValue(); + + if (collection.TryPickT0(out var array, out var reference)) + return array.Apply(static v => ToNumber(v)); + + var area = reference.Areas.First(); + var referenceValue = ctx.GetCellValue(area.Worksheet, area.FirstAddress.RowNumber, area.FirstAddress.RowNumber); + return ToNumber(referenceValue).ToAnyValue(); + + static ScalarValue ToNumber(ScalarValue scalar) { - isText = !(bool) IsLogical(p); - } - return isText; - } + if (scalar.TryPickNumber(out var number)) + return number; + if (scalar.TryPickLogical(out var logical)) + return logical ? 1 : 0; + if (scalar.TryPickError(out var error)) + return error; - static object N(List p) - { - return (double) p[0]; + return 0; // Blank, text + } } - static object NA(List p) + private static AnyValue NA(CalcContext ctx, Span value) { return XLError.NoValueAvailable; } - static object Type(List p) + private static AnyValue Type(CalcContext ctx, AnyValue value) { - if ((bool) IsNumber(p)) + if (!value.TryPickScalar(out var scalar, out var collection)) { - return 1; + var isArray = collection.TryPickT0(out _, out var reference); + if (isArray) + return 64; + if (reference.Areas.Count > 1) + return 16; + if (!reference.TryGetSingleCellValue(out scalar, ctx)) + return 64; } - if ((bool) IsText(p)) - { + + if (scalar.IsBlank || scalar.IsNumber) + return 1; + if (scalar.IsText) return 2; - } - if ((bool) IsLogical(p)) - { + if (scalar.IsLogical) return 4; - } - if ((bool) IsError(p)) - { + if (scalar.IsError) return 16; - } - if(p.Count > 1) + + // There is a "composite type", but no idea what exactly it is. Shouldn't happen. + throw new InvalidOperationException("Unknown type."); + } + + private static AnyValue GetParity(CalcContext ctx, AnyValue value, Func f) + { + // IsOdd/IsEven has very strange semantic that is different for pretty much every other function + // Array behaves differently for multi-cell references, in-place blank vs cell blank give different value... + if (value.TryPickScalar(out var scalar, out var coll)) { - return 64; + if (scalar.IsBlank) + return XLError.NoValueAvailable; + + return f(scalar, ctx).ToAnyValue(); } - return null; + + if (coll.TryPickT0(out var array, out var reference)) + return array.Apply(x => f(x, ctx)); + + if (!reference.TryGetSingleCellValue(out var cellValue, ctx)) + return XLError.IncompatibleValue; + + return f(cellValue, ctx).ToAnyValue(); } } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/Logical.cs b/ClosedXML/Excel/CalcEngine/Functions/Logical.cs index 0de0ea771..1ef1b7f87 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/Logical.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/Logical.cs @@ -1,5 +1,8 @@ +#nullable disable + using System; -using System.Collections.Generic; +using ClosedXML.Excel.CalcEngine.Functions; +using static ClosedXML.Excel.CalcEngine.Functions.SignatureAdapter; namespace ClosedXML.Excel.CalcEngine { @@ -7,80 +10,92 @@ internal static class Logical { public static void Register(FunctionRegistry ce) { - ce.RegisterFunction("AND", 1, int.MaxValue, And, AllowRange.All); - ce.RegisterFunction("OR", 1, int.MaxValue, Or); - ce.RegisterFunction("NOT", 1, Not); - ce.RegisterFunction("IF", 2, 3, If); - ce.RegisterFunction("TRUE", 0, True); - ce.RegisterFunction("FALSE", 0, False); - ce.RegisterFunction("IFERROR",2,IfError); + ce.RegisterFunction("AND", 1, int.MaxValue, And, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("FALSE", 0, 0, Adapt(False), FunctionFlags.Scalar); + ce.RegisterFunction("IF", 2, 3, AdaptLastOptional(If, false), FunctionFlags.Range, AllowRange.Only, 1, 2); + ce.RegisterFunction("IFERROR", 2, 2, Adapt(IfError), FunctionFlags.Scalar); + ce.RegisterFunction("NOT", 1, 1, AdaptCoerced(Not), FunctionFlags.Scalar); + ce.RegisterFunction("OR", 1, int.MaxValue, Or, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("TRUE", 0, 0, Adapt(True), FunctionFlags.Scalar); } - static object And(List p) + private static AnyValue And(CalcContext ctx, Span args) { - var b = true; - foreach (var v in p) - { - b = b && v; - } - return b; + var aggResult = args.Aggregate( + ctx, + true, + XLError.IncompatibleValue, + static (acc, val) => acc && val, + static (v, _) => + { + // Skip values that can't be converted, but aren't errors, like "text" + if (v.IsError) + return v.GetError(); + if (!v.TryCoerceLogicalOrBlankOrNumberOrText(out var logical, out var _)) + return true; + return logical; + }, + static v => v.IsLogical || v.IsNumber); // No text conversion for element of collection, blanks are ignored in references + + if (!aggResult.TryPickT0(out var value, out var error)) + return error; + + return value; } - static object Or(List p) + private static ScalarValue False() { - var b = false; - foreach (var v in p) - { - b = b || v; - } - return b; + return false; } - static object Not(List p) + private static AnyValue If(ScalarValue condition, AnyValue valueIfTrue, AnyValue valueIfFalse) { - return !p[0]; + if (!condition.TryCoerceLogicalOrBlankOrNumberOrText(out var value, out var error)) + return error; + + return value ? valueIfTrue : valueIfFalse; } - static object If(List p) + private static AnyValue IfError(ScalarValue potentialError, ScalarValue alternative) { - if (p[0]) - { - return p[1].Evaluate(); - } - else if (p.Count > 2) - { - if (p[2] is EmptyValueExpression) - return false; - else - return p[2].Evaluate(); - } - else return false; + if (!potentialError.IsError) + return potentialError.ToAnyValue(); + + return alternative.ToAnyValue(); } - static object True(List p) + private static AnyValue Not(Boolean value) { - return true; + return !value; } - static object False(List p) + private static AnyValue Or(CalcContext ctx, Span args) { - return false; + var aggResult = args.Aggregate( + ctx, + false, + XLError.IncompatibleValue, + static (acc, val) => acc || val, + static (v, _) => + { + // Skip values that can't be converted, but aren't errors, like "text" + if (v.IsError) + return v.GetError(); + if (!v.TryCoerceLogicalOrBlankOrNumberOrText(out var logical, out var _)) + return false; + return logical; + }, + static v => v.IsLogical || v.IsNumber); // No text conversion for element of collection, blanks are ignored in references + + if (!aggResult.TryPickT0(out var value, out var error)) + return error; + + return value; } - static object IfError(List p) + private static ScalarValue True() { - try - { - var value = p[0].Evaluate(); - if (value is XLError) - return p[1].Evaluate(); - - return value; - } - catch (ArgumentException) - { - return p[1].Evaluate(); - } + return true; } } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/Lookup.cs b/ClosedXML/Excel/CalcEngine/Functions/Lookup.cs index 3f416450e..7e8de6d39 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/Lookup.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/Lookup.cs @@ -1,7 +1,9 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned -using ClosedXML.Excel.CalcEngine.Exceptions; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using static ClosedXML.Excel.CalcEngine.Functions.SignatureAdapter; @@ -15,21 +17,21 @@ public static void Register(FunctionRegistry ce) //ce.RegisterFunction("AREAS", , Areas); // Returns the number of areas in a reference //ce.RegisterFunction("CHOOSE", , Choose); // Chooses a value from a list of values ce.RegisterFunction("COLUMN", 0, 1, Column, FunctionFlags.Range, AllowRange.All); // Returns the column number of a reference - //ce.RegisterFunction("COLUMNS", , Columns); // Returns the number of columns in a reference + ce.RegisterFunction("COLUMNS", 1, 1, Adapt(Columns), FunctionFlags.Range, AllowRange.All); // Returns the number of columns in a reference //ce.RegisterFunction("FORMULATEXT", , Formulatext); // Returns the formula at the given reference as text //ce.RegisterFunction("GETPIVOTDATA", , Getpivotdata); // Returns data stored in a PivotTable report - ce.RegisterFunction("HLOOKUP", 3, 4, Hlookup, AllowRange.Only, 1); // Looks in the top row of an array and returns the value of the indicated cell + ce.RegisterFunction("HLOOKUP", 3, 4, AdaptLastOptional(Hlookup, true), FunctionFlags.Range, AllowRange.Only, 1); // Looks in the top row of an array and returns the value of the indicated cell ce.RegisterFunction("HYPERLINK", 1, 2, Adapt(Hyperlink), FunctionFlags.Scalar | FunctionFlags.SideEffect); // Creates a shortcut or jump that opens a document stored on a network server, an intranet, or the Internet - ce.RegisterFunction("INDEX", 2, 4, Index, AllowRange.Only, 0, 1); // Uses an index to choose a value from a reference or array + ce.RegisterFunction("INDEX", 2, 4, AdaptIndex(Index), FunctionFlags.Range | FunctionFlags.ReturnsArray, AllowRange.Only, 0); // Uses an index to choose a value from a reference or array //ce.RegisterFunction("INDIRECT", , Indirect); // Returns a reference indicated by a text value //ce.RegisterFunction("LOOKUP", , Lookup); // Looks up values in a vector or array - ce.RegisterFunction("MATCH", 2, 3, Match, AllowRange.Only, 1); // Looks up values in a reference or array + ce.RegisterFunction("MATCH", 2, 3, AdaptMatch(Match), FunctionFlags.Range, AllowRange.Only, 1); // Looks up values in a reference or array //ce.RegisterFunction("OFFSET", , Offset); // Returns a reference offset from a given reference - ce.RegisterFunction("ROW", 0, 1, Row, FunctionFlags.Range, AllowRange.All); // Returns the row number of a reference - //ce.RegisterFunction("ROWS", , Rows); // Returns the number of rows in a reference + ce.RegisterFunction("ROW", 0, 1, Row, FunctionFlags.Range | FunctionFlags.ReturnsArray, AllowRange.All); // Returns the row number of a reference + ce.RegisterFunction("ROWS", 1, 1, Adapt(Rows), FunctionFlags.Range, AllowRange.All); // Returns the number of rows in a reference //ce.RegisterFunction("RTD", , Rtd); // Retrieves real-time data from a program that supports COM automation - //ce.RegisterFunction("TRANSPOSE", , Transpose); // Returns the transpose of an array - ce.RegisterFunction("VLOOKUP", 3, 4, Vlookup, AllowRange.Only, 1); // Looks in the first column of an array and moves across the row to return the value of a cell + ce.RegisterFunction("TRANSPOSE", 1, 1, Adapt(Transpose), FunctionFlags.Range | FunctionFlags.ReturnsArray, AllowRange.All); // Returns the transpose of an array + ce.RegisterFunction("VLOOKUP", 3, 4, AdaptLastOptional(Vlookup, true), FunctionFlags.Range, AllowRange.Only, 1); // Looks in the first column of an array and moves across the row to return the value of a cell } private static AnyValue Column(CalcContext ctx, Span p) @@ -53,189 +55,325 @@ private static AnyValue Column(CalcContext ctx, Span p) return new ConstArray(array); } - private static IXLRange ExtractRange(Expression expression) + private static AnyValue Columns(CalcContext _, AnyValue value) { - if (!(expression is XObjectExpression objectExpression)) - throw new NoValueAvailableException("Parameter has to be a valid range"); - - if (!(objectExpression.Value is CellRangeReference cellRangeReference)) - throw new NoValueAvailableException("lookup_array has to be a range"); - - var range = cellRangeReference.Range; - return range; + return RowsOrColumns(value, false); } - private static object Hlookup(List p) + private static AnyValue Hlookup(CalcContext ctx, ScalarValue lookupValue, AnyValue rangeValue, double rowNumber, bool approximateSearchFlag) { - var lookup_value = p[0]; - var range = ExtractRange(p[1]); - var row_index_num = (int)p[2]; - var range_lookup = p.Count < 4 - || p[3] is EmptyValueExpression - || (bool)(p[3]); - - if (row_index_num < 1) - throw new CellReferenceException("Row index has to be positive"); - - if (row_index_num > range.RowCount()) - throw new CellReferenceException("Row index has to be positive"); - - IXLRangeColumn matching_column; - matching_column = range.FindColumn(c => !c.Cell(1).IsEmpty() && new Expression(c.Cell(1).Value).CompareTo(lookup_value) == 0); - if (range_lookup && matching_column == null) + if (lookupValue.IsError) + return lookupValue.ToAnyValue(); + + // Only the lookup value is converted to 0, not values in the range + if (lookupValue.IsBlank) + lookupValue = 0; + + if (lookupValue.TryPickText(out var lookupText, out _) && lookupText.Length > 255) + return XLError.IncompatibleValue; + + if (rangeValue.TryPickScalar(out _, out var range)) + return XLError.NoValueAvailable; + if (!range.TryPickT0(out var array, out var reference)) { - var first_column = range.FirstColumn().ColumnNumber(); - var number_of_columns_in_range = range.ColumnsUsed().Count(); + if (reference.Areas.Count > 1) + return XLError.NoValueAvailable; - matching_column = range.FindColumn(c => - { - var column_index_in_range = c.ColumnNumber() - first_column + 1; - if (column_index_in_range < number_of_columns_in_range && !c.Cell(1).IsEmpty() && new Expression(c.Cell(1).Value).CompareTo(lookup_value) <= 0 && !c.ColumnRight().Cell(1).IsEmpty() && new Expression(c.ColumnRight().Cell(1).Value).CompareTo(lookup_value) > 0) - return true; - else if (column_index_in_range == number_of_columns_in_range && !c.Cell(1).IsEmpty() && new Expression(c.Cell(1).Value).CompareTo(lookup_value) <= 0) - return true; - else - return false; - }); + array = new ReferenceArray(reference.Areas.Single(), ctx); } - if (matching_column == null) - throw new NoValueAvailableException("No matches found."); + var rowIndex = (int)Math.Truncate(rowNumber) - 1; + if (rowIndex < 0) + return XLError.IncompatibleValue; + if (rowIndex >= array.Height) + return XLError.CellReference; + + if (approximateSearchFlag) + { + // Bisection in Excel and here differs, so we return different values for unsorted ranges, but same values for sorted ranges. + var transposedArray = new TransposedArray(array); + var foundColumn = Bisection(transposedArray, lookupValue); + if (foundColumn == -1) + return XLError.NoValueAvailable; + + return array[rowIndex, foundColumn].ToAnyValue(); + } + else + { + // TODO: Implement wildcard search + for (var columnIndex = 0; columnIndex < array.Width; columnIndex++) + { + var currentValue = array[0, columnIndex]; + + // Because lookup value can't be an error, it doesn't matter that sort treats all errors as equal. + var comparison = ScalarValueComparer.SortIgnoreCase.Compare(currentValue, lookupValue); + if (comparison == 0) + return array[rowIndex, columnIndex].ToAnyValue(); + } - return matching_column - .Cell(row_index_num) - .Value; + return XLError.NoValueAvailable; + } } private static AnyValue Hyperlink(CalcContext ctx, string linkLocation, ScalarValue? friendlyName) { - var link = new XLHyperlink(linkLocation); - var cell = ctx.Worksheet.Cell(ctx.FormulaAddress); - cell.SetHyperlink(link); - return friendlyName?.ToAnyValue() ?? linkLocation; } - private static object Index(List p) + public static AnyValue Index(CalcContext ctx, AnyValue value, List p) { - // This is one of the few functions that is "overloaded" - var range = ExtractRange(p[0]); + var areaNumber = p.Count > 2 ? p[2] : 1; + if (areaNumber < 1) + return XLError.IncompatibleValue; - if (range.ColumnCount() > 1 && range.RowCount() > 1) - { - var row_num = (int)p[1]; - var column_num = (int)p[2]; + if (!value.IsReference && areaNumber > 1) + return XLError.CellReference; - if (row_num > range.RowCount()) - throw new CellReferenceException("Out of bound row number"); + // There must be two paths, one for array and one for reference. Reference path + // must return reference, so it behaves correctly with implicit intersection. + OneOf data; + if (value.TryPickScalar(out var scalar, out var collection)) + { + if (scalar.IsBlank) + return XLError.IncompatibleValue; - if (column_num > range.ColumnCount()) - throw new CellReferenceException("Out of bound column number"); + data = new ScalarArray(scalar, 1, 1); + } + else if (collection.TryPickT0(out var valueArray, out var reference)) + { + data = valueArray; + } + else + { + if (areaNumber > reference.Areas.Count) + return XLError.CellReference; - return range.Row(row_num).Cell(column_num).Value; + data = reference.Areas[areaNumber - 1]; } - else if (p.Count == 2) + + var width = data.Match(static area => area.ColumnSpan, static array => array.Width); + var height = data.Match(static area => area.RowSpan, static array => array.Height); + + var rowNumber = 0; + var colNumber = 0; + if (p.Count == 1) { - var cellOffset = (int)p[1]; - if (cellOffset > range.RowCount() * range.ColumnCount()) - throw new CellReferenceException(); + if (width == 1) + rowNumber = p[0]; - return range.Cells().ElementAt(cellOffset - 1).Value; + if (height == 1) + colNumber = p[0]; } - else + + if (p.Count >= 2) { - int column_num = 1; - int row_num = 1; + rowNumber = p[0]; + colNumber = p[1]; + } - if (!(p[1] is EmptyValueExpression)) - row_num = (int)p[1]; + // Check the bounded values + if (rowNumber < 0 || colNumber < 0) + return XLError.IncompatibleValue; - if (!(p[2] is EmptyValueExpression)) - column_num = (int)p[2]; + if (rowNumber > height || colNumber > width) + return XLError.CellReference; - var rangeIsRow = range.RowCount() == 1; - if (rangeIsRow && row_num > 1) - throw new CellReferenceException(); + return data.TryPickT0(out var area, out var array) + ? IndexArea(area, rowNumber, colNumber) + : IndexArray(array, rowNumber, colNumber); - if (!rangeIsRow && column_num > 1) - throw new CellReferenceException(); + static Reference IndexArea(XLRangeAddress area, int rowNumber, int colNumber) + { + // Return whole area + if (rowNumber == 0 && colNumber == 0) + return new Reference(area); - if (row_num > range.RowCount()) - throw new CellReferenceException("Out of bound row number"); + // Return one column at colNumber + if (rowNumber == 0) + { + var topCell = new XLAddress(area.Worksheet, area.FirstAddress.RowNumber, area.FirstAddress.ColumnNumber + colNumber - 1, true, true); + var bottomCell = new XLAddress(area.Worksheet, area.LastAddress.RowNumber, area.FirstAddress.ColumnNumber + colNumber - 1, true, true); + return new Reference(new XLRangeAddress(topCell, bottomCell)); + } - if (column_num > range.ColumnCount()) - throw new CellReferenceException("Out of bound column number"); + // Return one row at rowNumber + if (colNumber == 0) + { + var leftCell = new XLAddress(area.Worksheet, area.FirstAddress.RowNumber + rowNumber - 1, area.FirstAddress.ColumnNumber, true, true); + var rightCell = new XLAddress(area.Worksheet, area.FirstAddress.RowNumber + rowNumber - 1, area.LastAddress.ColumnNumber, true, true); + return new Reference(new XLRangeAddress(leftCell, rightCell)); + } + + // Return single cell reference. + var areaCorner = area.FirstAddress; + var cellAddress = new XLAddress(area.Worksheet, areaCorner.RowNumber + rowNumber - 1, areaCorner.ColumnNumber + colNumber - 1, true, true); + return new Reference(new XLRangeAddress(cellAddress, cellAddress)); + } - return range.Row(row_num).Cell(column_num).Value; + static AnyValue IndexArray(Array array, int rowNumber, int colNumber) + { + // Return whole array + if (rowNumber == 0 && colNumber == 0) + return array; + + // Return one column at colNumber + if (rowNumber == 0) + return new SlicedArray(array, 0, array.Height, colNumber - 1, 1); + + // Return one row at rowNumber + if (colNumber == 0) + return new SlicedArray(array, rowNumber - 1, 1, 0, array.Width); + + // Return single value + return array[rowNumber - 1, colNumber - 1].ToAnyValue(); } } - private static object Match(List p) + private static ScalarValue Match(CalcContext ctx, ScalarValue target, AnyValue lookupArray, int matchType) { - var lookup_value = p[0]; - var range = ExtractRange(p[1]); - int match_type = 1; - if (p.Count > 2) - match_type = Math.Sign((int)p[2]); + if (target.IsBlank) + return XLError.NoValueAvailable; + + if (target.TryPickError(out var error)) + return error; - if (range.ColumnCount() != 1 && range.RowCount() != 1) - throw new CellValueException("Range has to be 1-dimensional"); + if (!lookupArray.TryPickCollectionArray(out var array, ctx)) + return XLError.NoValueAvailable; - Predicate lookupPredicate = null; - switch (match_type) + // Match only supports arrays with one row or one column. + // Normalize to an array with one column in both cases. + if (array.Height == 1 && array.Width > 1) + array = new TransposedArray(array); + + if (array.Width != 1) + return XLError.NoValueAvailable; + + var index = matchType switch { - case 0: - lookupPredicate = i => i == 0; - break; + < 0 => MatchDescending(target, array, ScalarValueComparer.SortIgnoreCase), + 0 => MatchUnsorted(target, array, ctx), + > 0 => MatchAscending(target, array, ScalarValueComparer.SortIgnoreCase), + }; - case 1: - lookupPredicate = i => i <= 0; - break; + if (index < 0) + return XLError.NoValueAvailable; - case -1: - lookupPredicate = i => i >= 0; - break; + return index + 1; + + static int MatchAscending(ScalarValue target, Array data, IComparer comparer) + { + var index = Bisection(target, data, comparer); + if (index == -1) + return index; - default: - throw new NoValueAvailableException("Invalid match_type"); + // When there are multiple same elements, return position of the last one + while (index < data.Height - 1 && comparer.Compare(data[index + 1, 0], data[index, 0]) == 0) + index++; + + return index; } - IXLCell foundCell = null; + static int MatchUnsorted(ScalarValue target, Array data, CalcContext ctx) + { + var criteria = Criteria.Create(target, ctx.Culture); + for (var i = 0; i < data.Height; ++i) + { + var value = data[i, 0]; + if (target.HaveSameType(value) && criteria.Match(value)) + return i; + } - if (match_type == 0) - foundCell = range - .CellsUsed(XLCellsUsedOptions.Contents, c => lookupPredicate.Invoke(new Expression(c.Value).CompareTo(lookup_value))) - .FirstOrDefault(); - else + return -1; + } + + static int MatchDescending(ScalarValue target, Array data, IComparer comparer) { - object previousValue = null; - foundCell = range - .CellsUsed(XLCellsUsedOptions.Contents) - .TakeWhile(c => + // Data should be in ascending order, but Excel doesn't use bisection. + var found = -1; + for (var i = 0; i < data.Height; ++i) + { + // Skip elements with different type + var value = data[i, 0]; + while (!value.HaveSameType(target)) { - var currentCellExpression = new Expression(c.Value); + if (i == data.Height - 1) + return found; + + value = data[++i, 0]; + } + + var compare = comparer.Compare(target, value); + if (compare == 0) + return i; - if (previousValue != null) - { - // When match_type != 0, we have to assume that the order of the items being search is ascending or descending - var previousValueExpression = new Expression(previousValue); - if (!lookupPredicate.Invoke(previousValueExpression.CompareTo(currentCellExpression))) - return false; - } + if (compare > 0) // target > value + return found; - previousValue = c.Value; + // value > target, so there might an exact match later + found = i; + } - return lookupPredicate.Invoke(currentCellExpression.CompareTo(lookup_value)); - }) - .LastOrDefault(); + return found; } + } + + /// + /// Find index of the greatest element smaller or equal to the . + /// + /// Value to look for. + /// Data in ascending order. + /// A comparator for comparing two values. + /// Index of found element. If the contains + /// a sequence of values, it can be index of any of them. + /// + private static int Bisection(ScalarValue target, Array data, IComparer comparer) + { + // This should match Excel logic perfectly. Make sure to do some fuzzy testing when changing the code. + var low = 0; + var high = data.Height - 1; + while (low < high) + { + var (middle, compare) = FindMiddleAbove(low, high, target, data, comparer); - if (foundCell == null) - throw new NoValueAvailableException(); + if (compare == 0) + return middle; + + // target < value + if (compare < 0) + high = Math.Max(low, middle - 1); + + // target > value + if (compare > 0) + low = Math.Min(high, middle + 1); + } + + // Final index might point to an element greater than the lookup + // (e.g. { 1, 2 } with lookup 1.5). The data should be ascending, + // so just go in the expected order. + for (var i = low; i >= 0; --i) + { + var compare = comparer.Compare(data[i, 0], target); + if (compare <= 0) // data[i] <= target + return i; + } + + return -1; + + static (int Middle, int Comparison) FindMiddleAbove(int low, int high, ScalarValue target, Array data, IComparer comparer) + { + var initial = (low + high) / 2; + var middle = initial; + while (middle <= high) + { + if (data[middle, 0].HaveSameType(target)) + return (middle, comparer.Compare(target, data[middle, 0])); - var firstCell = range.FirstCell(); + middle++; + } - return (foundCell.Address.ColumnNumber - firstCell.Address.ColumnNumber + 1) * (foundCell.Address.RowNumber - firstCell.Address.RowNumber + 1); + // There is nothing left in the higher half. Target must be in the lower half. + return (initial, -1); + } } private static AnyValue Row(CalcContext ctx, Span p) @@ -259,53 +397,200 @@ private static AnyValue Row(CalcContext ctx, Span p) return new ConstArray(array); } - private static object Vlookup(List p) + private static AnyValue Rows(CalcContext _, AnyValue value) { - var lookup_value = p[0]; - var range = ExtractRange(p[1]); - var col_index_num = (int)p[2]; - var range_lookup = p.Count < 4 - || p[3] is EmptyValueExpression - || (bool)(p[3]); + return RowsOrColumns(value, true); + } - if (col_index_num < 1) - throw new CellReferenceException("Column index has to be positive"); + private static AnyValue Transpose(CalcContext ctx, AnyValue value) + { + if (value.TryPickSingleOrMultiValue(out var single, out var multi, ctx)) + return single.ToAnyValue(); - if (col_index_num > range.ColumnCount()) - throw new CellReferenceException("Colum index must be smaller or equal to the number of columns in the table array"); + return new TransposedArray(multi); + } + + private static AnyValue Vlookup(CalcContext ctx, ScalarValue lookupValue, AnyValue rangeValue, double columnNumber, bool approximateSearchFlag) + { + if (lookupValue.IsError) + return lookupValue.ToAnyValue(); - IXLRangeRow matching_row; - try + // Only the lookup value is converted to 0, not values in the range + if (lookupValue.IsBlank) + lookupValue = 0; + + if (lookupValue.TryPickText(out var lookupText, out _) && lookupText.Length > 255) + return XLError.IncompatibleValue; + + if (rangeValue.TryPickScalar(out _, out var range)) + return XLError.NoValueAvailable; + if (!range.TryPickT0(out var array, out var reference)) { - matching_row = range.FindRow(r => !r.Cell(1).IsEmpty() && new Expression(r.Cell(1).Value).CompareTo(lookup_value) == 0); + if (reference.Areas.Count > 1) + return XLError.NoValueAvailable; + + array = new ReferenceArray(reference.Areas.Single(), ctx); } - catch (Exception ex) + + var columnIdx = (int)Math.Truncate(columnNumber) - 1; + if (columnIdx < 0) + return XLError.IncompatibleValue; + if (columnIdx >= array.Width) + return XLError.CellReference; + + if (approximateSearchFlag) { - throw new NoValueAvailableException("No matches found", ex); + // Bisection in Excel and here differs, so we return different values for unsorted ranges, but same values for sorted ranges. + var foundRow = Bisection(array, lookupValue); + if (foundRow == -1) + return XLError.NoValueAvailable; + + return array[foundRow, columnIdx].ToAnyValue(); } - if (range_lookup && matching_row == null) + else { - var first_row = range.FirstRow().RowNumber(); - var number_of_rows_in_range = range.RowsUsed().Count(); - - matching_row = range.FindRow(r => + // TODO: Implement wildcard search + for (var rowIndex = 0; rowIndex < array.Height; rowIndex++) { - var row_index_in_range = r.RowNumber() - first_row + 1; - if (row_index_in_range < number_of_rows_in_range && !r.Cell(1).IsEmpty() && new Expression(r.Cell(1).Value).CompareTo(lookup_value) <= 0 && !r.RowBelow().Cell(1).IsEmpty() && new Expression(r.RowBelow().Cell(1).Value).CompareTo(lookup_value) > 0) - return true; - else if (row_index_in_range == number_of_rows_in_range && !r.Cell(1).IsEmpty() && new Expression(r.Cell(1).Value).CompareTo(lookup_value) <= 0) - return true; - else - return false; - }); + var currentValue = array[rowIndex, 0]; + + // Because lookup value can't be an error, it doesn't matter that sort treats all errors as equal. + var comparison = ScalarValueComparer.SortIgnoreCase.Compare(currentValue, lookupValue); + if (comparison == 0) + return array[rowIndex, columnIdx].ToAnyValue(); + } + + return XLError.NoValueAvailable; } + } + + private static int Bisection(Array range, ScalarValue lookupValue) + { + // Bisection is predicated on a fact that values of the same type are sorted. + // If they are not, results are unpredictable. + // Invariants: + // * Low row has a value that is less or equal than lookup value + // * High row has a value that is greater than lookup value + var lowRow = 0; + var highRow = range.Height - 1; + + lowRow = FindSameTypeRow(range, highRow, 1, lowRow, in lookupValue); + if (lowRow == -1) + return -1; // Range doesn't contain even one element of same type + + // Sanity check for unsorted ranges. For bisection to work, lowRow always + // has to have a value that is less or equal to the lookup value. + var lowValue = range[lowRow, 0]; + var lowCompare = ScalarValueComparer.SortIgnoreCase.Compare(lowValue, lookupValue); + + // Ensure invariants before main loop. If even lowest value in the range is greater than lookup value, + // then there can't be any row that matches lookup value/lower. + if (lowCompare > 0) + return -1; + + // Since we already know that there is at least one element of same type as lookup value, + // high row will find something, though it might be same row as lowRow. + highRow = FindSameTypeRow(range, lowRow, -1, highRow, in lookupValue); + + // Sanity check for unsorted ranges. For bisection to work, highRow always + // has to have a value that is greater than the lookup value + var highValue = range[highRow, 0]; + var highCompare = ScalarValueComparer.SortIgnoreCase.Compare(highValue, lookupValue); + + // Ensure invariants before main loop. If the lookup value is greater/equal than + // the greatest value of the range, it is the result. + if (highCompare <= 0) + return highRow; + + // Now we have two borders with actual values and we know the lookup value is less than high and greater/equal to lower + while (true) + { + // The FindMiddle method returns only values [lowRow, highRow) + // so in each loop it decreases the interval. The lowRow value is + // the last one checked during search of a middle. + var middleRow = FindMiddle(range, lowRow, highRow, in lookupValue); + + // A condition for "if an exact match is not found, the next + // largest value that is less than lookup-value is returned". + // At this time, lowRow is less than lookup value and highRow + // is more than lookup value. + if (middleRow == lowRow) + return lowRow; + + var middleValue = range[middleRow, 0]; + var middleCompare = ScalarValueComparer.SortIgnoreCase.Compare(middleValue, lookupValue); + + if (middleCompare <= 0) + lowRow = middleRow; + else + highRow = middleRow; + } + } + + /// + /// Find a row with a value of same type as + /// between values and - 1. + /// We know that both and + /// contain value of the same type, so we always get a valid row. + /// + private static int FindMiddle(Array range, int low, int high, in ScalarValue lookupValue) + { + Debug.Assert(low < high); + var middleRow = (low + high) / 2; + + // Since low is < high, it's always possible skip high row for determining middle row + var higherIndex = FindSameTypeRow(range, high - 1, 1, middleRow, in lookupValue); + if (higherIndex != -1) + return higherIndex; + + // We can't skip low like we did for high, because there might be only different type + // Cells between low row and high row. + var lowerIndex = FindSameTypeRow(range, low, -1, middleRow, in lookupValue); + return lowerIndex; + } + + /// + /// Find row index of an element with same type as the lookup value. Go from + /// to the by a step + /// of . If there isn't any such row, return -1. + /// + private static int FindSameTypeRow(Array range, int limitRow, int delta, int startRow, in ScalarValue lookupValue) + { + // Although the spec says that elements must be sorted in + // "ascending order", as follows: ..., -2, -1, 0, 1, 2, ..., A-Z, FALSE, TRUE. + // In reality, comparison ignores elements of the different type than lookupValue. + // E.g. search for 2.5 in the {"1", 2, "3", #DIV/0!, 3 } will find the second element 2 + // Elements with incompatible type are just skipped. + int currentRow; + for (currentRow = startRow; !lookupValue.HaveSameType(range[currentRow, 0]); currentRow += delta) + { + // Don't move beyond limitRow + if (currentRow == limitRow) + return -1; + } + + return currentRow; + } + + private static AnyValue RowsOrColumns(AnyValue value, bool rows) + { + if (value.TryPickArea(out var area, out _)) + return rows ? area.RowSpan : area.ColumnSpan; + + if (value.TryPickArray(out var array)) + return rows ? array.Height : array.Width; + + if (value.TryPickError(out var error)) + return error; + + if (value.IsLogical || value.IsNumber || value.IsText) + return 1; - if (matching_row == null) - throw new NoValueAvailableException("No matches found."); + if (value.IsBlank) + return XLError.IncompatibleValue; - return matching_row - .Cell(col_index_num) - .Value; + // Only thing left, if reference has multiple areas + return XLError.CellReference; } } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/MathTrig.cs b/ClosedXML/Excel/CalcEngine/Functions/MathTrig.cs index 5ebd543f0..ed21a1c58 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/MathTrig.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/MathTrig.cs @@ -1,10 +1,8 @@ // Keep this file CodeMaid organised and cleaned -using ClosedXML.Excel.CalcEngine.Exceptions; using ClosedXML.Excel.CalcEngine.Functions; using System; -using System.Collections; using System.Collections.Generic; -using System.Globalization; +using System.Diagnostics; using System.Linq; using System.Text; using static ClosedXML.Excel.CalcEngine.Functions.SignatureAdapter; @@ -13,86 +11,110 @@ namespace ClosedXML.Excel.CalcEngine { internal static class MathTrig { + /// + /// Maximum integer number that can be precisely represented in a double. + /// Calculated as Math.Pow(2, 53) - 1, but use literal to make it + /// constant (=usable in pattern matching). + /// + private const double MaxDoubleInt = 9007199254740991; + private static readonly Random _rnd = new Random(); + /// + /// Key: roman form. Value: A collection of subtract symbols and subtract value. + /// Collection is sorted by subtract value in descending order. + /// + private static readonly Lazy>> RomanForms = new(BuildRomanForms); + + private static readonly IReadOnlyDictionary RomanSymbolValues = new Dictionary + { + {'I', 1}, + {'V', 5}, + {'X', 10}, + {'L', 50}, + {'C', 100}, + {'D', 500}, + {'M', 1000} + }; + #region Register public static void Register(FunctionRegistry ce) { ce.RegisterFunction("ABS", 1, 1, Adapt(Abs), FunctionFlags.Scalar); - ce.RegisterFunction("ACOS", 1, Acos); - ce.RegisterFunction("ACOSH", 1, Acosh); - ce.RegisterFunction("ACOT", 1, Acot); - ce.RegisterFunction("ACOTH", 1, Acoth); - ce.RegisterFunction("ARABIC", 1, Arabic); - ce.RegisterFunction("ASIN", 1, Asin); - ce.RegisterFunction("ASINH", 1, Asinh); - ce.RegisterFunction("ATAN", 1, Atan); - ce.RegisterFunction("ATAN2", 2, Atan2); - ce.RegisterFunction("ATANH", 1, Atanh); - ce.RegisterFunction("BASE", 2, 3, Base); - ce.RegisterFunction("CEILING", 2, Ceiling); - ce.RegisterFunction("CEILING.MATH", 1, 3, CeilingMath); - ce.RegisterFunction("COMBIN", 2, Combin); - ce.RegisterFunction("COMBINA", 2, CombinA); - ce.RegisterFunction("COS", 1, Cos); - ce.RegisterFunction("COSH", 1, Cosh); - ce.RegisterFunction("COT", 1, Cot); - ce.RegisterFunction("COTH", 1, Coth); - ce.RegisterFunction("CSC", 1, Csc); - ce.RegisterFunction("CSCH", 1, Csch); - ce.RegisterFunction("DECIMAL", 2, MathTrig.Decimal); - ce.RegisterFunction("DEGREES", 1, Degrees); - ce.RegisterFunction("EVEN", 1, Even); - ce.RegisterFunction("EXP", 1, Exp); - ce.RegisterFunction("FACT", 1, Fact); - ce.RegisterFunction("FACTDOUBLE", 1, FactDouble); - ce.RegisterFunction("FLOOR", 2, Floor); - ce.RegisterFunction("FLOOR.MATH", 1, 3, FloorMath); - ce.RegisterFunction("GCD", 1, 255, Gcd); - ce.RegisterFunction("INT", 1, Int); - ce.RegisterFunction("LCM", 1, 255, Lcm); - ce.RegisterFunction("LN", 1, Ln); - ce.RegisterFunction("LOG", 1, 2, Log); - ce.RegisterFunction("LOG10", 1, Log10); - ce.RegisterFunction("MDETERM", 1, MDeterm, AllowRange.All); - ce.RegisterFunction("MINVERSE", 1, MInverse, AllowRange.All); - ce.RegisterFunction("MMULT", 2, MMult, AllowRange.All); - ce.RegisterFunction("MOD", 2, Mod); - ce.RegisterFunction("MROUND", 2, MRound); - ce.RegisterFunction("MULTINOMIAL", 1, 255, Multinomial); - ce.RegisterFunction("ODD", 1, Odd); - ce.RegisterFunction("PI", 0, Pi); - ce.RegisterFunction("POWER", 2, Power); - ce.RegisterFunction("PRODUCT", 1, 255, Product); - ce.RegisterFunction("QUOTIENT", 2, Quotient); - ce.RegisterFunction("RADIANS", 1, Radians); - ce.RegisterFunction("RAND", 0, Rand); - ce.RegisterFunction("RANDBETWEEN", 2, RandBetween); - ce.RegisterFunction("ROMAN", 1, 2, Roman); - ce.RegisterFunction("ROUND", 2, Round); - ce.RegisterFunction("ROUNDDOWN", 2, RoundDown); - ce.RegisterFunction("ROUNDUP", 1, 2, RoundUp); - ce.RegisterFunction("SEC", 1, Sec); - ce.RegisterFunction("SECH", 1, Sech); - ce.RegisterFunction("SERIESSUM", 4, SeriesSum, AllowRange.Only, 3); - ce.RegisterFunction("SIGN", 1, Sign); - ce.RegisterFunction("SIN", 1, Sin); - ce.RegisterFunction("SINH", 1, Sinh); - ce.RegisterFunction("SQRT", 1, Sqrt); - ce.RegisterFunction("SQRTPI", 1, SqrtPi); + ce.RegisterFunction("ACOS", 1, 1, Adapt(Acos), FunctionFlags.Scalar); + ce.RegisterFunction("ACOSH", 1, 1, Adapt(Acosh), FunctionFlags.Scalar); + ce.RegisterFunction("ACOT", 1, 1, Adapt(Acot), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("ACOTH", 1, 1, Adapt(Acoth), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("ARABIC", 1, 1, Adapt(Arabic), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("ASIN", 1, 1, Adapt(Asin), FunctionFlags.Scalar); + ce.RegisterFunction("ASINH", 1, 1, Adapt(Asinh), FunctionFlags.Scalar); + ce.RegisterFunction("ATAN", 1, 1, Adapt(Atan), FunctionFlags.Scalar); + ce.RegisterFunction("ATAN2", 2, 2, Adapt(Atan2), FunctionFlags.Scalar); + ce.RegisterFunction("ATANH", 1, 1, Adapt(Atanh), FunctionFlags.Scalar); + ce.RegisterFunction("BASE", 2, 3, AdaptLastOptional(Base, 1), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("CEILING", 2, 2, Adapt(Ceiling), FunctionFlags.Scalar); + ce.RegisterFunction("CEILING.MATH", 1, 3, AdaptLastTwoOptional(CeilingMath, 1, 0), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("COMBIN", 2, 2, Adapt(Combin), FunctionFlags.Scalar); + ce.RegisterFunction("COMBINA", 2, 2, Adapt(CombinA), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("COS", 1, 1, Adapt(Cos), FunctionFlags.Scalar); + ce.RegisterFunction("COSH", 1, 1, Adapt(Cosh), FunctionFlags.Scalar); + ce.RegisterFunction("COT", 1, 1, Adapt(Cot), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("COTH", 1, 1, Adapt(Coth), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("CSC", 1, 1, Adapt(Csc), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("CSCH", 1, 1, Adapt(Csch), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("DECIMAL", 2, 2, Adapt(Decimal), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("DEGREES", 1, 1, Adapt(Degrees), FunctionFlags.Scalar); + ce.RegisterFunction("EVEN", 1, 1, Adapt(Even), FunctionFlags.Scalar); + ce.RegisterFunction("EXP", 1, 1, Adapt(Exp), FunctionFlags.Scalar); + ce.RegisterFunction("FACT", 1, 1, Adapt(Fact), FunctionFlags.Scalar); + ce.RegisterFunction("FACTDOUBLE", 1, 1, Adapt(FactDouble), FunctionFlags.Scalar); + ce.RegisterFunction("FLOOR", 2, 2, Adapt(Floor), FunctionFlags.Scalar); + ce.RegisterFunction("FLOOR.MATH", 1, 3, AdaptLastTwoOptional(FloorMath, 1, 0), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("GCD", 1, 255, Adapt(Gcd), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("INT", 1, 1, Adapt(Int), FunctionFlags.Scalar); + ce.RegisterFunction("LCM", 1, 255, Adapt(Lcm), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("LN", 1, 1, Adapt(Ln), FunctionFlags.Scalar); + ce.RegisterFunction("LOG", 1, 2, AdaptLastOptional(Log, 10), FunctionFlags.Scalar); + ce.RegisterFunction("LOG10", 1, 1, Adapt(Log10), FunctionFlags.Scalar); + ce.RegisterFunction("MDETERM", 1, 1, Adapt(MDeterm), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("MINVERSE", 1, 1, Adapt(MInverse), FunctionFlags.Range | FunctionFlags.ReturnsArray, AllowRange.All); + ce.RegisterFunction("MMULT", 2, 2, MMult, FunctionFlags.Range | FunctionFlags.ReturnsArray, AllowRange.All); + ce.RegisterFunction("MOD", 2, 2, Adapt(Mod), FunctionFlags.Scalar); + ce.RegisterFunction("MROUND", 2, 2, Adapt(MRound), FunctionFlags.Scalar); + ce.RegisterFunction("MULTINOMIAL", 1, 255, AdaptMultinomial(Multinomial), FunctionFlags.Scalar, AllowRange.All); + ce.RegisterFunction("ODD", 1, 1, Adapt(Odd), FunctionFlags.Scalar); + ce.RegisterFunction("PI", 0, 0, Adapt(Pi), FunctionFlags.Scalar); + ce.RegisterFunction("POWER", 2, 2, Adapt(Power), FunctionFlags.Scalar); + ce.RegisterFunction("PRODUCT", 1, 255, Product, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("QUOTIENT", 2, 2, Adapt(Quotient), FunctionFlags.Scalar); + ce.RegisterFunction("RADIANS", 1, 1, Adapt(Radians), FunctionFlags.Scalar); + ce.RegisterFunction("RAND", 0, 0, Adapt(Rand), FunctionFlags.Scalar | FunctionFlags.Volatile); + ce.RegisterFunction("RANDBETWEEN", 2, 2, Adapt(RandBetween), FunctionFlags.Scalar | FunctionFlags.Volatile); + ce.RegisterFunction("ROMAN", 1, 2, AdaptLastOptional(Roman, 0), FunctionFlags.Scalar); + ce.RegisterFunction("ROUND", 2, 2, Adapt(Round), FunctionFlags.Scalar); + ce.RegisterFunction("ROUNDDOWN", 2, 2, Adapt(RoundDown), FunctionFlags.Scalar); + ce.RegisterFunction("ROUNDUP", 2, 2, Adapt(RoundUp), FunctionFlags.Scalar); + ce.RegisterFunction("SEC", 1, 1, Adapt(Sec), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("SECH", 1, 1, Adapt(Sech), FunctionFlags.Scalar | FunctionFlags.Future); + ce.RegisterFunction("SERIESSUM", 4, 4, AdaptSeriesSum(SeriesSum), FunctionFlags.Range, AllowRange.Only, 3); + ce.RegisterFunction("SIGN", 1, 1, Adapt(Sign), FunctionFlags.Scalar); + ce.RegisterFunction("SIN", 1, 1, Adapt(Sin), FunctionFlags.Scalar); + ce.RegisterFunction("SINH", 1, 1, Adapt(Sinh), FunctionFlags.Scalar); + ce.RegisterFunction("SQRT", 1, 1, Adapt(Sqrt), FunctionFlags.Scalar); + ce.RegisterFunction("SQRTPI", 1, 1, Adapt(SqrtPi), FunctionFlags.Scalar); ce.RegisterFunction("SUBTOTAL", 2, 255, Adapt(Subtotal), FunctionFlags.Range, AllowRange.Except, 0); ce.RegisterFunction("SUM", 1, int.MaxValue, Sum, FunctionFlags.Range, AllowRange.All); - ce.RegisterFunction("SUMIF", 2, 3, SumIf, AllowRange.Only, 0, 2); - ce.RegisterFunction("SUMIFS", 3, 255, SumIfs, AllowRange.Only, new[] { 0 }.Concat(Enumerable.Range(0, 128).Select(x => x * 2 + 1)).ToArray()); - ce.RegisterFunction("SUMPRODUCT", 1, 30, SumProduct, AllowRange.All); - ce.RegisterFunction("SUMSQ", 1, 255, SumSq); + ce.RegisterFunction("SUMIF", 2, 3, AdaptLastOptional(SumIf), FunctionFlags.Range, AllowRange.Only, 0, 2); + ce.RegisterFunction("SUMIFS", 3, 255, AdaptIfs(SumIfs), FunctionFlags.Range, AllowRange.Only, new[] { 0 }.Concat(Enumerable.Range(0, 128).Select(x => x * 2 + 1)).ToArray()); + ce.RegisterFunction("SUMPRODUCT", 1, 30, Adapt(SumProduct), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("SUMSQ", 1, 255, SumSq, FunctionFlags.Range, AllowRange.All); //ce.RegisterFunction("SUMX2MY2", SumX2MY2, 1); //ce.RegisterFunction("SUMX2PY2", SumX2PY2, 1); //ce.RegisterFunction("SUMXMY2", SumXMY2, 1); - ce.RegisterFunction("TAN", 1, Tan); - ce.RegisterFunction("TANH", 1, Tanh); - ce.RegisterFunction("TRUNC", 1, 2, Trunc); + ce.RegisterFunction("TAN", 1, 1, Adapt(Tan), FunctionFlags.Scalar); + ce.RegisterFunction("TANH", 1, 1, Adapt(Tanh), FunctionFlags.Scalar); + ce.RegisterFunction("TRUNC", 1, 2, AdaptLastOptional(Trunc, 0), FunctionFlags.Scalar); } #endregion Register @@ -132,969 +154,1051 @@ public static double RadiansToGrads(double radians) return radians / Math.PI * 200.0; } - private static AnyValue Abs(double number) + private static ScalarValue Abs(double number) { return Math.Abs(number); } - private static object Acos(List p) + private static ScalarValue Acos(double number) { - double input = p[0]; - if (Math.Abs(input) > 1) - throw new NumberException(); + if (Math.Abs(number) > 1) + return XLError.NumberInvalid; - return Math.Acos(p[0]); + return Math.Acos(number); } - private static object Acosh(List p) + private static ScalarValue Acosh(double number) { - double number = p[0]; if (number < 1) - throw new NumberException(); + return XLError.NumberInvalid; - return XLMath.ACosh(p[0]); + return XLMath.ACosh(number); } - private static object Acot(List p) + private static ScalarValue Acot(double angle) { - double x = Math.Atan(1.0 / p[0]); + if (angle == 0) + return Math.PI / 2; + + var acot = Math.Atan(1.0 / angle); // Acot in Excel calculates the modulus of the function above. // as the % operator is not the modulus, but the remainder, we have to calculate the modulus by hand: - while (x < 0) - x += Math.PI; + while (acot < 0) + acot += Math.PI; - return x; + return acot; } - private static object Acoth(List p) + private static ScalarValue Acoth(double angle) { - double number = p[0]; - if (Math.Abs(number) < 1) - throw new NumberException(); + if (Math.Abs(angle) < 1) + return XLError.NumberInvalid; - return 0.5 * Math.Log((number + 1) / (number - 1)); + return 0.5 * Math.Log((angle + 1) / (angle - 1)); } - private static object Arabic(List p) + private static ScalarValue Arabic(CalcContext ctx, string input) { - string input = ((string)p[0]).Trim(); + if (input.Length > 255) + return XLError.IncompatibleValue; - try - { - if (input.Length == 0) - return 0; - if (input == "-") - throw new NumberException(); - else if (input[0] == '-') - return -XLMath.RomanToArabic(input.Substring(1)); - else - return XLMath.RomanToArabic(input); - } - catch (ArgumentOutOfRangeException) + // Check minus sign + var text = input.AsSpan().Trim(); + var minusSign = text.Length > 0 && text[0] == '-'; + if (minusSign) + text = text[1..]; + + var total = 0; + for (var i = text.Length - 1; i >= 0; --i) { - throw new CellValueException(); + var addSymbol = char.ToUpperInvariant(text[i]); + if (!RomanSymbolValues.TryGetValue(addSymbol, out var addValue)) + return XLError.IncompatibleValue; + + total += addValue; + + // Standard roman numbers allow only one subtract symbol, Excel allows many + // subtract symbols of different types. + while (i > 0) + { + var subtractSymbol = char.ToUpperInvariant(text[i - 1]); + if (!RomanSymbolValues.TryGetValue(subtractSymbol, out var subtractValue)) + return XLError.IncompatibleValue; + + if (subtractValue >= addValue) + break; + + total -= subtractValue; + --i; + } } + + if (minusSign && total == 0) + return XLError.NumberInvalid; + + return minusSign ? -total : total; } - private static object Asin(List p) + private static ScalarValue Asin(double number) { - double input = p[0]; - if (Math.Abs(input) > 1) - throw new NumberException(); + if (Math.Abs(number) > 1) + return XLError.NumberInvalid; - return Math.Asin(input); + return Math.Asin(number); } - private static object Asinh(List p) + private static ScalarValue Asinh(double number) { - return XLMath.ASinh(p[0]); + return XLMath.Asinh(number); } - private static object Atan(List p) + private static ScalarValue Atan(double number) { - return Math.Atan(p[0]); + return Math.Atan(number); } - private static object Atan2(List p) + private static ScalarValue Atan2(CalcContext ctx, double x, double y) { - double x = p[0]; - double y = p[1]; if (x == 0 && y == 0) - throw new DivisionByZeroException(); + return XLError.DivisionByZero; return Math.Atan2(y, x); } - private static object Atanh(List p) + private static ScalarValue Atanh(double number) { - double input = p[0]; - if (Math.Abs(input) >= 1) - throw new NumberException(); + if (Math.Abs(number) >= 1) + return XLError.NumberInvalid; - return XLMath.ATanh(p[0]); + return XLMath.ATanh(number); } - private static object Base(List p) + private static ScalarValue Base(double number, double radix, double minLength) { - long number; - int radix; - int minLength = 0; - - var rawNumber = p[0].Evaluate(); - if (rawNumber is long || rawNumber is int || rawNumber is byte || rawNumber is double || rawNumber is float) - number = Convert.ToInt64(rawNumber); - else - throw new CellValueException(); + number = Math.Truncate(number); + radix = Math.Truncate(radix); + minLength = Math.Truncate(minLength); + if (number is < 0 or > MaxDoubleInt || radix is < 2 or > 36 || minLength is < 0 or > 255) + return XLError.NumberInvalid; - var rawRadix = p[1].Evaluate(); - if (rawRadix is long || rawRadix is int || rawRadix is byte || rawRadix is double || rawRadix is float) - radix = Convert.ToInt32(rawRadix); - else - throw new CellValueException(); - - if (p.Count > 2) + var sb = new StringBuilder(); + while (number > 0) { - var rawMinLength = p[2].Evaluate(); - if (rawMinLength is long || rawMinLength is int || rawMinLength is byte || rawMinLength is double || rawMinLength is float) - minLength = Convert.ToInt32(rawMinLength); - else - throw new CellValueException(); - } + var digit = (int)(number % radix); + number = Math.Floor(number / radix); - if (number < 0 || radix < 2 || radix > 36) - throw new NumberException(); + var digitChar = digit < 10 + ? (char)(digit + '0') + : (char)(digit - 10 + 'A'); + sb.Insert(0, digitChar); + } - return XLMath.ChangeBase(number, radix).PadLeft(minLength, '0'); + return sb.ToString().PadLeft((int)minLength, '0'); } - private static object Ceiling(List p) + private static ScalarValue Ceiling(CalcContext ctx, double number, double significance) { - double number = p[0]; - double significance = p[1]; - if (significance == 0) - return 0d; - else if (significance < 0 && number > 0) - throw new NumberException(); - else if (significance < 0) - return -Math.Ceiling(-number / -significance) * -significance; - else - return Math.Ceiling(number / significance) * significance; - } + return 0; - private static object CeilingMath(List p) - { - double number = p[0]; - double significance = 1; - if (p.Count > 1) significance = p[1]; + if (significance < 0 && number > 0) + return XLError.NumberInvalid; - double mode = 0; - if (p.Count > 2) mode = p[2]; + if (number < 0) + return -Math.Ceiling(-number / -significance) * -significance; - if (significance == 0) - return 0d; - else if (number >= 0) - return Math.Ceiling(number / Math.Abs(significance)) * Math.Abs(significance); - else if (mode == 0) - return Math.Ceiling(number / Math.Abs(significance)) * Math.Abs(significance); - else - return -Math.Ceiling(-number / Math.Abs(significance)) * Math.Abs(significance); + return Math.Ceiling(number / significance) * significance; } - private static object Combin(List p) + private static ScalarValue CeilingMath(double number, double significance, double mode) { - Int32 n; - Int32 k; + if (significance == 0) + return 0; - var rawN = p[0].Evaluate(); - var rawK = p[1].Evaluate(); - if (rawN is long || rawN is int || rawN is byte || rawN is double || rawN is float) - n = (int)Math.Floor((double)rawN); - else - throw new NumberException(); + significance = Math.Abs(significance); - if (rawK is long || rawK is int || rawK is byte || rawK is double || rawK is float) - k = (int)Math.Floor((double)rawK); - else - throw new NumberException(); + // Mode 1 very similar to behavior of CEILING function, i.e. ceil + // away from zero even for negative numbers. Mode 1 is not the same + // as CEILING, e.g. CEILING(-5.5, 2.1) vs CEILING.MATH(-5.5, 2.1, 1)). + if (number < 0 && mode != 0) + return Math.Floor(number / significance) * significance; - n = (int)p[0]; - k = (int)p[1]; + return Math.Ceiling(number / significance) * significance; + } - if (n < 0 || n < k || k < 0) - throw new NumberException(); + private static ScalarValue Combin(CalcContext ctx, double number, double numberChosen) + { + var combinationsResult = XLMath.CombinChecked(number, numberChosen); + if (!combinationsResult.TryPickT0(out var combinations, out var error)) + return error; - return XLMath.Combin(n, k); + return combinations; } - private static object CombinA(List p) + private static ScalarValue CombinA(CalcContext ctx, double number, double chosen) { - Int32 number = (int)p[0]; // casting truncates towards 0 as specified - Int32 chosen = (int)p[1]; + number = Math.Truncate(number); // casting truncates towards 0 as specified + chosen = Math.Truncate(chosen); + + if (number < 0) + return XLError.NumberInvalid; - if (number < 0 || number < chosen) - throw new NumberException(); if (chosen < 0) - throw new NumberException(); + return XLError.NumberInvalid; - int n = number + chosen - 1; - int k = number - 1; + var n = number + chosen - 1; + if (n > int.MaxValue) + return XLError.NumberInvalid; - return n == k || k == 0 + var k = number - 1; + return chosen == 0 || k == 0 ? 1 - : (long)XLMath.Combin(n, k); + : XLMath.Combin(n, k); } - private static object Cos(List p) + private static ScalarValue Cos(double number) { - return Math.Cos(p[0]); + return Math.Cos(number); } - private static object Cosh(List p) + private static ScalarValue Cosh(double number) { - return Math.Cosh(p[0]); + var cosh = Math.Cosh(number); + if (double.IsInfinity(cosh)) + return XLError.NumberInvalid; + + return cosh; } - private static object Cot(List p) + private static ScalarValue Cot(double angle) { - var tan = Math.Tan(p[0]); - + var tan = Math.Tan(angle); if (tan == 0) - throw new DivisionByZeroException(); + return XLError.DivisionByZero; return 1 / tan; } - private static object Coth(List p) + private static ScalarValue Coth(double angle) { - double input = p[0]; - if (input == 0) - throw new DivisionByZeroException(); + if (angle == 0) + return XLError.DivisionByZero; - return 1 / Math.Tanh(input); + return 1 / Math.Tanh(angle); } - private static object Csc(List p) + private static ScalarValue Csc(double angle) { - double input = p[0]; - if (input == 0) - throw new DivisionByZeroException(); + if (angle == 0) + return XLError.DivisionByZero; - return 1 / Math.Sin(input); + return 1 / Math.Sin(angle); } - private static object Csch(List p) + private static ScalarValue Csch(double angle) { - if (Math.Abs((double)p[0].Evaluate()) < Double.Epsilon) - throw new DivisionByZeroException(); + if (angle == 0) + return XLError.DivisionByZero; - return 1 / Math.Sinh(p[0]); + return 1 / Math.Sinh(angle); } - private static object Decimal(List p) + private static ScalarValue Decimal(string text, double radix) { - string source = p[0]; - double radix = p[1]; - - if (radix < 2 || radix > 36) - throw new NumberException(); - - var asciiValues = Encoding.ASCII.GetBytes(source.ToUpperInvariant()); + radix = Math.Truncate(radix); + if (radix is < 2 or > 36) + return XLError.NumberInvalid; - double result = 0; - int i = 0; + if (text.Length > 255) + return XLError.IncompatibleValue; - foreach (byte digit in asciiValues) + var result = 0d; + foreach (var digit in text.AsSpan().TrimStart()) { - if (digit > 90) + var digitNumber = digit switch { - throw new NumberException(); - } - - int digitNumber = digit >= 48 && digit < 58 - ? digit - 48 - : digit - 55; + >= '0' and <= '9' => digit - '0', + >= 'A' and <= 'Z' => digit - 'A' + 10, + >= 'a' and <= 'z' => digit - 'a' + 10, + _ => int.MaxValue, + }; if (digitNumber > radix - 1) - throw new NumberException(); + return XLError.NumberInvalid; result = result * radix + digitNumber; - i++; + + if (double.IsInfinity(result)) + return XLError.NumberInvalid; } return result; } - private static object Degrees(List p) + private static ScalarValue Degrees(double number) { - return p[0] * (180.0 / Math.PI); + return number * (180.0 / Math.PI); } - private static object Even(List p) + private static ScalarValue Even(double number) { - var num = (int)Math.Ceiling(p[0]); + var num = Math.Ceiling(number); var addValue = num >= 0 ? 1 : -1; return XLMath.IsEven(num) ? num : num + addValue; } - private static object Exp(List p) + private static ScalarValue Exp(double number) { - return Math.Exp(p[0]); + var exp = Math.Exp(number); + if (double.IsInfinity(exp)) + return XLError.NumberInvalid; + + return exp; } - private static object Fact(List p) + private static ScalarValue Fact(double n) { - var input = p[0].Evaluate(); + if (n is < 0 or >= 171) + return XLError.NumberInvalid; - if (!(input is long || input is int || input is byte || input is double || input is float)) - throw new CellValueException(); + return XLMath.Factorial((int)Math.Floor(n)); + } - var num = Math.Floor((double)input); - double fact = 1.0; + private static ScalarValue FactDouble(double n) + { + var num = Math.Floor(n); + if (num < -1) + return XLError.NumberInvalid; - if (num < 0) - throw new NumberException(); + var fact = 1.0; if (num > 1) { - for (int i = 2; i <= num; i++) + var start = XLMath.IsEven(num) ? 2 : 1; + for (var i = start; i <= num; i += 2) { fact *= i; + if (double.IsInfinity(fact)) + return XLError.NumberInvalid; } } return fact; } - private static object FactDouble(List p) + private static ScalarValue Floor(CalcContext ctx, double number, double significance) { - var input = p[0].Evaluate(); + // Rounding down, to zero. If we are at the zero, there is nowhere to go. + if (number == 0) + return 0; - if (!(input is long || input is int || input is byte || input is double || input is float)) - throw new CellValueException(); + if (number > 0 && significance < 0) + return XLError.NumberInvalid; - var num = Math.Floor(p[0]); - double fact = 1.0; + if (significance == 0) + return XLError.DivisionByZero; - if (num < -1) - throw new NumberException(); + if (significance < 0) + return -Math.Floor(-number / -significance) * -significance; - if (num > 1) - { - var start = Math.Abs(num % 2) < XLHelper.Epsilon ? 2 : 1; - for (int i = start; i <= num; i += 2) - fact *= i; - } - return fact; + return Math.Floor(number / significance) * significance; } - private static object Floor(List p) + private static ScalarValue FloorMath(double number, double significance, double mode) { - double number = p[0]; - double significance = p[1]; - if (significance == 0) - throw new DivisionByZeroException(); - else if (significance < 0 && number > 0) - throw new NumberException(); - else if (significance < 0) - return -Math.Floor(-number / -significance) * -significance; - else + return 0d; + + significance = Math.Abs(significance); + if (number >= 0) + return Math.Floor(number / significance) * significance; + + // Mode 0 floors numbers to lower number. + if (mode == 0) return Math.Floor(number / significance) * significance; + + // Mode !0 truncates negative number, i.e. closer to zero + return Math.Truncate(number / significance) * significance; } - private static object FloorMath(List p) + private static ScalarValue Gcd(CalcContext ctx, List arrays) { - double number = p[0]; - double significance = 1; - if (p.Count > 1) significance = p[1]; + var result = 0d; + foreach (var array in arrays) + { + foreach (var scalar in array) + { + ctx.ThrowIfCancelled(); + if (scalar.IsLogical) + return XLError.IncompatibleValue; - double mode = 0; - if (p.Count > 2) mode = p[2]; + if (!scalar.ToNumber(ctx.Culture).TryPickT0(out var number, out var error)) + return error; - if (significance == 0) - return 0d; - else if (number >= 0) - return Math.Floor(number / Math.Abs(significance)) * Math.Abs(significance); - else if (mode == 0) - return Math.Floor(number / Math.Abs(significance)) * Math.Abs(significance); - else - return -Math.Floor(-number / Math.Abs(significance)) * Math.Abs(significance); - } + if (number is < 0 or > MaxDoubleInt) + return XLError.NumberInvalid; - private static object Gcd(List p) - { - return p.Select(v => (int)v).Aggregate(Gcd); + result = Gcd(number, Math.Truncate(result)); + } + } + + return result; } - private static int Gcd(int a, int b) + private static double Gcd(double a, double b) { - return b == 0 ? a : Gcd(b, a % b); + a = Math.Truncate(a); + b = Math.Truncate(b); + while (b != 0) + (a, b) = (b, a % b); + + return a; } - private static double[,] GetArray(Expression expression) + private static OneOf GetArray(AnyValue value, CalcContext ctx) { - if (expression is XObjectExpression objectExpression - && objectExpression.Value is CellRangeReference cellRangeReference) - { - var range = cellRangeReference.Range; - var rowCount = range.RowCount(); - var columnCount = range.ColumnCount(); - var arr = new double[rowCount, columnCount]; + if (value.TryPickSingleOrMultiValue(out var scalar, out var array, ctx)) + array = new ScalarArray(scalar, 1, 1); - for (int row = 0; row < rowCount; row++) - { - for (int column = 0; column < columnCount; column++) - { - arr[row, column] = range.Cell(row + 1, column + 1).GetDouble(); - } - } + var rows = array.Height; + var cols = array.Width; + var arr = new double[rows, cols]; - return arr; - } - else + for (var row = 0; row < rows; row++) { - return new[,] { { (double)expression } }; + for (var col = 0; col < cols; col++) + { + if (!array[row, col].TryPickNumber(out var number, out var error)) + return error; + + arr[row, col] = number; + } } + + return arr; } - private static object Int(List p) + private static ScalarValue Int(double number) { - return Math.Floor(p[0]); + return Math.Floor(number); } - private static object Lcm(List p) + private static ScalarValue Lcm(CalcContext ctx, List arrays) { - return p.Select(v => (int)v).Aggregate(Lcm); + var result = 1d; + foreach (var array in arrays) + { + foreach (var scalar in array) + { + ctx.ThrowIfCancelled(); + if (scalar.IsLogical) + return XLError.IncompatibleValue; + + if (!scalar.ToNumber(ctx.Culture).TryPickT0(out var number, out var error)) + return error; + + if (number is < 0 or > MaxDoubleInt) + return XLError.NumberInvalid; + + result = Lcm(result, Math.Truncate(number)); + } + } + + return result; } - private static int Lcm(int a, int b) + private static double Lcm(double a, double b) { if (a == 0 || b == 0) return 0; return a * (b / Gcd(a, b)); } - private static object Ln(List p) + private static ScalarValue Ln(double x) { - return Math.Log(p[0]); + if (x <= 0) + return XLError.NumberInvalid; + + return Math.Log(x); } - private static object Log(List p) + private static ScalarValue Log(CalcContext ctx, double x, double @base) { - var lbase = p.Count > 1 ? (double)p[1] : 10; - return Math.Log(p[0], lbase); + if (x <= 0 || @base <= 0) + return XLError.NumberInvalid; + + if (Math.Abs(@base - 1.0) < XLHelper.Epsilon) + return XLError.DivisionByZero; + + return Math.Log(x, @base); } - private static object Log10(List p) + private static ScalarValue Log10(double x) { - return Math.Log10(p[0]); + if (x <= 0) + return XLError.NumberInvalid; + + return Math.Log10(x); } - private static object MDeterm(List p) + private static AnyValue MDeterm(CalcContext ctx, AnyValue value) { - var arr = GetArray(p[0]); - var m = new XLMatrix(arr); + if (!GetArray(value, ctx).TryPickT0(out var array, out var error)) + return error; - return m.Determinant(); + var isSquare = array.GetLength(0) == array.GetLength(1); + if (!isSquare) + return XLError.IncompatibleValue; + + var matrix = new XLMatrix(array); + return matrix.Determinant(); } - private static object MInverse(List p) + private static AnyValue MInverse(CalcContext ctx, AnyValue value) { - var arr = GetArray(p[0]); - var m = new XLMatrix(arr); + if (!GetArray(value, ctx).TryPickT0(out var array, out var error)) + return error; + + var isSquare = array.GetLength(0) == array.GetLength(1); + if (!isSquare) + return XLError.IncompatibleValue; - return m.Invert().mat; + var matrix = new XLMatrix(array); + var inverse = matrix.Invert(); + if (inverse.IsSingular()) + return XLError.NumberInvalid; + + return new NumberArray(inverse.mat); } - private static object MMult(List p) + private static AnyValue MMult(CalcContext ctx, Span args) { - Double[,] A, B; + if (!GetArray(args[0], ctx).TryPickT0(out var matrixA, out var errorA)) + return errorA; - try - { - A = GetArray(p[0]); - B = GetArray(p[1]); - } - catch (FormatException e) - { - throw new CellValueException("Cells are empty or contain text.", e); - } + if (!GetArray(args[1], ctx).TryPickT0(out var matrixB, out var errorB)) + return errorB; - if (A.GetLength(1) != B.GetLength(0)) - throw new CellValueException("The number of columns in array1 is different from the number of rows in array2."); + if (matrixA.GetLength(1) != matrixB.GetLength(0)) + return XLError.IncompatibleValue; - var C = new double[A.GetLength(0), B.GetLength(1)]; - for (int i = 0; i < A.GetLength(0); i++) + var matrixC = new double[matrixA.GetLength(0), matrixB.GetLength(1)]; + for (var i = 0; i < matrixA.GetLength(0); i++) { - for (int j = 0; j < B.GetLength(1); j++) + for (var j = 0; j < matrixB.GetLength(1); j++) { - for (int k = 0; k < A.GetLength(1); k++) + for (var k = 0; k < matrixA.GetLength(1); k++) { - C[i, j] += A[i, k] * B[k, j]; + matrixC[i, j] += matrixA[i, k] * matrixB[k, j]; } } } - return C; + return new NumberArray(matrixC); } - private static object Mod(List p) + private static ScalarValue Mod(CalcContext ctx, double number, double divisor) { - double number = p[0]; - double divisor = p[1]; + if (divisor == 0) + return XLError.DivisionByZero; return number - Math.Floor(number / divisor) * divisor; } - private static object MRound(List p) + private static ScalarValue MRound(CalcContext ctx, double number, double multiple) { - var number = (Double)p[0]; - var multiple = (Double)p[1]; + if (multiple == 0) + return 0; if (Math.Sign(number) != Math.Sign(multiple)) - throw new NumberException("The Number and Multiple arguments must have the same sign."); + return XLError.NumberInvalid; return Math.Round(number / multiple, MidpointRounding.AwayFromZero) * multiple; } - private static object Multinomial(List p) + private static ScalarValue Multinomial(CalcContext ctx, List> numberCollections) { - return Multinomial(p.ConvertAll(v => (double)v)); - } - - private static double Multinomial(List numbers) - { - double numbersSum = 0; - foreach (var number in numbers) - { - numbersSum += number; - } - - double maxNumber = numbers.Max(); - var denomFactorPowers = new double[(uint)numbers.Max() + 1]; - foreach (var number in numbers) + var numbersSum = 0.0; + var denominator = 1.0; + foreach (var numberCollection in numberCollections) { - for (int i = 2; i <= number; i++) + foreach (var scalar in numberCollection) { - denomFactorPowers[i]++; - } - } + ctx.ThrowIfCancelled(); + if (scalar.IsLogical) + return XLError.IncompatibleValue; - for (int i = 2; i < denomFactorPowers.Length; i++) - { - denomFactorPowers[i]--; // reduce with nominator - } + if (!scalar.ToNumber(ctx.Culture).TryPickT0(out var number, out var error)) + return error; - int currentFactor = 2; - double currentPower = 1; - double result = 1; - for (double i = maxNumber + 1; i <= numbersSum; i++) - { - double tempDenom = 1; - while (tempDenom < result && currentFactor < denomFactorPowers.Length) - { - if (currentPower > denomFactorPowers[currentFactor]) - { - currentFactor++; - currentPower = 1; - } - else - { - tempDenom *= currentFactor; - currentPower++; - } + if (number < 0) + return XLError.NumberInvalid; + + number = Math.Truncate(number); + numbersSum += number; + denominator *= XLMath.Factorial(number); + if (double.IsInfinity(denominator)) + return XLError.NumberInvalid; } - result = result / tempDenom * i; } - return result; + var numerator = XLMath.Factorial(numbersSum); + if (double.IsInfinity(numerator)) + return XLError.NumberInvalid; + + return numerator / denominator; } - private static object Odd(List p) + private static ScalarValue Odd(double number) { - var num = (int)Math.Ceiling(p[0]); + var num = Math.Ceiling(number); var addValue = num >= 0 ? 1 : -1; return XLMath.IsOdd(num) ? num : num + addValue; } - private static object Pi(List p) + private static ScalarValue Pi() { return Math.PI; } - private static object Power(List p) + private static ScalarValue Power(CalcContext ctx, double x, double y) { - return Math.Pow(p[0], p[1]); + // The value of x is negative and y is not a whole number, #NUM! is returned. + var isPowerFraction = y % 1 != 0; + if (x < 0 && isPowerFraction) + return XLError.NumberInvalid; + + if (x == 0 && y == 0) + return XLError.NumberInvalid; + + if (x == 0 && y < 0) + return XLError.DivisionByZero; + + var power = Math.Pow(x, y); + if (double.IsInfinity(power) || double.IsNaN(power)) + return XLError.NumberInvalid; + + return power; } - private static object Product(List p) + private static AnyValue Product(CalcContext ctx, Span args) { - if (p.Count == 0) return 0; - Double total = 1; - p.ForEach(v => total *= v); - return total; + return Product(ctx, args, TallyNumbers.WithoutScalarBlank); } - private static object Quotient(List p) + private static AnyValue Product(CalcContext ctx, Span args, ITally tally) { - Double n = p[0]; - Double k = p[1]; + var result = tally.Tally(ctx, args, new ProductState(1, false)); + if (!result.TryPickT0(out var state, out var error)) + return error; - return (int)(n / k); + return state.HasValues ? state.Product : 0; } - private static object Radians(List p) + private static ScalarValue Quotient(CalcContext ctx, double dividend, double divisor) { - return p[0] * Math.PI / 180.0; + if (divisor == 0) + return XLError.DivisionByZero; + + return Math.Truncate(dividend / divisor); } - private static object Rand(List p) + private static ScalarValue Radians(double angle) { - return _rnd.NextDouble(); + return angle * Math.PI / 180.0; } - private static object RandBetween(List p) + private static ScalarValue Rand() { - return _rnd.Next((int)(double)p[0], (int)(double)p[1]); + return _rnd.NextDouble(); } - private static object Roman(List p) + private static ScalarValue RandBetween(CalcContext ctx, double lowerBound, double upperBound) { - if (p.Count == 1 - || (Boolean.TryParse((string)p[1], out bool boolTemp) && boolTemp) - || (Int32.TryParse((string)p[1], out int intTemp) && intTemp == 1)) - { - return XLMath.ToRoman((int)p[0]); - } + if (lowerBound > upperBound) + return XLError.NumberInvalid; - throw new ArgumentException("Can only support classic roman types."); + lowerBound = Math.Ceiling(lowerBound); + upperBound = Math.Ceiling(upperBound); + + var range = upperBound - lowerBound; + return lowerBound + Math.Round(_rnd.NextDouble() * range, MidpointRounding.AwayFromZero); } - private static object Round(List p) + private static ScalarValue Roman(CalcContext ctx, double number, double formValue) { - var value = (Double)p[0]; - var digits = (Int32)(Double)p[1]; - if (digits >= 0) - { - return Math.Round(value, digits, MidpointRounding.AwayFromZero); - } - else + if (number == 0) + return string.Empty; + + if (number is < 0 or > 3999) + return XLError.IncompatibleValue; + + var form = (int)Math.Truncate(formValue); + if (form is < 0 or > 4) + return XLError.IncompatibleValue; + + // The result can have at most 15 chars + var result = new StringBuilder(15); + var subtractValues = RomanForms.Value[form]; + foreach (var subtract in subtractValues) { - digits = Math.Abs(digits); - double temp = value / Math.Pow(10, digits); - temp = Math.Round(temp, 0, MidpointRounding.AwayFromZero); - return temp * Math.Pow(10, digits); + // While the number is larger than the current value, append the symbol + while (number >= subtract.Value) + { + result.Append(subtract.Symbol); + number -= subtract.Value; + } } + + return result.ToString(); } - private static object RoundDown(List p) + private static ScalarValue Round(CalcContext ctx, double value, double digits) { - var value = (Double)p[0]; - var digits = (Int32)(Double)p[1]; - - if (value >= 0) - return Math.Floor(value * Math.Pow(10, digits)) / Math.Pow(10, digits); - - return Math.Ceiling(value * Math.Pow(10, digits)) / Math.Pow(10, digits); + return XLMath.Round(value, digits); } - private static object RoundUp(List p) + private static ScalarValue RoundDown(CalcContext ctx, double value, double digits) { - var value = (Double)p[0]; - var digits = (Int32)(Double)p[1]; + var coef = Math.Pow(10, Math.Truncate(digits)); + return Math.Truncate(value * coef) / coef; + } + private static ScalarValue RoundUp(CalcContext ctx, double value, double digits) + { + var coef = Math.Pow(10, Math.Truncate(digits)); if (value >= 0) - return Math.Ceiling(value * Math.Pow(10, digits)) / Math.Pow(10, digits); + return Math.Ceiling(value * coef) / coef; - return Math.Floor(value * Math.Pow(10, digits)) / Math.Pow(10, digits); + return Math.Floor(value * coef) / coef; } - private static object Sec(List p) + private static ScalarValue Sec(double angle) { - if (double.TryParse(p[0], out double number)) - return 1.0 / Math.Cos(number); - else - throw new CellValueException(); + // Cos is actually never 0, because PI/2 can't be represented + // as a double. It's just a really small number and the result + // is thus never infinity. + return 1.0 / Math.Cos(angle); } - private static object Sech(List p) + private static ScalarValue Sech(double angle) { - return 1.0 / Math.Cosh(p[0]); + return 1.0 / Math.Cosh(angle); } - private static object SeriesSum(List p) + private static ScalarValue SeriesSum(CalcContext ctx, double input, double initial, double step, Array coefficients) { - var x = (Double)p[0]; - var n = (Double)p[1]; - var m = (Double)p[2]; - if (p[3] is XObjectExpression obj) + var total = 0d; + var i = 0; + foreach (var coefScalar in coefficients) { - Double total = 0; - Int32 i = 0; - foreach (var e in obj) - { - total += (double)e * Math.Pow(x, n + i * m); - i++; - } + ctx.ThrowIfCancelled(); + if (!coefScalar.TryPickNumberOrBlank(out var coef, out var error)) + return error; - return total; - } - else - { - return p[3] * Math.Pow(x, n); + total += coef * Math.Pow(input, initial + i * step); + if (double.IsInfinity(total)) + return XLError.NumberInvalid; + + i++; } + + return total; } - private static object Sign(List p) + private static ScalarValue Sign(double number) { - return Math.Sign(p[0]); + return Math.Sign(number); } - private static object Sin(List p) + private static ScalarValue Sin(double radians) { - return Math.Sin(p[0]); + return Math.Sin(radians); } - private static object Sinh(List p) + private static ScalarValue Sinh(double number) { - return Math.Sinh(p[0]); + var sinh = Math.Sinh(number); + if (double.IsInfinity(sinh)) + return XLError.NumberInvalid; + + return sinh; } - private static object Sqrt(List p) + private static ScalarValue Sqrt(double number) { - return Math.Sqrt(p[0]); + if (number < 0) + return XLError.NumberInvalid; + + return Math.Sqrt(number); } - private static object SqrtPi(List p) + private static ScalarValue SqrtPi(double number) { - var num = (Double)p[0]; - return Math.Sqrt(Math.PI * num); + if (number < 0) + return XLError.NumberInvalid; + + return Math.Sqrt(Math.PI * number); } - private static AnyValue Subtotal(CalcContext ctx, double number, List p) + private static AnyValue Subtotal(CalcContext ctx, double number, AnyValue[] fnArgs) { - var cellsWitoutSubtotal = p.SelectMany(reference => ctx.GetNonBlankCells(reference)) - .Where(cell => - { - if (!cell.HasFormula) - return true; - - return !ctx.CalcEngine.Parse(cell.FormulaA1).Flags.HasFlag(FormulaFlags.HasSubtotal); - }) - .Select(cell => new Expression(cell.Value)); + var funcNumber = number switch + { + >= 1 and < 12 => (int)number, + >= 101 and < 112 => (int)number, + _ => -1, + }; - var fId = (int)number; - var tally = new Tally(cellsWitoutSubtotal); + if (funcNumber < 0) + return XLError.IncompatibleValue; - return fId switch + var args = fnArgs.AsSpan(); + return funcNumber switch { - 1 => tally.Average(), - 2 => tally.Count(true), - 3 => tally.Count(false), - 4 => tally.Max(), - 5 => tally.Min(), - 6 => tally.Product(), - 7 => tally.Std(), - 8 => tally.StdP(), - 9 => tally.Sum(), - 10 => tally.Var(), - 11 => tally.VarP(), - _ => throw new ArgumentException("Function not supported."), + 1 => Statistical.Average(ctx, args, TallyNumbers.Subtotal10), + 2 => Statistical.Count(ctx, args, TallyNumbers.Subtotal10), + 3 => Statistical.Count(ctx, args, TallyAll.Subtotal10), + 4 => Statistical.Max(ctx, args, TallyNumbers.Subtotal10), + 5 => Statistical.Min(ctx, args, TallyNumbers.Subtotal10), + 6 => Product(ctx, args, TallyNumbers.Subtotal10), + 7 => Statistical.StDev(ctx, args, TallyNumbers.Subtotal10), + 8 => Statistical.StDevP(ctx, args, TallyNumbers.Subtotal10), + 9 => Sum(ctx, args, TallyNumbers.Subtotal10), + 10 => Statistical.Var(ctx, args, TallyNumbers.Subtotal10), + 11 => Statistical.VarP(ctx, args, TallyNumbers.Subtotal10), + 101 => Statistical.Average(ctx, args, TallyNumbers.Subtotal100), + 102 => Statistical.Count(ctx, args, TallyNumbers.Subtotal100), + 103 => Statistical.Count(ctx, args, TallyAll.Subtotal100), + 104 => Statistical.Max(ctx, args, TallyNumbers.Subtotal100), + 105 => Statistical.Min(ctx, args, TallyNumbers.Subtotal100), + 106 => Product(ctx, args, TallyNumbers.Subtotal100), + 107 => Statistical.StDev(ctx, args, TallyNumbers.Subtotal100), + 108 => Statistical.StDevP(ctx, args, TallyNumbers.Subtotal100), + 109 => Sum(ctx, args, TallyNumbers.Subtotal100), + 110 => Statistical.Var(ctx, args, TallyNumbers.Subtotal100), + 111 => Statistical.VarP(ctx, args, TallyNumbers.Subtotal100), + _ => throw new UnreachableException(), }; } private static AnyValue Sum(CalcContext ctx, Span args) { - var sum = 0.0; - foreach (var arg in args) - { - if (arg.TryPickScalar(out var scalar, out var collection)) - { - var conversionResult = scalar.ToNumber(ctx.Culture); - if (!conversionResult.TryPickT0(out var number, out var error)) - return error; + return Sum(ctx, args, TallyNumbers.Default); + } - sum += number; - } - else - { - var valuesIterator = collection.TryPickT0(out var array, out var reference) - ? array - : reference.GetCellsValues(ctx); - foreach (var value in valuesIterator) - { - // collections ignore strings and logical, only numbers (and errors) allowed - if (value.TryPickNumber(out var number)) - sum += number; - else if (value.TryPickError(out var error)) - return error; - } - } - } + private static AnyValue Sum(CalcContext ctx, Span args, ITally tally) + { + var result = tally.Tally(ctx, args, new SumState(0)); + if (!result.TryPickT0(out var state, out var error)) + return error; - return sum; + return state.Sum; } - private static object SumIf(List p) + private static AnyValue SumIf(CalcContext ctx, AnyValue range, ScalarValue selectionCriteria, AnyValue sumRange) { - // get parameters - var range = p[0] as IEnumerable; // range of values to match the criteria against - var sumRange = p.Count < 3 ? - p[0] as XObjectExpression : - p[2] as XObjectExpression; // range of values to sum up - var criteria = p[1].Evaluate(); // the criteria to evaluate + // Sum range is optional. If not specified, use the range as the sum range. + if (sumRange.IsBlank) + sumRange = range; - var rangeValues = range.Cast().ToList(); - var sumRangeValues = sumRange.Cast().ToList(); + var tally = new TallyCriteria(); + var criteria = Criteria.Create(selectionCriteria, ctx.Culture); - // compute total - var ce = new CalcEngine(CultureInfo.CurrentCulture); - var tally = new Tally(); - for (var i = 0; i < Math.Max(rangeValues.Count, sumRangeValues.Count); i++) - { - var targetValue = i < rangeValues.Count ? rangeValues[i] : string.Empty; - if (CalcEngineHelpers.ValueSatisfiesCriteria(targetValue, criteria, ce)) - { - var value = i < sumRangeValues.Count ? sumRangeValues[i] : 0d; - tally.AddValue(value); - } - } + // Excel doesn't support anything but area in the syntax, but we need to deal with it somehow. + if (!range.TryPickArea(out var area, out var areaError)) + return areaError; - // done - return tally.Sum(); + if (!sumRange.TryPickArea(out _, out var sumAreaError)) + return sumAreaError; + + tally.Add(area, criteria); + + return Sum(ctx, new[] { sumRange }, tally); } - private static object SumIfs(List p) + private static AnyValue SumIfs(CalcContext ctx, AnyValue sumRange, List<(AnyValue Range, ScalarValue Criteria)> criteriaRanges) { - // get parameters - var sumRange = p[0] as IEnumerable; + if (!sumRange.TryPickArea(out var sumArea, out var sumAreaError)) + return sumAreaError; - var sumRangeValues = new List(); - foreach (var value in sumRange) + var tally = new TallyCriteria(); + foreach (var (selectionRange, selectionCriteria) in criteriaRanges) { - sumRangeValues.Add(value); + var criteria = Criteria.Create(selectionCriteria, ctx.Culture); + if (!selectionRange.TryPickArea(out var selectionArea, out var selectionAreaError)) + return selectionAreaError; + + // All areas must have same size, that is different + // from SUMIF where areas can have different size. + if (sumArea.RowSpan != selectionArea.RowSpan || + sumArea.ColumnSpan != selectionArea.ColumnSpan) + return XLError.IncompatibleValue; + + tally.Add(selectionArea, criteria); } - var ce = new CalcEngine(CultureInfo.CurrentCulture); - var tally = new Tally(); + return Sum(ctx, new[] { sumRange }, tally); + } - int numberOfCriteria = p.Count / 2; // int division returns floor() automatically, that's what we want. + private static AnyValue SumProduct(CalcContext _, Array[] areas) + { + if (areas.Length < 1) + return XLError.IncompatibleValue; - // prepare criteria-parameters: - var criteriaRanges = new Tuple>[numberOfCriteria]; - for (int criteriaPair = 0; criteriaPair < numberOfCriteria; criteriaPair++) - { - if (p[criteriaPair * 2 + 1] is IEnumerable criteriaRange) - { - var criterion = p[criteriaPair * 2 + 2].Evaluate(); - var criteriaRangeValues = criteriaRange.Cast().ToList(); + var width = 0; + var height = 0; - criteriaRanges[criteriaPair] = new Tuple>( - criterion, - criteriaRangeValues); - } - else - { - throw new CellReferenceException($"Expected parameter {criteriaPair * 2 + 2} to be a range"); - } + // Check that all arguments have same width and height. + foreach (var area in areas) + { + var areaWidth = area.Width; + var areaHeight = area.Height; + + // We don't need to do this check for every value later, because scalar + // blank value can only happen for 1x1. + if (areaWidth == 1 && + areaHeight == 1 && + area[0, 0].IsBlank) + return XLError.IncompatibleValue; + + // If this is the first argument, use it as a baseline width and height + if (width == 0) width = areaWidth; + if (height == 0) height = areaHeight; + + if (width != areaWidth || height != areaHeight) + return XLError.IncompatibleValue; } - for (var i = 0; i < sumRangeValues.Count; i++) + // Calculate SumProduct + var sum = 0.0; + for (var rowIdx = 0; rowIdx < height; ++rowIdx) { - bool shouldUseValue = true; - - foreach (var criteriaPair in criteriaRanges) + for (var colIdx = 0; colIdx < width; ++colIdx) { - if (!CalcEngineHelpers.ValueSatisfiesCriteria( - i < criteriaPair.Item2.Count ? criteriaPair.Item2[i] : string.Empty, - criteriaPair.Item1, - ce)) + var product = 1.0; + foreach (var area in areas) { - shouldUseValue = false; - break; // we're done with the inner loop as we can't ever get true again. + var scalar = area[rowIdx, colIdx]; + + if (scalar.TryPickError(out var error)) + return error; + + if (!scalar.TryPickNumber(out var number)) + number = 0; + + product *= number; } - } - if (shouldUseValue) - tally.AddValue(sumRangeValues[i]); + sum += product; + } } - // done - return tally.Sum(); + return sum; } - private static object SumProduct(List p) + private static AnyValue SumSq(CalcContext ctx, Span args) { - // all parameters should be IEnumerable - if (p.Any(param => !(param is IEnumerable))) - throw new NoValueAvailableException(); + var result = TallyNumbers.Default.Tally(ctx, args, new SumSqState(0.0)); + if (!result.TryPickT0(out var sumSq, out var error)) + return error; - var counts = p.Cast().Select(param => - { - int i = 0; - foreach (var item in param) - i++; - return i; - }) - .Distinct(); - - // All parameters should have the same length - if (counts.Count() > 1) - throw new NoValueAvailableException(); - - var values = p - .Cast() - .Select(range => - { - var results = new List(); - foreach (var c in range) - { - if (c.IsNumber()) - results.Add(c.CastTo()); - else - results.Add(0.0); - } - return results; - }) - .ToArray(); - - return Enumerable.Range(0, counts.Single()) - .Aggregate(0d, (t, i) => - t + values.Aggregate(1d, - (product, list) => product * list[i] - ) - ); + return sumSq.Sum; } - private static object SumSq(List p) + private static ScalarValue Tan(double radians) { - var t = new Tally(p); - return t.NumericValues().Sum(v => Math.Pow(v, 2)); + // Cutoff point for Excel. .NET Core allows all values and .NET Fx ~< 1e+19. + // To ensure consistent behavior for all platforms, respect Excel limit. It's + // lower than both the .NET Core and the .NET Fx one. + if (Math.Abs(radians) >= 134217728) + return XLError.NumberInvalid; + + return Math.Tan(radians); } - private static object Tan(List p) + private static ScalarValue Tanh(double number) { - return Math.Tan(p[0]); + return Math.Tanh(number); } - private static object Tanh(List p) + private static ScalarValue Trunc(CalcContext ctx, double number, double digits) { - return Math.Tanh(p[0]); + var scaling = Math.Pow(10, digits); + return Math.Truncate(number * scaling) / scaling; } - private static object Trunc(List p) + private static Dictionary> BuildRomanForms() { - var number = (double)p[0]; + // Roman numbers can have several forms and each one has a different set of possible values. + // In Excel, each successive one has more subtract values than previous one. + var allForms = new Dictionary>(); + var form0 = new List<(string Symbol, int Value)> + { + ("M", 1000), ("CM", 900), + ("D", 500), ("CD", 400), + ("C", 100), ("XC", 90), + ("L", 50), ("XL", 40), + ("X", 10), ("IX", 9), + ("V", 5), ("IV", 4), + ("I", 1), + }; + allForms.Add(0, form0); + + var form1Additions = new (string Symbol, int Value)[] + { + ("LM", 950), + ("LD", 450), + ("VC", 95), + ("VL", 45), + }; + var form1 = form0.Concat(form1Additions).OrderByDescending(x => x.Value).ToArray(); + allForms.Add(1, form1); + + var form2Additions = new (string Symbol, int Value)[] + { + ("XM", 990), + ("XD", 490), + ("IC", 99), + ("IL", 49), + }; + var form2 = form1.Concat(form2Additions).OrderByDescending(x => x.Value).ToArray(); + allForms.Add(2, form2); - var num_digits = 0d; - if (p.Count > 1) - num_digits = (double)p[1]; + var form3Additions = new (string Symbol, int Value)[] + { + ("VM", 995), + ("VD", 495), + }; + var form3 = form2.Concat(form3Additions).OrderByDescending(x => x.Value).ToArray(); + allForms.Add(3, form3); + + var form4Additions = new (string Symbol, int Value)[] + { + ("IM", 999), + ("ID", 499), + }; + var form4 = form3.Concat(form4Additions).OrderByDescending(x => x.Value).ToArray(); + allForms.Add(4, form4); + return allForms; + } + + private readonly record struct SumState(double Sum) : ITallyState + { + public SumState Tally(double number) => new(Sum + number); + } - var scaling = Math.Pow(10, num_digits); + private readonly record struct SumSqState(double Sum) : ITallyState + { + public SumSqState Tally(double number) + { + return new SumSqState(Sum + number * number); + } + } - var truncated = (int)(number * scaling); - return (double)truncated / scaling; + private readonly record struct ProductState(double Product, bool HasValues) : ITallyState + { + public ProductState Tally(double number) => new(Product * number, true); } } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/SignatureAdapter.cs b/ClosedXML/Excel/CalcEngine/Functions/SignatureAdapter.cs index d9f260593..219c9917c 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/SignatureAdapter.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/SignatureAdapter.cs @@ -1,19 +1,483 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; -namespace ClosedXML.Excel.CalcEngine.Functions -{ - /// - /// A collection of adapter functions from a more a generic formula function to more specific ones. - /// - internal static class SignatureAdapter - { - #region Signature adapters - // Each method converts a more specific signature of a function into a generic formula function type. - // We have many functions with same signature and the adapters should be reusable. Convert parameters - // through value converters below. We can hopefully generate them at a later date, so try to keep them similar. +namespace ClosedXML.Excel.CalcEngine.Functions +{ + /// + /// A collection of adapter functions from a more a generic formula function to more specific ones. + /// + internal static class SignatureAdapter + { + #region Signature adapters + // Each method converts a more specific signature of a function into a generic formula function type. + // We have many functions with same signature and the adapters should be reusable. Convert parameters + // through value converters below. We can hopefully generate them at a later date, so try to keep them similar. + + public static CalcEngineFunction Adapt(Func f) + { + return (_, _) => f().ToAnyValue(); + } + + public static CalcEngineFunction AdaptCoerced(Func f) + { + return (ctx, args) => + { + var arg0Converted = CoerceToLogical(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + return f(arg0); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + return f(arg0).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + return f(ctx, arg0).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToScalarValue(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + return f(ctx, arg0).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(ctx, arg0, arg1).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToNumber(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + return f(ctx, arg0, arg1, arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToText(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + return f(ctx, arg0, arg1, arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToNumber(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + var arg3Converted = CoerceToLogical(args[3], ctx); + if (!arg3Converted.TryPickT0(out var arg3, out var err3)) + return err3; + + return f(arg0, arg1, arg2, arg3); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + return f(ctx, arg0).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToText(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(arg0, arg1).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(arg0, arg1).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToNumber(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + var arg3Converted = ToText(args[3], ctx); + if (!arg3Converted.TryPickT0(out var arg3, out var err3)) + return err3; + + return f(ctx, arg0, arg1, arg2, arg3).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToNumber(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + return f(ctx, arg0, arg1, arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0 = args[0]; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(ctx, arg0, arg1); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1 = default(ScalarValue?); + if (args.Length > 1) + { + var arg1Converted = ToScalarValue(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1Value, out var err1)) + return err1; + + arg1 = arg1Value; + } + + + return f(ctx, arg0, arg1); + }; + } + + public static CalcEngineFunction Adapt(Func, ScalarValue> f) + { + return (ctx, args) => + { + var texts = new List(args.Length); + foreach (var arg in args) + { + var argConverted = ToText(arg, ctx); + if (!argConverted.TryPickT0(out var text, out var error)) + return error; + + texts.Add(text); + } + + return f(ctx, texts).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => f(ctx, args[0]); + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToScalarValue(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + return f(ctx, arg0); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToScalarValue(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToText(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(ctx, arg0, arg1).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToScalarValue(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToScalarValue(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(arg0, arg1); + }; + } + + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0 = args[0]; + + var arg1Converted = ToScalarValue(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(ctx, arg0, arg1); + }; + } + + public static CalcEngineFunction Adapt(Func, ScalarValue> f) + { + return (ctx, args) => + { + var arrays = new List(); + foreach (var arg in args) + { + if (arg.TryPickSingleOrMultiValue(out var scalar, out var array, ctx)) + array = new ScalarArray(scalar, 1, 1); + + arrays.Add(array); + } + + return f(ctx, arrays).ToAnyValue(); + }; + } + + public static CalcEngineFunction Adapt(Func, ScalarValue> f) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = CoerceToLogical(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var remainingArgs = new List(); + foreach (var arg in args[2..]) + remainingArgs.Add(arg); + + return f(ctx, arg0, arg1, remainingArgs).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptLastOptional(Func f, AnyValue lastDefault) + { + return (ctx, args) => + { + var arg0Converted = ToScalarValue(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1 = args[1]; + var arg2 = args.Length > 2 ? args[2] : lastDefault; + return f(arg0, arg1, arg2); + }; + } + + public static CalcEngineFunction AdaptLastOptional(Func f, double lastDefault) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args.Length > 1 ? args[1] : lastDefault, ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(ctx, arg0, arg1).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptLastOptional(Func f, double lastDefault) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToNumber(args.Length > 2 ? args[2] : lastDefault, ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + return f(ctx, arg0, arg1, arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptLastOptional(Func f, bool lastDefault) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = CoerceToLogical(args.Length > 2 ? args[2] : lastDefault, ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + return f(ctx, arg0, arg1, arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptLastOptional(Func f, double lastDefault) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args.Length > 1 ? args[1] : lastDefault, ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(ctx, arg0, arg1).ToAnyValue(); + }; + } - public static CalcEngineFunction Adapt(Func f) + public static CalcEngineFunction AdaptLastOptional(Func f, double lastDefault) { return (ctx, args) => { @@ -21,11 +485,32 @@ public static CalcEngineFunction Adapt(Func f) if (!arg0Converted.TryPickT0(out var arg0, out var err0)) return err0; - return f(arg0); + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToNumber(args.Length > 2 ? args[2] : lastDefault, ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + return f(arg0, arg1, arg2).ToAnyValue(); }; } - public static CalcEngineFunction Adapt(Func f) + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var argsLoop = args[1..].ToArray(); + return f(ctx, arg0, argsLoop); + }; + } + + public static CalcEngineFunction AdaptLastOptional(Func, AnyValue> f) { return (ctx, args) => { @@ -33,39 +518,439 @@ public static CalcEngineFunction Adapt(Func 1) + var arg1Converted = ToText(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + OneOf arg2Optional = Blank.Value; + if (args.Length > 2) { - var arg1Converted = ToScalarValue(args[1], ctx); - if (!arg1Converted.TryPickT0(out var arg1Value, out var err1)) - return err1; + var arg2Converted = ToNumber(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; - arg1 = arg1Value; + arg2Optional = arg2; } + return f(ctx, arg0, arg1, arg2Optional); + }; + } + + public static CalcEngineFunction AdaptLastOptional(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToScalarValue(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; - return f(ctx, arg0, arg1); + var arg1Converted = args.Length > 1 ? ToScalarValue(args[1], ctx) : ScalarValue.Blank; + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + return f(ctx, arg0, arg1).ToAnyValue(); }; } - public static CalcEngineFunction Adapt(Func, AnyValue> f) + public static CalcEngineFunction AdaptLastOptional(Func f) { return (ctx, args) => { - var arg0Converted = ToNumber(args[0], ctx); + var arg0Converted = ToScalarValue(args[0], ctx); if (!arg0Converted.TryPickT0(out var arg0, out var err0)) return err0; - var argsLoop = new List(); + var arg1Converted = ToScalarValue(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2 = args.Length > 2 ? args[2] : AnyValue.Blank; + + return f(ctx, arg0, arg1, arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptLastOptional(Func f) + { + return (ctx, args) => + { + var arg0 = args[0]; + + var arg1Converted = ToScalarValue(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2 = args.Length > 2 ? args[2] : AnyValue.Blank; + + return f(ctx, arg0, arg1, arg2); + }; + } + + /// + /// An adapter for {SUM,AVERAGE}IFS functions. + /// + public static CalcEngineFunction AdaptIfs(Func, AnyValue> f) + { + return (ctx, args) => + { + var tallyRange = args[0]; + if (!ToCriteria(ctx, args[1..]).TryPickT0(out var criteria, out var error)) + return error; + + return f(ctx, tallyRange, criteria); + }; + } + + /// + /// An adapter for COUNTIFS function. + /// + public static CalcEngineFunction AdaptIfs(Func, AnyValue> f) + { + return (ctx, args) => + { + if (!ToCriteria(ctx, args).TryPickT0(out var criteria, out var error)) + return error; + + return f(ctx, criteria); + }; + } + + public static CalcEngineFunction AdaptIndex(Func, AnyValue> f) + { + return (ctx, args) => + { + var arg0 = args[0]; + var numbers = new List(args.Length - 1); for (var i = 1; i < args.Length; ++i) { - if (!args[i].TryPickReference(out var reference, out var error)) + if (!ToNumber(args[i], ctx).TryPickT0(out var number, out var error)) return error; - argsLoop.Add(reference); + numbers.Add((int)number); } - return f(ctx, arg0, argsLoop); + return f(ctx, arg0, numbers); + }; + } + + public static CalcEngineFunction AdaptMatch(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToScalarValue(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1 = args[1]; + var arg2Converted = args.Length > 2 ? ToNumber(args[2], ctx) : 1; + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + return f(ctx, arg0, arg1, (int)arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptSeriesSum(Func f) + { + return (ctx, args) => + { + // SERIESSUM doesn't convert logical values to number... + if (args[0].IsLogical) + return XLError.IncompatibleValue; + + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + if (args[1].IsLogical) + return XLError.IncompatibleValue; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + if (args[2].IsLogical) + return XLError.IncompatibleValue; + + var arg2Converted = ToNumber(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + if (args[3].TryPickSingleOrMultiValue(out var scalar, out var arg3, ctx)) + { + if (scalar.IsLogical) + return XLError.IncompatibleValue; + + if (!scalar.ToNumber(ctx.Culture).TryPickT0(out var number, out var error)) + return error; + + arg3 = new ScalarArray(number, 1, 1); + } + + return f(ctx, arg0, arg1, arg2, arg3).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptNumberValue(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var decimalSeparator = ctx.Culture.NumberFormat.NumberDecimalSeparator; + var arg1Converted = ToText(args.Length > 1 ? args[1] : decimalSeparator, ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var groupSeparator = ctx.Culture.NumberFormat.NumberGroupSeparator; + var arg2Converted = ToText(args.Length > 2 ? args[2] : groupSeparator, ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + return f(ctx, arg0, arg1, arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptSubstitute(Func f) + { + return (ctx, args) => + { + var arg0Converted = ToText(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToText(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToText(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + double? arg3 = null; + if (args.Length > 3) + { + // Excel doesn't accept logical, be more permissive. + var arg3Converted = ToNumber(args[3], ctx); + if (!arg3Converted.TryPickT0(out var arg3Number, out var err3)) + return err3; + + arg3 = arg3Number; + } + + return f(ctx, arg0, arg1, arg2, arg3).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptMultinomial(Func>, ScalarValue> f) + { + return (ctx, args) => + { + // This can skip blank values, because blank doesn't increase nominator + // and doesn't change denominator due to 0! = 1 + var scalarCollections = new List>(args.Length); + foreach (var arg in args) + scalarCollections.Add(GetNonBlankScalars(arg, ctx)); + + return f(ctx, scalarCollections).ToAnyValue(); + }; + + static IEnumerable GetNonBlankScalars(AnyValue value, CalcContext ctx) + { + if (value.TryPickScalar(out var scalar, out var collection)) + { + if (!scalar.IsBlank) + yield return scalar; + } + else if (collection.TryPickT0(out var array, out var reference)) + { + foreach (var element in array) + { + if (!element.IsBlank) + yield return element; + } + } + else + { + foreach (var element in ctx.GetNonBlankValues(reference)) + { + if (!element.IsBlank) + yield return element; + } + } + } + } + + /// + /// Adapt a function that accepts areas as arguments (e.g. SUMPRODUCT). The key benefit is + /// that all ReferenceArray allocation is done once for a function. The method + /// shouldn't be used for functions that accept 3D references (e.g. SUMSQ). It is still + /// necessary to check all errors in the , adapt method doesn't do that + /// on its own (potential performance problem). The signature uses an array instead of + /// IReadOnlyList interface for performance reasons (can't JIT access props through interface). + /// + public static CalcEngineFunction Adapt(Func f) + { + return (ctx, args) => + { + var areas = new Array[args.Length]; + for (var i = 0; i < args.Length; ++i) + { + areas[i] = args[i].TryPickSingleOrMultiValue(out var scalar, out var array, ctx) + ? new ScalarArray(scalar, 1, 1) + : array; + } + + return f(ctx, areas); + }; + } + + public static CalcEngineFunction AdaptLastOptional(Func f, bool defaultValue0) + { + return (ctx, args) => + { + var arg0Converted = ToScalarValue(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1 = args[1]; + + var arg2Converted = ToNumber(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + var arg3Converted = args.Length >= 4 ? CoerceToLogical(args[3], ctx) : defaultValue0; + if (!arg3Converted.TryPickT0(out var arg3, out var err3)) + return err3; + + return f(ctx, arg0, arg1, arg2, arg3); + }; + } + + public static CalcEngineFunction AdaptLastTwoOptional(Func f, double defaultValue1, double defaultValue2) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = args.Length > 1 ? ToNumber(args[1], ctx) : defaultValue1; + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = args.Length > 2 ? ToNumber(args[2], ctx) : defaultValue2; + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + return f(arg0, arg1, arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptLastTwoOptional(Func f, double defaultValue1, bool defaultValue2) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = args.Length > 1 ? ToNumber(args[1], ctx) : defaultValue1; + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + // AnyValue to bool has different semantic than AnyValue to number, e.g. "0" is not valid for bool coercion + var arg2Converted = args.Length > 2 ? args[2] : defaultValue2; + if (!CoerceToLogical(arg2Converted, ctx).TryPickT0(out var arg2, out var err2)) + return err2; + + return f(ctx, arg0, arg1, arg2).ToAnyValue(); + }; + } + + public static CalcEngineFunction AdaptLastTwoOptional(Func f, double defaultValue0, double defaultValue1) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToNumber(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + var arg3Optional = defaultValue0; + if (args.Length >= 4) + { + var arg3Converted = ToNumber(args[3], ctx); + if (!arg3Converted.TryPickT0(out var arg3, out var err3)) + return err3; + + arg3Optional = arg3; + } + + var arg4Optional = defaultValue1; + if (args.Length >= 5) + { + var arg4Converted = ToNumber(args[4], ctx); + if (!arg4Converted.TryPickT0(out var arg4, out var err4)) + return err4; + + arg4Optional = arg4; + } + + return f(arg0, arg1, arg2, arg3Optional, arg4Optional); + }; + } + + public static CalcEngineFunction AdaptLastTwoOptional(Func f, double defaultValue0, double defaultValue1) + { + return (ctx, args) => + { + var arg0Converted = ToNumber(args[0], ctx); + if (!arg0Converted.TryPickT0(out var arg0, out var err0)) + return err0; + + var arg1Converted = ToNumber(args[1], ctx); + if (!arg1Converted.TryPickT0(out var arg1, out var err1)) + return err1; + + var arg2Converted = ToNumber(args[2], ctx); + if (!arg2Converted.TryPickT0(out var arg2, out var err2)) + return err2; + + var arg3Converted = ToNumber(args[3], ctx); + if (!arg3Converted.TryPickT0(out var arg3, out var err3)) + return err3; + + var arg4Optional = defaultValue0; + if (args.Length >= 5) + { + var arg4Converted = ToNumber(args[4], ctx); + if (!arg4Converted.TryPickT0(out var arg4, out var err4)) + return err4; + + arg4Optional = arg4; + } + + var arg5Optional = defaultValue1; + if (args.Length >= 6) + { + var arg5Converted = ToNumber(args[5], ctx); + if (!arg5Converted.TryPickT0(out var arg5, out var err5)) + return err5; + + arg5Optional = arg5; + } + + return f(arg0, arg1, arg2, arg3, arg4Optional, arg5Optional); }; } @@ -75,13 +960,27 @@ public static CalcEngineFunction Adapt(Func // Each method is named ToSomething and it converts an argument into a desired type (e.g. for ToSomething it should be type Something). // Return value is always OneOf, if there is an error, return it as an error. + private static OneOf CoerceToLogical(in AnyValue value, CalcContext ctx) + { + if (!ToScalarValue(in value, ctx).TryPickT0(out var scalar, out var scalarError)) + return scalarError; + + // LibreOffice does accept text, tries to parse it as a number and coerces the number + // to bool. Excel does not accept number in text argument. + if (!scalar.TryCoerceLogicalOrBlankOrNumberOrText(out var logical, out var coercionError)) + return coercionError; + + return logical; + } + private static OneOf ToNumber(in AnyValue value, CalcContext ctx) { if (value.TryPickScalar(out var scalar, out var collection)) return scalar.ToNumber(ctx.Culture); - if (collection.TryPickT0(out _, out var reference)) - throw new NotImplementedException("Array formulas not implemented."); + // When user specifies array as an argument in an array formula for a scalar function, use [0,0] + if (collection.TryPickT0(out var array, out var reference)) + return array[0, 0].ToNumber(ctx.Culture); if (reference.TryGetSingleCellValue(out var scalarValue, ctx)) return scalarValue.ToNumber(ctx.Culture); @@ -117,6 +1016,29 @@ private static OneOf ToScalarValue(in AnyValue value, Calc return OneOf.FromT1(XLError.IncompatibleValue); } + private static OneOf, XLError> ToCriteria(CalcContext ctx, ReadOnlySpan args) + { + var allCriteria = new List<(AnyValue Range, ScalarValue Criteria)>(); + var pairCount = (args.Length + 1) / 2; + for (var i = 0; i < pairCount; ++i) + { + var rangeArgIndex = 2 * i; + var range = args[rangeArgIndex]; + + // Excel grammar requires even number of arguments. We can't + // do that, so use blank for missing pair value. + var criteriaArgIndex = rangeArgIndex + 1; + var criteriaArgConverted = criteriaArgIndex < args.Length + ? ToScalarValue(args[criteriaArgIndex], ctx) + : ScalarValue.Blank; + if (!criteriaArgConverted.TryPickT0(out var criteria, out var criteriaError)) + return criteriaError; + + allCriteria.Add((range, criteria)); + } + + return allCriteria; + } #endregion } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/Statistical.cs b/ClosedXML/Excel/CalcEngine/Functions/Statistical.cs index 9ee4673a1..adf303928 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/Statistical.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/Statistical.cs @@ -1,8 +1,8 @@ -using ClosedXML.Excel.CalcEngine.Exceptions; using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; +using ClosedXML.Excel.CalcEngine.Functions; +using static ClosedXML.Excel.CalcEngine.Functions.SignatureAdapter; namespace ClosedXML.Excel.CalcEngine { @@ -11,28 +11,29 @@ internal static class Statistical public static void Register(FunctionRegistry ce) { //ce.RegisterFunction("AVEDEV", AveDev, 1, int.MaxValue); - ce.RegisterFunction("AVERAGE", 1, int.MaxValue, Average, AllowRange.All); // Returns the average (arithmetic mean) of the arguments - ce.RegisterFunction("AVERAGEA", 1, int.MaxValue, AverageA, AllowRange.All); + ce.RegisterFunction("AVERAGE", 1, int.MaxValue, Average, FunctionFlags.Range, AllowRange.All); // Returns the average (arithmetic mean) of the arguments + ce.RegisterFunction("AVERAGEA", 1, int.MaxValue, AverageA, FunctionFlags.Range, AllowRange.All); //BETADIST Returns the beta cumulative distribution function - //BETAINV Returns the inverse of the cumulative distribution function for a specified beta distribution - //BINOMDIST Returns the individual term binomial distribution probability + //BETAINV Returns the inverse of the cumulative distribution function for a specified beta distribution + ce.RegisterFunction("BINOMDIST", 4, 4, Adapt(BinomDist), FunctionFlags.Scalar); //BINOMDIST Returns the individual term binomial distribution probability + ce.RegisterFunction("BINOM.DIST", 4, 4, Adapt(BinomDist), FunctionFlags.Scalar); // In theory more precise BINOMDIST. //CHIDIST Returns the one-tailed probability of the chi-squared distribution //CHIINV Returns the inverse of the one-tailed probability of the chi-squared distribution //CHITEST Returns the test for independence //CONFIDENCE Returns the confidence interval for a population mean //CORREL Returns the correlation coefficient between two data sets - ce.RegisterFunction("COUNT", 1, int.MaxValue, Count, AllowRange.All); - ce.RegisterFunction("COUNTA", 1, int.MaxValue, CountA, AllowRange.All); - ce.RegisterFunction("COUNTBLANK", 1, CountBlank, AllowRange.All); - ce.RegisterFunction("COUNTIF", 2, CountIf, AllowRange.Only, 0); - ce.RegisterFunction("COUNTIFS", 2, 255, CountIfs, AllowRange.Only, Enumerable.Range(0, 128).Select(x => x * 2).ToArray()); + ce.RegisterFunction("COUNT", 1, int.MaxValue, Count, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("COUNTA", 1, 255, CountA, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("COUNTBLANK", 1, 1, Adapt(CountBlank), FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("COUNTIF", 2, 2, Adapt((Func)CountIf), FunctionFlags.Range, AllowRange.Only, 0); + ce.RegisterFunction("COUNTIFS", 2, 255, AdaptIfs(CountIfs), FunctionFlags.Range, AllowRange.Only, Enumerable.Range(0, 128).Select(x => x * 2).ToArray()); //COVAR Returns covariance, the average of the products of paired deviations //CRITBINOM Returns the smallest value for which the cumulative binomial distribution is less than or equal to a criterion value - ce.RegisterFunction("DEVSQ", 1, 255, DevSq, AllowRange.All); // Returns the sum of squares of deviations + ce.RegisterFunction("DEVSQ", 1, 255, DevSq, FunctionFlags.Range, AllowRange.All); // Returns the sum of squares of deviations //EXPONDIST Returns the exponential distribution //FDIST Returns the F probability distribution //FINV Returns the inverse of the F probability distribution - ce.RegisterFunction("FISHER", 1, Fisher); // Returns the Fisher transformation + ce.RegisterFunction("FISHER", 1, 1, Adapt(Fisher), FunctionFlags.Scalar); // Returns the Fisher transformation //FISHERINV Returns the inverse of the Fisher transformation //FORECAST Returns a value along a linear trend //FREQUENCY Returns a frequency distribution as a vertical array @@ -40,22 +41,23 @@ public static void Register(FunctionRegistry ce) //GAMMADIST Returns the gamma distribution //GAMMAINV Returns the inverse of the gamma cumulative distribution //GAMMALN Returns the natural logarithm of the gamma function, Γ(x) - ce.RegisterFunction("GEOMEAN", 1, 255, Geomean, AllowRange.All); // Returns the geometric mean + ce.RegisterFunction("GEOMEAN", 1, 255, GeoMean, FunctionFlags.Range, AllowRange.All); // Returns the geometric mean //GROWTH Returns values along an exponential trend //HARMEAN Returns the harmonic mean //HYPGEOMDIST Returns the hypergeometric distribution //INTERCEPT Returns the intercept of the linear regression line //KURT Returns the kurtosis of a data set //LARGE Returns the k-th largest value in a data set + ce.RegisterFunction("LARGE", 2, 2, Adapt(Large), FunctionFlags.Range, AllowRange.Only, 0); //LINEST Returns the parameters of a linear trend //LOGEST Returns the parameters of an exponential trend //LOGINV Returns the inverse of the lognormal distribution //LOGNORMDIST Returns the cumulative lognormal distribution - ce.RegisterFunction("MAX", 1, int.MaxValue, Max, AllowRange.All); - ce.RegisterFunction("MAXA", 1, int.MaxValue, MaxA, AllowRange.All); - ce.RegisterFunction("MEDIAN", 1, int.MaxValue, Median, AllowRange.All); - ce.RegisterFunction("MIN", 1, int.MaxValue, Min, AllowRange.All); - ce.RegisterFunction("MINA", 1, int.MaxValue, MinA, AllowRange.All); + ce.RegisterFunction("MAX", 1, 255, Max, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("MAXA", 1, int.MaxValue, MaxA, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("MEDIAN", 1, int.MaxValue, Median, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("MIN", 1, int.MaxValue, Min, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("MINA", 1, int.MaxValue, MinA, FunctionFlags.Range, AllowRange.All); //MODE Returns the most common value in a data set //NEGBINOMDIST Returns the negative binomial distribution //NORMDIST Returns the normal cumulative distribution @@ -75,226 +77,501 @@ public static void Register(FunctionRegistry ce) //SLOPE Returns the slope of the linear regression line //SMALL Returns the k-th smallest value in a data set //STANDARDIZE Returns a normalized value - ce.RegisterFunction("STDEV", 1, int.MaxValue, StDev, AllowRange.All); - ce.RegisterFunction("STDEVA", 1, int.MaxValue, StDevA, AllowRange.All); - ce.RegisterFunction("STDEVP", 1, int.MaxValue, StDevP, AllowRange.All); - ce.RegisterFunction("STDEVPA", 1, int.MaxValue, StDevPA, AllowRange.All); - ce.RegisterFunction("STDEV.S", 1, int.MaxValue, StDev); - ce.RegisterFunction("STDEV.P", 1, int.MaxValue, StDevP); + ce.RegisterFunction("STDEV", 1, int.MaxValue, StDev, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("STDEVA", 1, int.MaxValue, StDevA, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("STDEVP", 1, int.MaxValue, StDevP, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("STDEVPA", 1, int.MaxValue, StDevPA, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("STDEV.S", 1, int.MaxValue, StDev, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("STDEV.P", 1, int.MaxValue, StDevP, FunctionFlags.Range, AllowRange.All); //STEYX Returns the standard error of the predicted y-value for each x in the regression //TDIST Returns the Student's t-distribution //TINV Returns the inverse of the Student's t-distribution //TREND Returns values along a linear trend //TRIMMEAN Returns the mean of the interior of a data set //TTEST Returns the probability associated with a Student's t-test - ce.RegisterFunction("VAR", 1, int.MaxValue, Var, AllowRange.All); - ce.RegisterFunction("VARA", 1, int.MaxValue, VarA, AllowRange.All); - ce.RegisterFunction("VARP", 1, int.MaxValue, VarP, AllowRange.All); - ce.RegisterFunction("VARPA", 1, int.MaxValue, VarPA, AllowRange.All); - ce.RegisterFunction("VAR.S", 1, int.MaxValue, Var); - ce.RegisterFunction("VAR.P", 1, int.MaxValue, VarP); + ce.RegisterFunction("VAR", 1, int.MaxValue, Var, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("VARA", 1, int.MaxValue, VarA, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("VARP", 1, int.MaxValue, VarP, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("VARPA", 1, int.MaxValue, VarPA, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("VAR.S", 1, int.MaxValue, Var, FunctionFlags.Range, AllowRange.All); + ce.RegisterFunction("VAR.P", 1, int.MaxValue, VarP, FunctionFlags.Range, AllowRange.All); //WEIBULL Returns the Weibull distribution //ZTEST Returns the one-tailed probability-value of a z-test } - private static object Average(List p) + private static AnyValue Average(CalcContext ctx, Span args) { - return GetTally(p, true).Average(); + return Average(ctx, args, TallyNumbers.Default); } - private static object AverageA(List p) + internal static AnyValue Average(CalcContext ctx, Span args, ITally tally) { - return GetTally(p, false).Average(); - } + if (args.Length < 1) + return XLError.IncompatibleValue; - private static object Count(List p) - { - return GetTally(p, true).Count(); + if (!tally.Tally(ctx, args, new SumState()).TryPickT0(out var state, out var error)) + return error; + + if (state.Count == 0) + return XLError.DivisionByZero; + + return state.Sum / state.Count; } - private static object CountA(List p) + private static AnyValue AverageA(CalcContext ctx, Span args) { - return GetTally(p, false).Count(); + return Average(ctx, args, TallyAll.WithArrayText); } - private static object CountBlank(List p) + private static AnyValue BinomDist(double numberSuccesses, double numberTrials, double successProbability, bool cumulativeFlag) { - if ((p[0] as XObjectExpression)?.Value as CellRangeReference == null) - throw new NoValueAvailableException("COUNTBLANK should have a single argument which is a range reference"); + if (successProbability is < 0 or > 1) + return XLError.NumberInvalid; - var e = p[0] as XObjectExpression; - long totalCount = CalcEngineHelpers.GetTotalCellsCount(e); - long nonBlankCount = 0; - foreach (var value in e) + if (cumulativeFlag) { - if (!CalcEngineHelpers.ValueIsBlank(value)) - nonBlankCount++; + var cdf = 0d; + for (var y = 0; y <= numberSuccesses; ++y) + { + var result = BinomDist(y, numberTrials, successProbability); + if (!result.TryPickT0(out var pf, out var error)) + return error; + + cdf += pf; + } + + if (double.IsNaN(cdf) || double.IsInfinity(cdf)) + return XLError.NumberInvalid; + + return cdf; } + else + { + var result = BinomDist(numberSuccesses, numberTrials, successProbability); + if (!result.TryPickT0(out var binomDist, out var error)) + return error; - return 0d + totalCount - nonBlankCount; + return binomDist; + } } - private static object CountIf(List p) + private static OneOf BinomDist(double x, double n, double p) { - CalcEngine ce = new CalcEngine(CultureInfo.CurrentCulture); - var cnt = 0.0; - long processedCount = 0; - if (p[0] is XObjectExpression ienum) - { - long totalCount = CalcEngineHelpers.GetTotalCellsCount(ienum); - var criteria = p[1].Evaluate(); - foreach (var value in ienum) - { - if (CalcEngineHelpers.ValueSatisfiesCriteria(value, criteria, ce)) - cnt++; - processedCount++; - } + if (!XLMath.CombinChecked(n, x).TryPickT0(out var combinations, out var error)) + return error; - // Add count of empty cells outside the used range if they match criteria - if (CalcEngineHelpers.ValueSatisfiesCriteria(string.Empty, criteria, ce)) - cnt += (totalCount - processedCount); - } + x = Math.Floor(x); + n = Math.Floor(n); + var binomDist = combinations * Math.Pow(p, x) * Math.Pow(1 - p, n - x); + if (double.IsNaN(binomDist) || double.IsInfinity(binomDist)) + return XLError.NumberInvalid; + + return binomDist; + } - return cnt; + private static AnyValue Count(CalcContext ctx, Span args) + { + return Count(ctx, args, TallyNumbers.IgnoreErrors); } - private static object CountIfs(List p) + internal static AnyValue Count(CalcContext ctx, Span args, ITally tally) { - // get parameters - var ce = new CalcEngine(CultureInfo.CurrentCulture); - long count = 0; + if (args.Length < 1) + return XLError.IncompatibleValue; - int numberOfCriteria = p.Count / 2; + var result = tally.Tally(ctx, args, new CountState(0)); + if (!result.TryPickT0(out var state, out var error)) + return error; - long totalCount = 0; - // prepare criteria-parameters: - var criteriaRanges = new Tuple>[numberOfCriteria]; - for (int criteriaPair = 0; criteriaPair < numberOfCriteria; criteriaPair++) - { - var criteriaRange = p[criteriaPair * 2] as XObjectExpression; - var criterion = p[(criteriaPair * 2) + 1].Evaluate(); - var criteriaRangeValues = new List(); - foreach (var value in criteriaRange) - { - criteriaRangeValues.Add(value); - } + return state.Count; + } - criteriaRanges[criteriaPair] = new Tuple>( - criterion, - criteriaRangeValues); + private static AnyValue CountA(CalcContext ctx, Span args) + { + return Count(ctx, args, TallyAll.IncludeErrors); + } - if (totalCount == 0) - totalCount = CalcEngineHelpers.GetTotalCellsCount(criteriaRange); - } + private static AnyValue CountBlank(CalcContext ctx, AnyValue arg) + { + if (!arg.TryPickArea(out var area, out var error)) + return error; - long processedCount = 0; - for (var i = 0; i < criteriaRanges[0].Item2.Count; i++) - { - if (criteriaRanges.All(criteriaPair => CalcEngineHelpers.ValueSatisfiesCriteria( - criteriaPair.Item2[i], criteriaPair.Item1, ce))) - count++; + // To be efficient for cases like whole sheet with only few values, calculate + // the blank count as number of total area size without non-blank cells. + var nonBlankCount = ctx.GetNonBlankValues(new Reference(area)) + .Where(static value => !value.IsBlank && !(value.IsText && value.GetText().Length == 0)) + .LongCount(); - processedCount++; - } + return area.Size - nonBlankCount; + } + + private static AnyValue CountIf(CalcContext ctx, AnyValue countRange, ScalarValue selectionCriteria) + { + // Excel doesn't support anything but area in the syntax, but we need to deal with it somehow. + if (!countRange.TryPickArea(out var countArea, out var areaError)) + return areaError; + + var tally = new TallyCriteria(static _ => 1); + var criteria = Criteria.Create(selectionCriteria, ctx.Culture); + tally.Add(countArea, criteria); - // Add count of empty cells outside the used range if they match criteria - if (criteriaRanges.All(criteriaPair => CalcEngineHelpers.ValueSatisfiesCriteria( - string.Empty, criteriaPair.Item1, ce))) + // TallyCriteria only sums up the value. + var result = tally.Tally(ctx, new[] { countRange }, new CountState(0)); + if (!result.TryPickT0(out var state, out var error)) + return error; + + return state.Count; + } + + private static AnyValue CountIfs(CalcContext ctx, List<(AnyValue Range, ScalarValue Criteria)> criteriaRanges) + { + if (!criteriaRanges[0].Range.TryPickArea(out var countArea, out var areaError)) + return areaError; + + var tally = new TallyCriteria(static _ => 1); + foreach (var (selectionRange, selectionCriteria) in criteriaRanges) { - count += (totalCount - processedCount); + var criteria = Criteria.Create(selectionCriteria, ctx.Culture); + if (!selectionRange.TryPickArea(out var selectionArea, out var selectionAreaError)) + return selectionAreaError; + + // All areas must have same size. + if (countArea.RowSpan != selectionArea.RowSpan || + countArea.ColumnSpan != selectionArea.ColumnSpan) + return XLError.IncompatibleValue; + + tally.Add(selectionArea, criteria); } - // done - return count; + // The values in the range aren't used, so just use first area + var result = tally.Tally(ctx, new[] { criteriaRanges[0].Range }, new CountState(0)); + if (!result.TryPickT0(out var state, out var error)) + return error; + + return state.Count; } - private static object DevSq(List p) + private static AnyValue DevSq(CalcContext ctx, Span args) { - return GetTally(p, true).DevSq(); + var result = GetSquareDiffSum(ctx, args, TallyNumbers.Default); + if (!result.TryPickT0(out var squareDiff, out var error)) + return error; + + // An outlier, most others return #DIV/0! when they can't calculate mean. + if (squareDiff.Count == 0) + return XLError.NumberInvalid; + + return squareDiff.Sum; } - private static object Fisher(List p) + private static ScalarValue Fisher(CalcContext ctx, double x) { - var x = (double)p[0]; - if (x <= -1 || x >= 1) throw new NumberException("Incorrect value. Should be: -1 > x < 1."); + if (x is <= -1 or >= 1) + return XLError.NumberInvalid; return 0.5 * Math.Log((1 + x) / (1 - x)); } - private static object Geomean(List p) + private static AnyValue GeoMean(CalcContext ctx, Span args) + { + // Rather than interrupting a cycle early, just add it all + // go through all values anyway. I don't want to code same + // loop 1000 times and non-positive numbers will be rare. + var tally = TallyNumbers.Default.Tally(ctx, args, new LogSumState(0.0, 0)); + if (!tally.TryPickT0(out var geoMean, out var error)) + return error; + + if (geoMean.Count == 0) + return XLError.NumberInvalid; + + // Some value was negative or zero. NaN plus whatever is NaN, infinity + // plus whatever is also infinity. + if (double.IsInfinity(geoMean.LogSum) || double.IsNaN(geoMean.LogSum)) + return XLError.NumberInvalid; + + return Math.Exp(geoMean.LogSum / geoMean.Count); + } + + private static AnyValue Max(CalcContext ctx, Span args) + { + return Max(ctx, args, TallyNumbers.Default); + } + + internal static AnyValue Max(CalcContext ctx, Span args, ITally tally) + { + var result = tally.Tally(ctx, args, new MaxState()); + if (!result.TryPickT0(out var state, out var error)) + return error; + + if (!state.HasValues) + return 0; + + return state.Max; + } + + private static AnyValue MaxA(CalcContext ctx, Span args) + { + return Max(ctx, args, TallyAll.Default); + } + + private static AnyValue Median(CalcContext ctx, Span args) + { + // There is a better median algorithm that uses two heaps, but NetFx + // doesn't have heap structure. + var result = TallyNumbers.Default.Tally(ctx, args, new ValuesState(new List())); + if (!result.TryPickT0(out var state, out var error)) + return error; + + var allNumbers = state.Values; + if (allNumbers.Count == 0) + return XLError.NumberInvalid; + + allNumbers.Sort(); + + var halfIndex = allNumbers.Count / 2; + var hasEvenCount = allNumbers.Count % 2 == 0; + if (hasEvenCount) + return (allNumbers[halfIndex - 1] + allNumbers[halfIndex]) / 2; + + return allNumbers[halfIndex]; + } + + private static AnyValue Min(CalcContext ctx, Span args) + { + return Min(ctx, args, TallyNumbers.Default); + } + + internal static AnyValue Min(CalcContext ctx, Span args, ITally tally) + { + var result = tally.Tally(ctx, args, new MinState()); + + if (!result.TryPickT0(out var state, out var error)) + return error; + + // Not even one non-ignored value found, return 0. + if (!state.HasValues) + return 0; + + return state.Min; + } + + private static AnyValue MinA(CalcContext ctx, Span args) { - return GetTally(p, true).Geomean(); + return Min(ctx, args, TallyAll.Default); } - private static object Max(List p) + private static AnyValue StDev(CalcContext ctx, Span args) { - return GetTally(p, true).Max(); + return StDev(ctx, args, TallyNumbers.Default); } - private static object MaxA(List p) + internal static AnyValue StDev(CalcContext ctx, Span args, ITally tally) { - return GetTally(p, false).Max(); + if (!GetSquareDiffSum(ctx, args, tally).TryPickT0(out var squareDiff, out var error)) + return error; + + if (squareDiff.Count <= 1) + return XLError.DivisionByZero; + + return Math.Sqrt(squareDiff.Sum / (squareDiff.Count - 1)); } - private static object Median(List p) + private static AnyValue StDevA(CalcContext ctx, Span args) { - return GetTally(p, false).Median(); + return StDev(ctx, args, TallyAll.Default); } - private static object Min(List p) + private static AnyValue StDevP(CalcContext ctx, Span args) { - return GetTally(p, true).Min(); + return StDevP(ctx, args, TallyNumbers.Default); } - private static object MinA(List p) + internal static AnyValue StDevP(CalcContext ctx, Span args, ITally tally) { - return GetTally(p, false).Min(); + if (!GetSquareDiffSum(ctx, args, tally).TryPickT0(out var squareDiff, out var error)) + return error; + + if (squareDiff.Count < 1) + return XLError.DivisionByZero; + + return Math.Sqrt(squareDiff.Sum / squareDiff.Count); } - private static object StDev(List p) + private static AnyValue StDevPA(CalcContext ctx, Span args) { - return GetTally(p, true).Std(); + return StDevP(ctx, args, TallyAll.Default); } - private static object StDevA(List p) + private static AnyValue Var(CalcContext ctx, Span args) { - return GetTally(p, false).Std(); + return Var(ctx, args, TallyNumbers.Default); } - private static object StDevP(List p) + internal static AnyValue Var(CalcContext ctx, Span args, ITally tally) { - return GetTally(p, true).StdP(); + if (!GetSquareDiffSum(ctx, args, tally).TryPickT0(out var squareDiff, out var error)) + return error; + + if (squareDiff.Count <= 1) + return XLError.DivisionByZero; + + return squareDiff.Sum / (squareDiff.Count - 1); + } + + private static AnyValue VarA(CalcContext ctx, Span args) + { + return Var(ctx, args, TallyAll.Default); } - private static object StDevPA(List p) + private static AnyValue VarP(CalcContext ctx, Span args) { - return GetTally(p, false).StdP(); + return VarP(ctx, args, TallyNumbers.Default); } - private static object Var(List p) + internal static AnyValue VarP(CalcContext ctx, Span args, ITally tally) { - return GetTally(p, true).Var(); + if (!GetSquareDiffSum(ctx, args, tally).TryPickT0(out var squareDiff, out var error)) + return error; + + if (squareDiff.Count < 1) + return XLError.DivisionByZero; + + return squareDiff.Sum / squareDiff.Count; } - private static object VarA(List p) + private static AnyValue VarPA(CalcContext ctx, Span args) { - return GetTally(p, false).Var(); + return VarP(ctx, args, TallyAll.Default); } - private static object VarP(List p) + private static AnyValue Large(CalcContext ctx, AnyValue arrayParam, double kParam) { - return GetTally(p, true).VarP(); + if (kParam < 1) + return XLError.NumberInvalid; + + var k = (int)Math.Ceiling(kParam); + + IEnumerable values; + int size; + if (arrayParam.TryPickScalar(out var scalar, out var collection)) + { + if (!scalar.ToNumber(ctx.Culture).TryPickT0(out var number, out var error)) + return error; + + values = new ScalarValue[] { number }; + size = 1; + } + else if (collection.TryPickT0(out var array, out var reference)) + { + values = array; + size = array.Width * array.Height; + } + else + { + values = reference.GetCellsValues(ctx); + size = reference.NumberOfCells; + } + + // Pre-allocate array to reduce allocations during doubling of buffer. + var total = new List(size); + foreach (var value in values) + { + if (value.IsError) + return value.GetError(); + + if (value.IsNumber) + total.Add(value.GetNumber()); + } + + if (k > total.Count) + return XLError.NumberInvalid; + + total.Sort(); + + return total[^k]; } - private static object VarPA(List p) + /// + /// Calculate SUM((x_i - mean_x)^2) and number of samples. This method uses two-pass algorithm. + /// There are several one-pass algorithms, but they are not numerically stable. In this case, accuracy + /// takes precedence (plus VAR/STDEV are not a very frequently used function). Excel might have used + /// those one-pass formulas in the past (see Statistical flaws in Excel), but doesn't seem to + /// be using them anymore. + /// + private static OneOf GetSquareDiffSum(CalcContext ctx, Span args, ITally tally) { - return GetTally(p, false).VarP(); + if (!tally.Tally(ctx, args, new SumState(0.0, 0)).TryPickT0(out var sumState, out var sumError)) + return sumError; + + if (sumState.Count == 0) + return new SquareDiff(0.0, 0, double.NaN); + + var sampleMean = sumState.Sum / sumState.Count; + + // Calculate sum of squares of deviations from sample mean + var initialSquareDiffState = new SquareDiff(Sum: 0.0, Count: 0, SampleMean: sampleMean); + var result = tally.Tally(ctx, args, initialSquareDiffState); + + if (!result.TryPickT0(out var squareDiff, out var error)) + return error; + + return squareDiff; + } + + private readonly record struct SumState(double Sum, int Count) : ITallyState + { + public SumState Tally(double number) => new(Sum + number, Count + 1); + } + + private readonly record struct SquareDiff(double Sum, int Count, double SampleMean) : ITallyState + { + public SquareDiff Tally(double sampleValue) + { + var diff = sampleValue - SampleMean; + var sum = Sum + diff * diff; + return new SquareDiff(sum, Count + 1, SampleMean); + } + } + + private readonly record struct MinState(double Min, bool HasValues) : ITallyState + { + public MinState() : this(double.MaxValue, false) + { + } + + public MinState Tally(double number) => new(Math.Min(Min, number), true); + } + + private readonly record struct MaxState(double Max, bool HasValues) : ITallyState + { + public MaxState() : this(double.MinValue, false) + { + } + + public MaxState Tally(double number) => new(Math.Max(Max, number), true); + } + + private readonly record struct LogSumState(double LogSum, int Count) : ITallyState + { + public LogSumState Tally(double number) + { + var logSum = LogSum + Math.Log(number); + return new(logSum, Count + 1); + } + } + + private readonly record struct ValuesState(List Values) : ITallyState + { + public ValuesState Tally(double number) + { + Values.Add(number); + return new ValuesState(Values); + } } - // utility for tallying statistics - private static Tally GetTally(List p, bool numbersOnly) + private readonly record struct CountState(int Count) : ITallyState { - return new Tally(p, numbersOnly); + public CountState Tally(double number) => new(Count + 1); } } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/Tally.cs b/ClosedXML/Excel/CalcEngine/Functions/Tally.cs deleted file mode 100644 index 99ffdf432..000000000 --- a/ClosedXML/Excel/CalcEngine/Functions/Tally.cs +++ /dev/null @@ -1,288 +0,0 @@ -// Keep this file CodeMaid organised and cleaned -using ClosedXML.Excel.CalcEngine.Exceptions; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Threading; - -namespace ClosedXML.Excel.CalcEngine -{ - internal class Tally : IEnumerable - { - private readonly List _list = new List(); - private readonly bool NumbersOnly; - - private double[] _numericValues; - - public Tally() - : this(false) - { } - - public Tally(bool numbersOnly) - : this(null, numbersOnly) - { } - - public Tally(IEnumerable p) - : this(p, false) - { } - - public Tally(IEnumerable p, bool numbersOnly) - { - if (p != null) - { - foreach (var e in p) - { - Add(e); - } - } - - NumbersOnly = numbersOnly; - } - - public void Add(Expression e) - { - // handle enumerables - if (e is IEnumerable ienum) - { - foreach (var value in ienum) - { - _list.Add(value); - } - _numericValues = null; - return; - } - - // handle expressions - var val = e.Evaluate(); - if (val is string || !(val is IEnumerable valEnumerable)) - _list.Add(val); - else - foreach (var v in valEnumerable) - _list.Add(v); - - _numericValues = null; - } - - public void AddValue(Object v) - { - _list.Add(v); - _numericValues = null; - } - - public double Average() - { - var nums = NumericValuesInternal(); - if (nums.Length == 0) throw new ApplicationException("No values"); - return nums.Average(); - } - - public double Median() - { - var nums = NumericValuesInternal() - .OrderBy(n => n) - .ToArray(); - - if (nums.Length == 0) throw new ApplicationException("No values"); - - bool hasEvenCount = nums.Length % 2 == 0; - if (hasEvenCount) - { - int numElementsToSkip = (nums.Length / 2) - 1; - - return nums.Skip(numElementsToSkip) - .Take(2) - .Average(); - } - - int medianIndex = (int)Math.Floor(nums.Length / 2d); - - return nums[medianIndex]; - } - - public double Count() - { - return Count(NumbersOnly); - } - - public double Count(bool numbersOnly) - { - if (numbersOnly) - return NumericValuesInternal().Length; - else - return _list.Count(o => !CalcEngineHelpers.ValueIsBlank(o)); - } - - public double DevSq() - { - var nums = NumericValuesInternal(); - if (nums.Length == 0) throw new CellValueException("No numeric parameters."); - - return nums.Sum(x => Math.Pow(x - Average(), 2)); - } - - public double Geomean() - { - var nums = NumericValuesInternal(); - - if (nums.Length == 0) throw new NumberException("No numeric parameters."); - if (HasNonPositiveNumbers()) throw new NumberException("Incorrect parameters. Use only positive numbers in your data."); - - return Math.Pow(Product(), 1.0 / nums.Length); - } - - public IEnumerator GetEnumerator() - { - return _list.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - public double Max() - { - var nums = NumericValuesInternal(); - return nums.Length == 0 ? 0 : nums.Max(); - } - - public double Min() - { - var nums = NumericValuesInternal(); - return nums.Length == 0 ? 0 : nums.Min(); - } - - public IEnumerable NumericValues() - => NumericValuesInternal().AsEnumerable(); - - public double Product() - { - var nums = NumericValuesInternal(); - return nums.Length == 0 - ? 0 - : nums.Aggregate(1d, (a, b) => a * b); - } - - public double Range() => Max() - Min(); - - public double Std() - { - var values = NumericValuesInternal(); - var count = values.Length; - double ret = 0; - if (count != 0) - { - //Compute the Average - double avg = values.Average(); - //Perform the Sum of (value-avg)_2_2 - double sum = values.Sum(d => Math.Pow(d - avg, 2)); - //Put it all together - ret = Math.Sqrt((sum) / (count - 1)); - } - else - { - throw new ApplicationException("No values"); - } - return ret; - } - - public double StdP() - { - var nums = NumericValuesInternal(); - var avg = nums.Average(); - var sum2 = nums.Sum(d => d * d); - var count = nums.Length; - return count <= 1 ? 0 : Math.Sqrt(sum2 / count - avg * avg); - } - - public double Sum() => NumericValuesInternal().Sum(); - - public double Var() - { - var nums = NumericValuesInternal(); - var avg = nums.Average(); - var sum2 = Sum2(nums); - var count = nums.Length; - return count <= 1 ? 0 : (sum2 / count - avg * avg) * count / (count - 1); - } - - public double VarP() - { - var nums = NumericValuesInternal(); - var avg = nums.Average(); - var sum2 = Sum2(nums); - var count = nums.Length; - return count <= 1 ? 0 : sum2 / count - avg * avg; - } - - private static double Sum2(IEnumerable nums) - { - return nums.Sum(d => d * d); - } - - private bool HasNonPositiveNumbers() - { - var nums = NumericValuesInternal(); - return nums.Any(x => x <= 0); - } - - private IEnumerable NumericValuesEnumerable() - { - foreach (var value in _list) - { - if (value is string || !(value is IEnumerable vEnumerable)) - { - if (TryParseToDouble(value, aggressiveConversion: false, out double tmp)) - yield return tmp; - } - else - { - foreach (var v in vEnumerable) - { - if (TryParseToDouble(v, aggressiveConversion: false, out double tmp)) - yield return tmp; - } - } - } - } - - private double[] NumericValuesInternal() - => LazyInitializer.EnsureInitialized(ref _numericValues, () => NumericValuesEnumerable().ToArray()); - - // If aggressiveConversion == true, then try to parse non-numeric types to double too - private bool TryParseToDouble(object value, bool aggressiveConversion, out double d) - { - d = 0; - if (value.IsNumber()) - { - d = Convert.ToDouble(value); - return true; - } - else if (value is Boolean b) - { - if (!aggressiveConversion) return false; - - d = (b ? 1 : 0); - return true; - } - else if (value is DateTime dt) - { - d = dt.ToOADate(); - return true; - } - else if (value is TimeSpan ts) - { - d = ts.TotalDays; - return true; - } - else if (value is string s) - { - if (!aggressiveConversion) return false; - return double.TryParse(s, out d); - } - - return false; - } - } -} diff --git a/ClosedXML/Excel/CalcEngine/Functions/TallyAll.cs b/ClosedXML/Excel/CalcEngine/Functions/TallyAll.cs new file mode 100644 index 000000000..27c4e3a44 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Functions/TallyAll.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel.CalcEngine.Functions; + +/// +/// A tally function for *A functions (e.g. AverageA, MinA, MaxA). The behavior is buggy in Excel, +/// because they doesn't count logical values in array, but do count them in reference ¯\_(ツ)_/¯. +/// +internal class TallyAll : ITally +{ + private readonly bool _ignoreArrayText; + private readonly bool _includeErrors; + private readonly Func> _getNonBlankValues; + + /// + /// + /// Scalar values are converted to number, conversion might lead to errors. + /// Array values includes numbers, ignore logical and text. + /// Reference values include logical, number and text is considered a zero. + /// + /// Errors are propagated. + /// + internal static readonly ITally Default = new TallyAll(ignoreArrayText: true); + + /// + /// + /// Scalar values are converted to number, conversion might lead to errors. + /// Array values includes numbers, text is considered a zero and logical values are ignored. + /// Reference values include logical, number and text is considered a zero. + /// + /// Errors are propagated. + /// + internal static readonly ITally WithArrayText = new TallyAll(ignoreArrayText: false); + + /// + /// + /// Scalar values are converted to number, conversion might lead to errors. + /// Array values includes numbers, text is considered a zero and logical values are ignored. + /// Reference values include logical, number and text is considered a zero. + /// + /// Errors are considered zero and are not propagated. + /// + internal static readonly ITally IncludeErrors = new TallyAll(includeErrors: true); + + /// + /// Tally algorithm for SUBTOTAL functions 1..11. + /// + internal static readonly ITally Subtotal10 = new TallyAll(getNonBlankValues: static (ctx, reference) => ctx.GetFilteredNonBlankValues(reference, "SUBTOTAL")); + + /// + /// Tally algorithm for SUBTOTAL functions 101..111. + /// + internal static readonly ITally Subtotal100 = new TallyAll(getNonBlankValues: static (ctx, reference) => ctx.GetFilteredNonBlankValues(reference, "SUBTOTAL", skipHiddenRows: true)); + + private TallyAll(bool ignoreArrayText = true, bool includeErrors = false, Func>? getNonBlankValues = null) + { + _ignoreArrayText = ignoreArrayText; + _includeErrors = includeErrors; + _getNonBlankValues = getNonBlankValues ?? (static (ctx, reference) => ctx.GetNonBlankValues(reference)); + } + + public OneOf Tally(CalcContext ctx, Span args, T initialState) + where T : ITallyState + { + var state = initialState; + foreach (var arg in args) + { + if (arg.TryPickScalar(out var scalar, out var collection)) + { + // Scalars are converted to number. + if (!scalar.ToNumber(ctx.Culture).TryPickT0(out var number, out var error)) + { + if (!_includeErrors) + return error; + + number = 0; + } + + // All scalars are counted + state = state.Tally(number); + } + else + { + bool isArray; + IEnumerable valuesIterator; + if (collection.TryPickT0(out var array, out var reference)) + { + valuesIterator = array; + isArray = true; + } + else + { + valuesIterator = _getNonBlankValues(ctx, reference); + isArray = false; + } + foreach (var value in valuesIterator) + { + // Blank lines are ignored. Logical are counted in reference, but not in array. + if (!isArray && value.TryPickLogical(out var logical)) + { + state = state.Tally(logical ? 1 : 0); + } + else if (value.TryPickNumber(out var number)) + { + state = state.Tally(number); + } + else if (value.IsText && (!isArray || !_ignoreArrayText)) + { + // Some *A functions consider text in an array (e.g. {"3", "Hello"}) as a zero and others don't. + // The text values from cells behave differently. Unlike array, the *A functions consider cell text as 0. + state = state.Tally(0); + } + else if (value.TryPickError(out var error)) + { + if (!_includeErrors) + return error; + + state = state.Tally(0); + } + } + } + } + + return state; + } +} diff --git a/ClosedXML/Excel/CalcEngine/Functions/TallyCriteria.cs b/ClosedXML/Excel/CalcEngine/Functions/TallyCriteria.cs new file mode 100644 index 000000000..fada01a4c --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Functions/TallyCriteria.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel.CalcEngine.Functions; + +/// +/// Tally for {SUM,COUNT,AVERAGE}IF/S and database function. The created tally must contain +/// all selection areas and associated criteria. The main function is then +/// called with values that will be tallied, based on the areas+criteria in the tally object. +/// +internal class TallyCriteria : ITally +{ + /// + /// A collection of areas that are tested and if all satisfy the criteria, corresponding values + /// in the tally areas are tallied. + /// + private readonly List<(XLRangeAddress Area, Criteria Criteria)> _criteriaRanges = new(); + + /// + /// A method to convert a value in the tally area to a number. If scalar value shouldn't be tallied, return null. + /// + private readonly Func _toNumber; + + internal TallyCriteria() + : this(static cellValue => cellValue.TryPickNumber(out var number) ? number : null) + { + } + + internal TallyCriteria(Func toNumber) + { + _toNumber = toNumber; + } + + /// + /// Add criteria to the tally that limit which values should be tallied. + /// + internal void Add(XLRangeAddress area, Criteria criteria) + { + _criteriaRanges.Add((area, criteria)); + } + + public OneOf Tally(CalcContext ctx, Span args, T initialState) + where T : ITallyState + { + // All criteria functions permit only area reference arguments. Excel ensures this + // invariant by grammar, we just check the the argument value. + var talliedAreas = new List(args.Length); + foreach (var arg in args) + { + if (!arg.TryPickArea(out var tallyArea, out var error)) + return error; + + talliedAreas.Add(tallyArea); + } + + // For each selection area and its criteria, get list of points that satisfy the criteria. + var criteriaPoints = new List<(XLSheetPoint Origin, IEnumerable Enumerable)>(); + foreach (var (area, criteria) in _criteriaRanges) + { + // This is a lazy IEnumerable, it's not yet evaluated. + var areaCriteriaPoints = ctx.GetCriteriaPoints(area, criteria); + var origin = XLSheetRange.FromRangeAddress(area).FirstPoint; + criteriaPoints.Add((origin, areaCriteriaPoints)); + } + + // Get list of points that satisfy all criteria + var talliedCoordinates = GetCombinedCoordinates(criteriaPoints); + + var state = initialState; + foreach (var (rowOfs, colOfs) in talliedCoordinates) + { + foreach (var area in talliedAreas) + { + var origin = area.FirstAddress; + var shifted = new XLSheetPoint(origin.RowNumber + rowOfs, origin.ColumnNumber + colOfs); + var cellValue = ctx.GetCellValue(area.Worksheet, shifted.Row, shifted.Column); + var number = _toNumber(cellValue); + if (number is not null) + state = state.Tally(number.Value); + } + } + + return state; + } + + private static IEnumerable GetCombinedCoordinates(List<(XLSheetPoint Origin, IEnumerable Enumerable)> enumerables) + { + var enumerators = enumerables.Select(e => e.Enumerable.GetEnumerator()).ToList(); + try + { + // Move to the first element + foreach (var enumerator in enumerators) + { + if (!enumerator.MoveNext()) + yield break; + } + + // Until all elements are processed. + while (true) + { + // Do all enumerators have same offset? + var allSame = true; + var minOfs = GetOffset(0); + for (var i = 1; i < enumerables.Count; ++i) + { + var currentOfs = GetOffset(i); + var comparison = currentOfs.CompareTo(minOfs); + if (minOfs != currentOfs) + allSame = false; + + if (comparison < 0) + minOfs = currentOfs; + } + + // If all offsets are same, that means all criteria are + // satisfied for same offset. + if (allSame) + yield return minOfs; + + // Move all enumerators that point at the minimum offset + // to the next element. + for (var i = 0; i < enumerables.Count; ++i) + { + var currentOfs = GetOffset(i); + if (currentOfs.CompareTo(minOfs) <= 0) + { + if (!enumerators[i].MoveNext()) + yield break; + } + } + } + } + finally + { + foreach (var enumerator in enumerators) + enumerator.Dispose(); + } + + XLSheetOffset GetOffset(int i) + { + var origin = enumerables[i].Origin; + var point = enumerators[i].Current; + return point - origin; + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/Functions/TallyNumbers.cs b/ClosedXML/Excel/CalcEngine/Functions/TallyNumbers.cs new file mode 100644 index 000000000..a3ddeb322 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Functions/TallyNumbers.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel.CalcEngine.Functions; + +internal class TallyNumbers : ITally +{ + private readonly bool _ignoreScalarBlank; + private readonly bool _ignoreErrors; + private readonly Func> _getNonBlankValues; + + /// + /// Tally numbers. + /// + internal static readonly TallyNumbers Default = new(); + + /// + /// Ignore blank from scalar values. Basically used for PRODUCT function, so it doesn't end up with 0. + /// + internal static readonly TallyNumbers WithoutScalarBlank = new(ignoreScalarBlank: true); + + /// + /// Tally algorithm for SUBTOTAL functions 1..11. + /// + internal static readonly TallyNumbers Subtotal10 = new(static (ctx, reference) => ctx.GetFilteredNonBlankValues(reference, "SUBTOTAL")); + + /// + /// Tally algorithm for SUBTOTAL functions 101..111. + /// + internal static readonly TallyNumbers Subtotal100 = new(static (ctx, reference) => ctx.GetFilteredNonBlankValues(reference, "SUBTOTAL", skipHiddenRows: true)); + + /// + /// Tally numbers. Any error (including conversion), logical, text is ignored and not tallied. + /// + internal static readonly TallyNumbers IgnoreErrors = new(ignoreErrors: true); + + private TallyNumbers(Func>? getNonBlankValues = null, bool ignoreScalarBlank = false, bool ignoreErrors = false) + { + _ignoreScalarBlank = ignoreScalarBlank; + _ignoreErrors = ignoreErrors; + _getNonBlankValues = getNonBlankValues ?? (static (ctx, reference) => ctx.GetNonBlankValues(reference)); + } + + /// + /// The method tries to convert scalar arguments to numbers, but ignores non-numbers in + /// reference/array. Any error found is propagated to the result. + /// + public OneOf Tally(CalcContext ctx, Span args, T initialState) + where T : ITallyState + { + var tally = initialState; + foreach (var arg in args) + { + if (arg.TryPickScalar(out var scalar, out var collection)) + { + if (_ignoreScalarBlank && scalar.IsBlank) + continue; + + // Scalars are converted to number. + if (!scalar.ToNumber(ctx.Culture).TryPickT0(out var number, out var error)) + { + if (_ignoreErrors) + continue; + + return error; + } + + tally = tally.Tally(number); + } + else + { + var valuesIterator = !collection.TryPickT0(out var array, out var reference) + ? _getNonBlankValues(ctx, reference) + : array; + foreach (var value in valuesIterator) + { + if (value.TryPickError(out var error)) + { + if (_ignoreErrors) + continue; + + return error; + } + + // For arrays and references, only the number type is used. Other types are ignored. + if (value.TryPickNumber(out var number)) + tally = tally.Tally(number); + } + } + } + + return tally; + } +} diff --git a/ClosedXML/Excel/CalcEngine/Functions/Text.cs b/ClosedXML/Excel/CalcEngine/Functions/Text.cs index d24101e10..df310722e 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/Text.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/Text.cs @@ -1,483 +1,744 @@ -using ClosedXML.Excel.CalcEngine.Exceptions; using ExcelNumberFormat; using System; -using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; -using System.Text.RegularExpressions; +using ClosedXML.Excel.CalcEngine.Functions; +using static ClosedXML.Excel.CalcEngine.Functions.SignatureAdapter; namespace ClosedXML.Excel.CalcEngine { internal static class Text { + /// + /// Characters 0x80 to 0xFF of win-1252 encoding. Core doesn't include win-1252 encoding, + /// so keep conversion table in this string. + /// + private const string Windows1252 = + "\u20AC\u0081\u201A\u0192\u201E\u2026\u2020\u2021\u02C6\u2030\u0160\u2039\u0152\u008D\u017D\u008F" + + "\u0090\u2018\u2019\u201C\u201D\u2022\u2013\u2014\u02DC\u2122\u0161\u203A\u0153\u009D\u017E\u0178" + + "\u00A0\u00A1\u00A2\u00A3\u00A4\u00A5\u00A6\u00A7\u00A8\u00A9\u00AA\u00AB\u00AC\u00AD\u00AE\u00AF" + + "\u00B0\u00B1\u00B2\u00B3\u00B4\u00B5\u00B6\u00B7\u00B8\u00B9\u00BA\u00BB\u00BC\u00BD\u00BE\u00BF" + + "\u00C0\u00C1\u00C2\u00C3\u00C4\u00C5\u00C6\u00C7\u00C8\u00C9\u00CA\u00CB\u00CC\u00CD\u00CE\u00CF" + + "\u00D0\u00D1\u00D2\u00D3\u00D4\u00D5\u00D6\u00D7\u00D8\u00D9\u00DA\u00DB\u00DC\u00DD\u00DE\u00DF" + + "\u00E0\u00E1\u00E2\u00E3\u00E4\u00E5\u00E6\u00E7\u00E8\u00E9\u00EA\u00EB\u00EC\u00ED\u00EE\u00EF" + + "\u00F0\u00F1\u00F2\u00F3\u00F4\u00F5\u00F6\u00F7\u00F8\u00F9\u00FA\u00FB\u00FC\u00FD\u00FE\u00FF"; + + private static readonly Lazy> Windows1252Char = new(static () => + Enumerable.Range(0, 0x80).Select(static i => (Char: (char)i, Code: i)) + .Concat(Windows1252.Select(static (c, i) => (Char: c, Code: i + 0x80))) + .ToDictionary(x => x.Code, x => char.ToString(x.Char))); + + private static readonly Lazy> Windows1252Code = new(static () => + Windows1252Char.Value.ToDictionary(x => x.Value[0], x => x.Key)); + public static void Register(FunctionRegistry ce) { - ce.RegisterFunction("ASC", 1, Asc); // Changes full-width (double-byte) English letters or katakana within a character string to half-width (single-byte) characters + ce.RegisterFunction("ASC", 1, 1, Adapt(Asc), FunctionFlags.Scalar); // Changes full-width (double-byte) English letters or katakana within a character string to half-width (single-byte) characters //ce.RegisterFunction("BAHTTEXT Converts a number to text, using the ß (baht) currency format - ce.RegisterFunction("CHAR", 1, _Char); // Returns the character specified by the code number - ce.RegisterFunction("CLEAN", 1, Clean); // Removes all nonprintable characters from text - ce.RegisterFunction("CODE", 1, Code); // Returns a numeric code for the first character in a text string - ce.RegisterFunction("CONCAT", 1, int.MaxValue, Concat, AllowRange.All); // Joins several text items into one text item - - // LEGACY: Remove after switch to new engine. CONCATENATE function doesn't actually accept ranges, but it's legacy implementation has a check and there is a test. - ce.RegisterFunction("CONCATENATE", 1, int.MaxValue, Concatenate, AllowRange.All); // Joins several text items into one text item - ce.RegisterFunction("DOLLAR", 1, 2, Dollar); // Converts a number to text, using the $ (dollar) currency format - ce.RegisterFunction("EXACT", 2, Exact); // Checks to see if two text values are identical - ce.RegisterFunction("FIND", 2, 3, Find); //Finds one text value within another (case-sensitive) - ce.RegisterFunction("FIXED", 1, 3, Fixed); // Formats a number as text with a fixed number of decimals + ce.RegisterFunction("CHAR", 1, 1, Adapt(Char), FunctionFlags.Scalar); // Returns the character specified by the code number + ce.RegisterFunction("CLEAN", 1, 1, Adapt(Clean), FunctionFlags.Scalar); // Removes all nonprintable characters from text + ce.RegisterFunction("CODE", 1, 1, Adapt(Code), FunctionFlags.Scalar); // Returns a numeric code for the first character in a text string + ce.RegisterFunction("CONCAT", 1, 255, Adapt(Concat), FunctionFlags.Future | FunctionFlags.Range, AllowRange.All); // Joins several text items into one text item + ce.RegisterFunction("CONCATENATE", 1, 255, Adapt(Concatenate), FunctionFlags.Scalar); // Joins several text items into one text item + ce.RegisterFunction("DOLLAR", 1, 2, AdaptLastOptional(Dollar, 2), FunctionFlags.Scalar); // Converts a number to text, using the $ (dollar) currency format + ce.RegisterFunction("EXACT", 2, 2, Adapt(Exact), FunctionFlags.Scalar); // Checks to see if two text values are identical + ce.RegisterFunction("FIND", 2, 3, AdaptLastOptional(Find), FunctionFlags.Scalar); //Finds one text value within another (case-sensitive) + ce.RegisterFunction("FIXED", 1, 3, AdaptLastTwoOptional(Fixed, 2, false), FunctionFlags.Scalar); // Formats a number as text with a fixed number of decimals //ce.RegisterFunction("JIS Changes half-width (single-byte) English letters or katakana within a character string to full-width (double-byte) characters - ce.RegisterFunction("LEFT", 1, 2, Left); // LEFTB Returns the leftmost characters from a text value - ce.RegisterFunction("LEN", 1, Len); //, Returns the number of characters in a text string - ce.RegisterFunction("LOWER", 1, Lower); // Converts text to lowercase - ce.RegisterFunction("MID", 3, Mid); // Returns a specific number of characters from a text string starting at the position you specify - ce.RegisterFunction("NUMBERVALUE", 1, 3, NumberValue); // Converts a text argument to a number + ce.RegisterFunction("LEFT", 1, 2, AdaptLastOptional(Left, 1), FunctionFlags.Scalar); // Returns the leftmost characters from a text value + //ce.RegisterFunction("LEFTB", 1, 2, AdaptLastOptional(Leftb, 1), FunctionFlags.Scalar); // Returns the leftmost bytes from a text value + ce.RegisterFunction("LEN", 1, 1, Adapt(Len), FunctionFlags.Scalar); //, Returns the number of characters in a text string + ce.RegisterFunction("LOWER", 1, 1, Adapt(Lower), FunctionFlags.Scalar); // Converts text to lowercase + ce.RegisterFunction("MID", 3, 3, Adapt(Mid), FunctionFlags.Scalar); // Returns a specific number of characters from a text string starting at the position you specify + ce.RegisterFunction("NUMBERVALUE", 1, 3, AdaptNumberValue(NumberValue), FunctionFlags.Scalar | FunctionFlags.Future); // Converts a text argument to a number //ce.RegisterFunction("PHONETIC Extracts the phonetic (furigana) characters from a text string - ce.RegisterFunction("PROPER", 1, Proper); // Capitalizes the first letter in each word of a text value - ce.RegisterFunction("REPLACE", 4, Replace); // Replaces characters within text - ce.RegisterFunction("REPT", 2, Rept); // Repeats text a given number of times - ce.RegisterFunction("RIGHT", 1, 2, Right); // Returns the rightmost characters from a text value - ce.RegisterFunction("SEARCH", 2, 3, Search); // Finds one text value within another (not case-sensitive) - ce.RegisterFunction("SUBSTITUTE", 3, 4, Substitute); // Substitutes new text for old text in a text string - ce.RegisterFunction("T", 1, T); // Converts its arguments to text - ce.RegisterFunction("TEXT", 2, _Text); // Formats a number and converts it to text - ce.RegisterFunction("TEXTJOIN", 3, 254, TextJoin, AllowRange.Except, 0, 1); // Joins text via delimiter - ce.RegisterFunction("TRIM", 1, Trim); // Removes spaces from text - ce.RegisterFunction("UPPER", 1, Upper); // Converts text to uppercase - ce.RegisterFunction("VALUE", 1, Value); // Converts a text argument to a number + ce.RegisterFunction("PROPER", 1, 1, Adapt(Proper), FunctionFlags.Scalar); // Capitalizes the first letter in each word of a text value + ce.RegisterFunction("REPLACE", 4, 4, Adapt(Replace), FunctionFlags.Scalar); // Replaces characters within text + ce.RegisterFunction("REPT", 2, 2, Adapt(Rept), FunctionFlags.Scalar); // Repeats text a given number of times + ce.RegisterFunction("RIGHT", 1, 2, AdaptLastOptional(Right, 1), FunctionFlags.Scalar); // Returns the rightmost characters from a text value + ce.RegisterFunction("SEARCH", 2, 3, AdaptLastOptional(Search), FunctionFlags.Scalar); // Finds one text value within another (not case-sensitive) + ce.RegisterFunction("SUBSTITUTE", 3, 4, AdaptSubstitute(Substitute), FunctionFlags.Scalar); // Substitutes new text for old text in a text string + ce.RegisterFunction("T", 1, 1, Adapt(T), FunctionFlags.Range | FunctionFlags.ReturnsArray, AllowRange.All); // Converts its arguments to text + ce.RegisterFunction("TEXT", 2, 2, Adapt(_Text), FunctionFlags.Scalar); // Formats a number and converts it to text + ce.RegisterFunction("TEXTJOIN", 3, 255, Adapt(TextJoin), FunctionFlags.Range | FunctionFlags.Future, AllowRange.Except, 0, 1); // Joins text via delimiter + ce.RegisterFunction("TRIM", 1, 1, Adapt(Trim), FunctionFlags.Scalar); // Removes spaces from text + ce.RegisterFunction("UPPER", 1, 1, Adapt(Upper), FunctionFlags.Scalar); // Converts text to uppercase + ce.RegisterFunction("VALUE", 1, 1, Adapt(Value), FunctionFlags.Scalar); // Converts a text argument to a number } - private static object _Char(List p) + private static ScalarValue Asc(CalcContext ctx, string text) { - var i = (int)p[0]; - if (i < 1 || i > 255) - throw new CellValueException(string.Format("The number {0} is out of the required range (1 to 255)", i)); + // Excel version only works when authoring language is set to a specific languages (e.g Japanese). + // Function doesn't do anything when Excel is set to most locales (e.g. English). There is no further + // info. For practical purposes, it converts full-width characters from Halfwidth and Fullwidth Forms + // unicode block to half-width variants. - var c = (char)i; - return c.ToString(); + // Because fullwidth code points are in base multilingual plane, I just skip over surrogates. + var sb = new StringBuilder(text.Length); + foreach (int c in text) + sb.Append((char)ToHalfForm(c)); + + return sb.ToString(); + + // Per ODS specification https://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part2.html#ASC + static int ToHalfForm(int c) + { + return c switch + { + >= 0x30A1 and <= 0x30AA when c % 2 == 0 => (c - 0x30A2) / 2 + 0xFF71, // katakana a-o + >= 0x30A1 and <= 0x30AA when c % 2 == 1 => (c - 0x30A1) / 2 + 0xFF67, // katakana small a-o + >= 0x30AB and <= 0x30C2 when c % 2 == 1 => (c - 0x30AB) / 2 + 0xFF76, // katakana ka-chi + >= 0x30AB and <= 0x30C2 when c % 2 == 0 => (c - 0x30AC) / 2 + 0xFF76, // katakana ga-dhi + 0x30C3 => 0xFF6F, // katakana small tsu + >= 0x30C4 and <= 0x30C9 when c % 2 == 0 => (c - 0x30C4) / 2 + 0xFF82, // katakana tsu-to + >= 0x30C4 and <= 0x30C9 when c % 2 == 1 => (c - 0x30C5) / 2 + 0xFF82, // katakana du-do + >= 0x30CA and <= 0x30CE => c - 0x30CA + 0xFF85, // katakana na-no + >= 0x30CF and <= 0x30DD when c % 3 == 0 => (c - 0x30CF) / 3 + 0xFF8A, // katakana ha-ho + >= 0x30CF and <= 0x30DD when c % 3 == 1 => (c - 0x30D0) / 3 + 0xFF8A, // katakana ba-bo + >= 0x30CF and <= 0x30DD when c % 3 == 2 => (c - 0x30d1) / 3 + 0xff8a, // katakana pa-po + >= 0x30DE and <= 0x30E2 => c - 0x30DE + 0xFF8F, // katakana ma-mo + >= 0x30E3 and <= 0x30E8 when c % 2 == 0 => (c - 0x30E4) / 2 + 0xFF94, // katakana ya-yo + >= 0x30E3 and <= 0x30E8 when c % 2 == 1 => (c - 0x30E3) / 2 + 0xFF6C, // katakana small ya - yo + >= 0x30E9 and <= 0x30ED => c - 0x30e9 + 0xff97, // katakana ra-ro + 0x30EF => 0xFF9C, // katakana wa + 0x30F2 => 0xFF66, // katakana wo + 0x30F3 => 0xFF9D, // katakana n + >= 0xFF01 and <= 0xFF5E => c - 0xFF01 + 0x0021, // ASCII characters + 0x2015 => 0xFF70, // HORIZONTAL BAR => HALFWIDTH KATAKANA-HIRAGANA PROLONGED SOUND MARK + 0x2018 => 0x0060, // LEFT SINGLE QUOTATION MARK => GRAVE ACCENT + 0x2019 => 0x0027, // RIGHT SINGLE QUOTATION MARK => APOSTROPHE + 0x201D => 0x0022, // RIGHT DOUBLE QUOTATION MARK => QUOTATION MARK + 0x3001 => 0xFF64, // IDEOGRAPHIC COMMA + 0x3002 => 0xFF61, // IDEOGRAPHIC FULL STOP + 0x300C => 0xFF62, // LEFT CORNER BRACKET + 0x300D => 0xFF63, // RIGHT CORNER BRACKET + 0x309B => 0xFF9E, // KATAKANA-HIRAGANA VOICED SOUND MARK + 0x309C => 0xFF9F, // KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK + 0x30FB => 0xFF65, // KATAKANA MIDDLE DOT + 0x30FC => 0xFF70, // KATAKANA-HIRAGANA PROLONGED SOUND MARK + 0xFFE5 => 0x005C, // FULLWIDTH YEN SIGN => REVERSE SOLIDUS "\" + _ => c + }; + } } - private static object Code(List p) + private static ScalarValue Char(double number) { - var s = (string)p[0]; - return (int)s[0]; + number = Math.Truncate(number); + if (number is < 1 or > 255) + return XLError.IncompatibleValue; + + // Spec says to interpret numbers as values encoded in iso-8859-1. The actual + // encoding depends on authoring language, e.g. JP uses JIS X 0201. Fun fact, + // JP has values 253-255 from iso-8859-1, not JIS. EN/CZ/RU uses win-1252. + // Anyway, there is no way to get a map of all encodings, so let's use one. + // Win-1252 is probably the best default choice, because this function is + // pre-unicode and Excel was mostly sold in US/EU. + var value = checked((int)number); + + return Windows1252Char.Value[value]; } - private static object Concat(List p) + private static ScalarValue Clean(CalcContext ctx, string text) { - var sb = new StringBuilder(); - foreach (var x in p) + // Although standard says it removes only 0..1F, real one removes other characters as + // well. Based on `LEN(CLEAN(UNICHAR(A1))) = 0`, it removes 1-1F and 0x80-0x9F. ODF + // says to remove Cc and Cn, but Excel doesn't seem to remove Cn. + var result = new StringBuilder(text.Length); + foreach (char c in text) { - if (x is IEnumerable enumerable) - { - foreach (var i in enumerable) - sb.Append((string)(new Expression(i))); - } - else - sb.Append((string)x); + int codePoint = c; + if (codePoint is >= 0 and <= 0x1F) + continue; + + if (codePoint is >= 0x80 and <= 0x9F) + continue; + + result.Append(c); } - return sb.ToString(); + + return result.ToString(); } - private static object Concatenate(List p) + private static ScalarValue Code(CalcContext ctx, string text) + { + // CODE should be an inverse function to CHAR + if (text.Length == 0) + return XLError.IncompatibleValue; + + if (!Windows1252Code.Value.TryGetValue(text[0], out var code)) + return Windows1252Code.Value['?']; + + return code; + } + + private static ScalarValue Concat(CalcContext ctx, List texts) { var sb = new StringBuilder(); - foreach (var x in p) + foreach (var array in texts) { - if (x is XObjectExpression objectExpression) + foreach (var scalar in array) { - if (objectExpression.Value is CellRangeReference cellRangeReference) - { - if (!cellRangeReference.Range.RangeAddress.IsValid) - throw new CellReferenceException(); + ctx.ThrowIfCancelled(); + if (!scalar.ToText(ctx.Culture).TryPickT0(out var text, out var error)) + return error; - // Only single cell range references allows at this stage. See unit test for more details - if (cellRangeReference.Range.RangeAddress.NumberOfCells > 1) - throw new CellValueException("This function does not accept cell ranges as parameters."); - } - else - // I'm unsure about what else objectExpression.Value could be, but let's throw CellReferenceException - throw new CellReferenceException(); + sb.Append(text); + if (sb.Length > 32767) + return XLError.IncompatibleValue; } - - sb.Append((string)x); } + return sb.ToString(); } - private static object Find(List p) + private static ScalarValue Concatenate(CalcContext ctx, List texts) { - var srch = (string)p[0]; - var text = (string)p[1]; - var start = 0; - if (p.Count > 2) + var totalLength = texts.Sum(static x => x.Length); + var sb = new StringBuilder(totalLength); + foreach (var text in texts) { - start = (int)p[2] - 1; + sb.Append(text); + if (sb.Length > 32767) + return XLError.IncompatibleValue; } - var index = text.IndexOf(srch, start, StringComparison.Ordinal); - if (index == -1) - throw new ArgumentException("String not found."); - else - return index + 1; + + return sb.ToString(); } - private static object Left(List p) + private static AnyValue Find(CalcContext ctx, String findText, String withinText, OneOf startNum) { - var str = (string)p[0]; - var n = 1; - if (p.Count > 1) + var startIndex = startNum.TryPickT0(out var startNumber, out _) ? (int)Math.Truncate(startNumber) - 1 : 0; + if (startIndex < 0 || startIndex > withinText.Length) + return XLError.IncompatibleValue; + + var text = withinText.AsSpan(startIndex); + var index = text.IndexOf(findText.AsSpan()); + return index == -1 + ? XLError.IncompatibleValue + : index + startIndex + 1; + } + + private static ScalarValue Fixed(CalcContext ctx, double number, double numDecimals, bool suppressComma) + { + numDecimals = Math.Truncate(numDecimals); + + // Excel allows up to 127 decimal digits. The .NET Core 8+ allows it, but older Core and + // Fx are more limited. To keep code sane, use 99, so N99 formatting string works everywhere. + if (numDecimals > 99) + return XLError.IncompatibleValue; + + var culture = ctx.Culture; + if (suppressComma) { - n = (int)p[1]; + culture = (CultureInfo)culture.Clone(); + culture.NumberFormat.NumberGroupSeparator = string.Empty; } - if (n >= str.Length) return str; - return str.Substring(0, n); - } + var rounded = XLMath.Round(number, numDecimals); - private static object Len(List p) - { - return ((string)p[0]).Length; + // Number rounded to tens, hundreds... should be displayed without any decimal places + var digits = Math.Max(numDecimals, 0); + return rounded.ToString("N" + digits, culture); } - private static object Lower(List p) + private static ScalarValue Left(CalcContext ctx, string text, double numChars) { - return ((string)p[0]).ToLower(); - } + if (numChars < 0) + return XLError.IncompatibleValue; + + numChars = Math.Truncate(numChars); + if (numChars >= text.Length) + return text; + + // StringInfo.LengthInTextElements returns a length in graphemes, regardless of + // how is grapheme stored (e.g. denormalized family emoji is 7 code points long, + // with 4 emoji and 3 zero width joiners). + // Generally we should return number of codepoints, at least that's how Excel and + // LibreOffice do it (at least for LEFT). + var i = 0; + while (numChars > 0 && i < text.Length) + { + // Most C# text API will happily ignore invalid surrogate pairs, so do we + i += char.IsSurrogatePair(text, i) ? 2 : 1; + numChars--; + } - private static object Mid(List p) - { - var str = (string)p[0]; - var start = (int)p[1] - 1; - var length = (int)p[2]; - if (start > str.Length - 1) - return String.Empty; - if (start + length > str.Length - 1) - return str.Substring(start); - return str.Substring(start, length); + return text[..i]; } - private static string MatchHandler(Match m) + private static ScalarValue Len(CalcContext ctx, string text) { - return m.Groups[1].Value.ToUpper() + m.Groups[2].Value; + // Excel counts code units, not codepoints, e.g. it returns 2 for emoji in astral + // plane. LibreOffice returns 1 and most other functions (e.g. LEFT) use codepoints, + // not code units. Sanity says count codepoints, but compatibility says code units. + return text.Length; } - private static object Proper(List p) + private static ScalarValue Lower(CalcContext ctx, string text) { - var s = (string)p[0]; - if (s.Length == 0) return ""; + // Spec says "by doing a character-by-character conversion" + // so don't do the whole string at once. + var sb = new StringBuilder(text.Length); + for (var i = 0; i < text.Length; ++i) + { + var c = text[i]; + char lowercase; + if (i == text.Length - 1 && c == 'Σ') + { + // Spec: when Σ (U+03A3) is found in a word-final position, it is converted + // to ς (U+03C2) instead of σ (U+03C3). + lowercase = 'ς'; + } + else + { + lowercase = char.ToLower(c, ctx.Culture); + } - MatchEvaluator evaluator = new MatchEvaluator(MatchHandler); - StringBuilder sb = new StringBuilder(); + sb.Append(lowercase); + } - string pattern = "\\b(\\w)(\\w+)?\\b"; - Regex regex = new Regex(pattern, RegexOptions.Multiline | RegexOptions.IgnoreCase); - return regex.Replace(s.ToLower(), evaluator); + return sb.ToString(); } - private static object Replace(List p) + private static ScalarValue Mid(CalcContext ctx, string text, double startPos, double numChars) { - // old start len new - var s = (string)p[0]; - var start = (int)p[1] - 1; - var len = (int)p[2]; - var rep = (string)p[3]; + // Unlike LEFT, MID uses code units and even cuts off half of surrogates, + // e.g. LEN(MID("😊😊",1,3)) = 3. Also, spec has parameters at wrong places. + if (startPos is < 1 or >= int.MaxValue + 1d || numChars is < 0 or >= int.MaxValue + 1d) + return XLError.IncompatibleValue; - if (s.Length == 0) return rep; + var start = checked((int)Math.Truncate(startPos)) - 1; + var length = checked((int)Math.Truncate(numChars)); + if (start >= text.Length - 1) + return string.Empty; - var sb = new StringBuilder(); - sb.Append(s.Substring(0, start)); - sb.Append(rep); - sb.Append(s.Substring(start + len)); + if (start + length >= text.Length) + return text[start..]; - return sb.ToString(); + return text.Substring(start, length); } - private static object Rept(List p) + private static ScalarValue Proper(CalcContext ctx, string text) { - var sb = new StringBuilder(); - var s = (string)p[0]; - var repeats = (int)p[1]; - if (repeats < 0) throw new IndexOutOfRangeException("repeats"); - for (int i = 0; i < repeats; i++) + if (text.Length == 0) + return string.Empty; + + var culture = ctx.Culture; + var sb = new StringBuilder(text.Length); + var prevWasLetter = false; + foreach (var c in text) { - sb.Append(s); + var casedChar = prevWasLetter + ? char.ToLower(c, culture) + : char.ToUpper(c, culture); + sb.Append(casedChar); + prevWasLetter = char.IsLetter(c); } + return sb.ToString(); } - private static object Right(List p) + private static ScalarValue Replace(CalcContext ctx, string oldText, double startPos, double numChars, string replacement) { - var str = (string)p[0]; - var n = 1; - if (p.Count > 1) - { - n = (int)p[1]; - } + if (startPos is < 1 or >= XLHelper.CellTextLimit + 1) + return XLError.IncompatibleValue; + + if (numChars is < 0 or >= XLHelper.CellTextLimit + 1) + return XLError.IncompatibleValue; - if (n >= str.Length) return str; + var prefixLength = checked((int)startPos) - 1; + if (prefixLength > oldText.Length) + prefixLength = oldText.Length; - return str.Substring(str.Length - n); + var deletedLength = checked((int)numChars); + if (prefixLength + deletedLength > oldText.Length) + deletedLength = oldText.Length - prefixLength; + + // Excel does everything is in code units, produces invalid surrogate pairs and everything. + var sb = new StringBuilder(oldText.Length - deletedLength + replacement.Length); + var text = oldText.AsSpan(); + sb.Append(text[..prefixLength]); + sb.Append(replacement); + sb.Append(text[(prefixLength + deletedLength)..]); + + return sb.ToString(); } - private static string WildcardToRegex(string pattern) + private static ScalarValue Rept(string text, double replicationCount) { - return Regex.Escape(pattern) - .Replace(".", "\\.") - .Replace("\\*", ".*") - .Replace("\\?", "."); + if (replicationCount is < 0 or >= int.MaxValue + 1d) + return XLError.IncompatibleValue; + + // If text is empty, loop could run too many times + if (text.Length == 0) + return string.Empty; + + var count = checked((int)replicationCount); + var resultLength = text.Length * count; + if (resultLength > XLHelper.CellTextLimit) + return XLError.IncompatibleValue; + + var sb = new StringBuilder(resultLength); + for (var i = 0; i < count; ++i) + sb.Append(text); + + return sb.ToString(); } - private static object Search(List p) + private static ScalarValue Right(CalcContext ctx, string text, double numChars) { - var search = WildcardToRegex(p[0]); - var text = (string)p[1]; + // Unlike MID, RIGHT uses codepoint semantic + if (numChars < 0) + return XLError.IncompatibleValue; - if ("" == text) throw new ArgumentException("Invalid input string."); + numChars = Math.Truncate(numChars); + if (numChars >= text.Length) + return text; - var start = 0; - if (p.Count > 2) + var i = text.Length; + while (numChars > 0 && i > 0) { - start = (int)p[2] - 1; + i -= i > 1 && char.IsSurrogatePair(text[i - 2], text[i - 1]) ? 2 : 1; + numChars--; } - Regex r = new Regex(search, RegexOptions.Compiled | RegexOptions.IgnoreCase); - var match = r.Match(text.Substring(start)); - if (!match.Success) - throw new ArgumentException("Search failed."); - else - return match.Index + start + 1; - //var index = text.IndexOf(search, start, StringComparison.OrdinalIgnoreCase); - //if (index == -1) - // throw new ArgumentException("String not found."); - //else - // return index + 1; + return text[i..]; } - private static object Substitute(List p) + private static AnyValue Search(CalcContext ctx, String findText, String withinText, OneOf startNum) { - // get parameters - var text = (string)p[0]; - var oldText = (string)p[1]; - var newText = (string)p[2]; + if (withinText.Length == 0) + return XLError.IncompatibleValue; - if ("" == text) return ""; - if ("" == oldText) return text; + var startIndex = startNum.TryPickT0(out var startNumber, out _) ? (int)Math.Truncate(startNumber) : 1; + startIndex -= 1; + if (startIndex < 0 || startIndex >= withinText.Length) + return XLError.IncompatibleValue; - // if index not supplied, replace all - if (p.Count == 3) - { + var wildcard = new Wildcard(findText); + ReadOnlySpan text = withinText.AsSpan().Slice(startIndex); + var firstIdx = wildcard.Search(text); + if (firstIdx < 0) + return XLError.IncompatibleValue; + + return firstIdx + startIndex + 1; + } + + private static ScalarValue Substitute(CalcContext ctx, string text, string oldText, string newText, double? occurrenceOrMissing) + { + // Replace is case sensitive + if (occurrenceOrMissing is < 1 or >= 2147483647) + return XLError.IncompatibleValue; + + if (text.Length == 0 || oldText.Length == 0) + return text; + + if (occurrenceOrMissing is null) return text.Replace(oldText, newText); + + // There must be at least one loop (>=1), so `pos` will be set to an index or returned as not found + var pos = -1; + var occurrence = (int)occurrenceOrMissing.Value; + for (var i = 0; i < occurrence; ++i) + { + pos = text.IndexOf(oldText, pos + 1, StringComparison.Ordinal); + if (pos < 0) + return text; } - // replace specific instance - int index = (int)p[3]; - if (index < 1) + var textSpan = text.AsSpan(); + var sb = new StringBuilder(text.Length - oldText.Length + newText.Length); + sb.Append(textSpan[..pos]); + sb.Append(newText); + sb.Append(textSpan[(pos + oldText.Length)..]); + return sb.ToString(); + } + + private static AnyValue T(CalcContext ctx, AnyValue value) + { + if (value.TryPickScalar(out var scalar, out var collection)) { - throw new ArgumentException("Invalid index in Substitute."); + if (scalar.TryPickError(out var scalarError)) + return scalarError; + + return scalar.IsText ? scalar.GetText() : string.Empty; } - int pos = text.IndexOf(oldText); - while (pos > -1 && index > 1) + + if (collection.TryPickT0(out var array, out var reference)) { - pos = text.IndexOf(oldText, pos + 1); - index--; + var arrayResult = new ScalarValue[array.Height, array.Width]; + for (var row = 0; row < array.Height; ++row) + { + for (var col = 0; col < array.Width; ++col) + { + ctx.ThrowIfCancelled(); + var element = array[row, col]; + if (element.TryPickError(out var arrayError)) + { + arrayResult[row, col] = arrayError; + } + else if (element.IsText) + { + arrayResult[row, col] = element.GetText(); + } + else + { + arrayResult[row, col] = string.Empty; + } + } + } + + return new ConstArray(arrayResult); } - return pos > -1 - ? text.Substring(0, pos) + newText + text.Substring(pos + oldText.Length) - : text; - } - private static object T(List p) - { - var value = p[0].Evaluate(); - if (value is string) - return value; - else - return ""; + var area = reference.Areas[0]; + var cellValue = ctx.GetCellValue(area.Worksheet, area.FirstAddress.RowNumber, area.FirstAddress.ColumnNumber); + if (cellValue.TryPickError(out var cellError)) + return cellError; + + return cellValue.IsText ? cellValue.GetText() : string.Empty; } - private static object _Text(List p) + private static ScalarValue _Text(CalcContext ctx, ScalarValue value, string format) { - var value = p[0].Evaluate(); - - // Input values of type string don't get any formatting applied. - if (value is string) return value; + // Non-convertible values are turned to string + if (!value.ToNumber(ctx.Culture).TryPickT0(out var number, out _) || value.IsLogical) + { + return value + .ToText(ctx.Culture) + .Match(static x => x, static x => x); + } - var number = (double)p[0]; - var format = (string)p[1]; - if (string.IsNullOrEmpty(format.Trim())) return ""; + // Library doesn't format whitespace formats + if (string.IsNullOrWhiteSpace(format)) + return format; var nf = new NumberFormat(format); - if (nf.IsDateTimeFormat) - return nf.Format(DateTime.FromOADate(number), CultureInfo.InvariantCulture); - else - return nf.Format(number, CultureInfo.InvariantCulture); - } + // Values formated as date/time must be in the limit for dates + var isDateFormat = nf.IsDateTimeFormat || nf.IsTimeSpanFormat; + if (isDateFormat && number < 0 || number >= ctx.DateSystemUpperLimit) + return XLError.IncompatibleValue; - /// - /// A function to Join text https://support.office.com/en-us/article/textjoin-function-357b449a-ec91-49d0-80c3-0e8fc845691c - /// - /// Parameters - /// string - /// - /// Delimiter in first param must be a string - /// or - /// Second param must be a boolean (TRUE/FALSE) - /// - private static object TextJoin(List p) - { - var values = new List(); - string delimiter; - bool ignoreEmptyStrings; try { - delimiter = (string)p[0]; - ignoreEmptyStrings = (bool)p[1]; + return nf.Format(number, ctx.Culture); } - catch (Exception e) + catch { - throw new CellValueException("Failed to parse arguments", e); + return XLError.IncompatibleValue; } + } - foreach (var param in p.Skip(2)) + private static ScalarValue TextJoin(CalcContext ctx, string delimiter, bool ignoreEmpty, List texts) + { + var first = true; + var sb = new StringBuilder(); + foreach (var textValue in texts) { - if (param is XObjectExpression tableArray) + // Optimization for large areas, e.g. column ranges + var textElements = ignoreEmpty + ? ctx.GetNonBlankValues(textValue) + : ctx.GetAllValues(textValue); + foreach (var scalar in textElements) { - if (!(tableArray.Value is CellRangeReference rangeReference)) - throw new NoValueAvailableException("tableArray has to be a range"); - - var range = rangeReference.Range; - IEnumerable cellValues; - if (ignoreEmptyStrings) - cellValues = range.CellsUsed() - .Select(c => c.GetString()) - .Where(s => !string.IsNullOrEmpty(s)); + ctx.ThrowIfCancelled(); + if (!scalar.ToText(ctx.Culture).TryPickT0(out var text, out var error)) + return error; + + if (ignoreEmpty && text.Length == 0) + continue; + + if (first) + { + sb.Append(text); + first = false; + } else - cellValues = (range as XLRange).CellValues() - .Cast() - .Select(o => o.ToString()); + { + sb.Append(delimiter).Append(text); + } - values.AddRange(cellValues); - } - else - { - values.Add((string)param); + if (sb.Length > XLHelper.CellTextLimit) + return XLError.IncompatibleValue; } } - var retVal = string.Join(delimiter, values); - - if (retVal.Length > 32767) - throw new CellValueException(); - - return retVal; + return sb.ToString(); } - private static object Trim(List p) + private static ScalarValue Trim(CalcContext ctx, string text) { - //Should not trim non breaking space - //See http://office.microsoft.com/en-us/excel-help/trim-function-HP010062581.aspx - return ((string)p[0]).Trim(' '); + const char space = ' '; + var span = text.AsSpan().Trim(space); + var sb = new StringBuilder(span.Length); + for (var i = 0; i < span.Length; ++i) + { + sb.Append(span[i]); + if (span[i] == space) + { + while (i < span.Length - 1 && span[i + 1] == space) + i++; + } + } + + return sb.ToString(); } - private static object Upper(List p) + private static ScalarValue Upper(CalcContext ctx, string text) { - return ((string)p[0]).ToUpper(); + return text.ToUpper(ctx.Culture); } - private static object Value(List p) + private static AnyValue Value(CalcContext ctx, ScalarValue arg) { - return double.Parse(p[0], NumberStyles.Any, CultureInfo.InvariantCulture); + // Specification is vague/misleading: + // * function accepts significantly more diverse range of inputs e.g. result of "($100)" is -100 + // despite braces not being part of any default number format. + // * Different cultures work weird, e.g. 7:30 PM is detected as 19:30 in cs locale despite "PM" designator being "odp." + // * Formats 14 and 22 differ depending on the locale (that is why in dialogue are with a '*' sign) + if (arg.IsBlank) + return 0; + + if (arg.TryPickNumber(out var number)) + return number; + + if (!arg.TryPickText(out var text, out var error)) + return error; + + const string percentSign = "%"; + var isPercent = text.IndexOf(percentSign, StringComparison.Ordinal) >= 0; + var textWithoutPercent = isPercent ? text.Replace(percentSign, string.Empty) : text; + if (double.TryParse(textWithoutPercent, NumberStyles.Any, ctx.Culture, out var parsedNumber)) + return isPercent ? parsedNumber / 100d : parsedNumber; + + // fraction not parsed, maybe in the future + // No idea how Date/Time parsing works, good enough for initial approach + var dateTimeFormats = new[] + { + ctx.Culture.DateTimeFormat.ShortDatePattern, + ctx.Culture.DateTimeFormat.YearMonthPattern, + ctx.Culture.DateTimeFormat.ShortTimePattern, + ctx.Culture.DateTimeFormat.LongTimePattern, + @"mm-dd-yy", // format 14 + @"d-MMMM-yy", // format 15 + @"d-MMMM", // format 16 + @"d-MMM-yyyy", + @"H:mm", // format 20 + @"H:mm:ss" // format 21 + }; + const DateTimeStyles dateTimeStyle = DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.NoCurrentDateDefault; + if (DateTime.TryParseExact(text, dateTimeFormats, ctx.Culture, dateTimeStyle, out var parsedDate)) + return parsedDate.ToOADate(); + + return XLError.IncompatibleValue; } - private static object NumberValue(List p) + private static ScalarValue NumberValue(CalcContext ctx, string text, string decimalSeparator, string groupSeparator) { - var numberFormatInfo = new NumberFormatInfo(); + if (decimalSeparator.Length == 0) + return XLError.IncompatibleValue; - numberFormatInfo.NumberDecimalSeparator = p.Count > 1 ? p[1] : CultureInfo.InvariantCulture.NumberFormat.NumberDecimalSeparator; - numberFormatInfo.CurrencyDecimalSeparator = numberFormatInfo.NumberDecimalSeparator; + if (groupSeparator.Length == 0) + return XLError.IncompatibleValue; - numberFormatInfo.NumberGroupSeparator = p.Count > 2 ? p[2] : CultureInfo.InvariantCulture.NumberFormat.NumberGroupSeparator; - numberFormatInfo.CurrencyGroupSeparator = numberFormatInfo.NumberGroupSeparator; + if (text.Length == 0) + return 0; - if (numberFormatInfo.NumberDecimalSeparator == numberFormatInfo.NumberGroupSeparator) + var decimalSep = decimalSeparator[0]; + var groupSep = groupSeparator[0]; + if (decimalSep == groupSep) + return XLError.IncompatibleValue; + + // Protect against taking up too much stack in stackalloc + if (text.Length >= 256) + return XLError.IncompatibleValue; + + // Process by ODF specification. Add one character for optional 0 before decimal. + Span textSpan = stackalloc char[text.Length + 1]; + var newLength = 0; + var decimalSeen = false; + foreach (var c in text) { - throw new CellValueException("CurrencyDecimalSeparator and CurrencyGroupSeparator have to be different."); + if (c == decimalSep) + { + // Only first decimal separator should be replaced by '.' + textSpan[newLength++] = !decimalSeen ? '.' : c; + decimalSeen = true; + } + else if (c == groupSep && !decimalSeen) + { + // Do nothing. Skip all group separators before first encounter of decimal one + } + else if (!char.IsWhiteSpace(c)) + { + textSpan[newLength++] = c; + } } - //Remove all whitespace characters - var input = Regex.Replace(p[0], @"\s+", "", RegexOptions.Compiled); - if (string.IsNullOrEmpty(input)) + if (textSpan.Length > 0 && textSpan[0] == '.') { - return 0d; + textSpan[..newLength].CopyTo(textSpan[1..]); + textSpan[0] = '0'; + newLength++; } - if (double.TryParse(input, NumberStyles.Any, numberFormatInfo, out var result)) + textSpan = textSpan[..newLength]; + + // Count percent signs at the end + var percentCount = 0; + while (textSpan.Length > 0 && textSpan[^1] == '%') { - if (result <= -1e308 || result >= 1e308) - throw new CellValueException("The value is too large"); + textSpan = textSpan[..^1]; + percentCount++; + } - if (result >= -1e-309 && result <= 1e-309 && result != 0) - throw new CellValueException("The value is too tiny"); + if (!double.TryParse(textSpan.ToString(), NumberStyles.Float | NumberStyles.AllowParentheses, CultureInfo.InvariantCulture, out var number)) + return XLError.IncompatibleValue; - if (result >= -1e-308 && result <= 1e-308) - result = 0d; + // Too large exponent can return infinity + if (double.IsInfinity(number)) + return XLError.NumberInvalid; - return result; - } + for (var i = 0; i < percentCount; ++i) + number /= 100.0; - throw new CellValueException("Could not convert the value to a number"); - } + if (number is <= -1e308 or >= 1e308) + return XLError.IncompatibleValue; - private static object Asc(List p) - { - return (string)p[0]; - } + if (number is >= -1e-309 and <= 1e-309 && number != 0) + return XLError.IncompatibleValue; - private static object Clean(List p) - { - var s = (string)p[0]; + if (number is >= -1e-308 and <= 1e-308) + number = 0d; - var result = new StringBuilder(); - foreach (var c in from c in s let b = (byte)c where b >= 32 select c) - { - result.Append(c); - } - return result.ToString(); + return number; } - private static object Dollar(List p) + private static ScalarValue Dollar(CalcContext ctx, double number, double decimals) { - Double value = p[0]; - int dec = p.Count == 2 ? (int)p[1] : 2; + // Excel has limit of 127 decimal places, but C# has limit of 99. + decimals = Math.Truncate(decimals); + if (decimals > 99) + return XLError.IncompatibleValue; - return value.ToString("C" + dec); - } + if (decimals >= 0) + return number.ToString("C" + decimals, ctx.Culture); - private static object Exact(List p) - { - var t1 = (string)p[0]; - var t2 = (string)p[1]; + var factor = Math.Pow(10, -decimals); + var rounded = Math.Round(number / factor, 0, MidpointRounding.AwayFromZero); + if (rounded != 0) + rounded *= factor; - return t1 == t2; + return rounded.ToString("C0", ctx.Culture); } - private static object Fixed(List p) + private static ScalarValue Exact(string lhs, string rhs) { - var numberToFormat = p[0].Evaluate(); - if (numberToFormat is string) - throw new ApplicationException("Input type can't be string"); - - Double value = p[0]; - int decimal_places = p.Count >= 2 ? (int)p[1] : 2; - Boolean no_commas = p.Count == 3 && p[2]; - - var retVal = value.ToString("N" + decimal_places); - if (no_commas) - return retVal.Replace(",", String.Empty); - else - return retVal; + return lhs == rhs; } } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/XLMath.cs b/ClosedXML/Excel/CalcEngine/Functions/XLMath.cs index 3adcaa5d8..c8998d177 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/XLMath.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/XLMath.cs @@ -1,186 +1,128 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel.CalcEngine.Functions { - public static class XLMath + internal static class XLMath { - public static double DegreesToRadians(double degrees) + public static double Asinh(double x) { - return (Math.PI / 180.0) * degrees; + return (Math.Log(x + Math.Sqrt(x * x + 1.0))); } - public static double RadiansToDegrees(double radians) + public static double ACosh(double x) { - return (180.0 / Math.PI) * radians; + return (Math.Log(x + Math.Sqrt((x * x) - 1.0))); } - public static double GradsToRadians(double grads) + public static double ATanh(double x) { - return (grads / 200.0) * Math.PI; + return (Math.Log((1.0 + x) / (1.0 - x)) / 2.0); } - public static double RadiansToGrads(double radians) + public static double Csch(double x) { - return (radians / Math.PI) * 200.0; + return (1.0 / Math.Sinh(x)); } - public static double DegreesToGrads(double degrees) + internal static OneOf CombinChecked(double number, double numberChosen) { - return (degrees / 9.0) * 10.0; - } + if (number < 0 || numberChosen < 0) + return XLError.NumberInvalid; - public static double GradsToDegrees(double grads) - { - return (grads / 10.0) * 9.0; - } + var n = Math.Floor(number); + var k = Math.Floor(numberChosen); - public static double ASinh(double x) - { - return (Math.Log(x + Math.Sqrt(x * x + 1.0))); - } + // Parameter doesn't fit into int. That's how many multiplications Excel allows. + if (n >= int.MaxValue || k >= int.MaxValue) + return XLError.NumberInvalid; - public static double ACosh(double x) - { - return (Math.Log(x + Math.Sqrt((x * x) - 1.0))); - } + if (n < k) + return XLError.NumberInvalid; - public static double ATanh(double x) - { - return (Math.Log((1.0 + x) / (1.0 - x)) / 2.0); - } + var combinations = Combin(n, k); + if (double.IsInfinity(combinations) || double.IsNaN(combinations)) + return XLError.NumberInvalid; - public static double ACoth(double x) - { - //return (Math.Log((x + 1.0) / (x - 1.0)) / 2.0); - return (ATanh(1.0 / x)); + return combinations; } - public static double ASech(double x) + internal static double Combin(double n, double k) { - return (ACosh(1.0 / x)); - } + if (k == 0) return 1; - public static double ACsch(double x) - { - return (ASinh(1.0 / x)); - } + // Don't use recursion, malicious input could exhaust stack. + // Don't calculate directly from factorials, could overflow. + double result = 1; + for (var i = 1; i <= k; i++, n--) + { + result *= n; + result /= i; + } - public static double Sech(double x) - { - return (1.0 / Math.Cosh(x)); + return result; } - public static double Csch(double x) + internal static double Factorial(double n) { - return (1.0 / Math.Sinh(x)); - } + n = Math.Truncate(n); + var factorial = 1d; + while (n > 1) + { + factorial *= n--; - public static double Coth(double x) - { - return (Math.Cosh(x) / Math.Sinh(x)); - } + // n can be very large, stop when we reach infinity. + if (double.IsInfinity(factorial)) + return factorial; + } - public static double Combin(Int32 n, Int32 k) - { - if (k == 0) return 1; - return n * Combin(n - 1, k - 1) / k; + return factorial; } public static Boolean IsEven(Int32 value) { return Math.Abs(value % 2) == 0; } - public static Boolean IsOdd(Int32 value) + + public static Boolean IsEven(double value) { - return Math.Abs(value % 2) != 0; + // Check the number doesn't have any fractions and that it is even. + // Due to rounding after division, only checking for % 2 could fail + // for numbers really close to whole number. + var hasNoFraction = value % 1 == 0; + var isEven = value % 2 == 0; + return hasNoFraction && isEven; } - public static string ToRoman(int number) + public static Boolean IsOdd(Int32 value) { - if ((number < 0) || (number > 3999)) throw new ArgumentOutOfRangeException("insert value betwheen 1 and 3999"); - if (number < 1) return string.Empty; - if (number >= 1000) return "M" + ToRoman(number - 1000); - if (number >= 900) return "CM" + ToRoman(number - 900); - if (number >= 500) return "D" + ToRoman(number - 500); - if (number >= 400) return "CD" + ToRoman(number - 400); - if (number >= 100) return "C" + ToRoman(number - 100); - if (number >= 90) return "XC" + ToRoman(number - 90); - if (number >= 50) return "L" + ToRoman(number - 50); - if (number >= 40) return "XL" + ToRoman(number - 40); - if (number >= 10) return "X" + ToRoman(number - 10); - if (number >= 9) return "IX" + ToRoman(number - 9); - if (number >= 5) return "V" + ToRoman(number - 5); - if (number >= 4) return "IV" + ToRoman(number - 4); - if (number >= 1) return "I" + ToRoman(number - 1); - throw new ArgumentOutOfRangeException("something bad happened"); + return Math.Abs(value % 2) != 0; } - public static int RomanToArabic(string text) + public static Boolean IsOdd(double value) { - if (text.Length == 0) - return 0; - if (text.StartsWith("M", StringComparison.InvariantCultureIgnoreCase)) - return 1000 + RomanToArabic(text.Substring(1)); - if (text.StartsWith("CM", StringComparison.InvariantCultureIgnoreCase)) - return 900 + RomanToArabic(text.Substring(2)); - if (text.StartsWith("D", StringComparison.InvariantCultureIgnoreCase)) - return 500 + RomanToArabic(text.Substring(1)); - if (text.StartsWith("CD", StringComparison.InvariantCultureIgnoreCase)) - return 400 + RomanToArabic(text.Substring(2)); - if (text.StartsWith("C", StringComparison.InvariantCultureIgnoreCase)) - return 100 + RomanToArabic(text.Substring(1)); - if (text.StartsWith("XC", StringComparison.InvariantCultureIgnoreCase)) - return 90 + RomanToArabic(text.Substring(2)); - if (text.StartsWith("L", StringComparison.InvariantCultureIgnoreCase)) - return 50 + RomanToArabic(text.Substring(1)); - if (text.StartsWith("XL", StringComparison.InvariantCultureIgnoreCase)) - return 40 + RomanToArabic(text.Substring(2)); - if (text.StartsWith("X", StringComparison.InvariantCultureIgnoreCase)) - return 10 + RomanToArabic(text.Substring(1)); - if (text.StartsWith("IX", StringComparison.InvariantCultureIgnoreCase)) - return 9 + RomanToArabic(text.Substring(2)); - if (text.StartsWith("V", StringComparison.InvariantCultureIgnoreCase)) - return 5 + RomanToArabic(text.Substring(1)); - if (text.StartsWith("IV", StringComparison.InvariantCultureIgnoreCase)) - return 4 + RomanToArabic(text.Substring(2)); - if (text.StartsWith("I", StringComparison.InvariantCultureIgnoreCase)) - return 1 + RomanToArabic(text.Substring(1)); - - throw new ArgumentOutOfRangeException("text is not a valid roman number"); + var hasNoFraction = value % 1 == 0; + var isOdd = value % 2 != 0; + return hasNoFraction && isOdd; } - public static string ChangeBase(long number, int radix) + public static double Round(double value, double digits) { - if (number < 0) - throw new ArgumentOutOfRangeException("number must be greater or equal to 0"); - if (radix < 2) - throw new ArgumentOutOfRangeException("radix must be greater or equal to 2"); - if (radix > 36) - throw new ArgumentOutOfRangeException("radix must be smaller than or equal to 36"); - - StringBuilder sb = new StringBuilder(); - long remaining = number; - - if (remaining == 0) + digits = Math.Truncate(digits); + if (digits < 0) { - sb.Insert(0, '0'); - } + var coef = Math.Pow(10, Math.Abs(digits)); + var shifted = value / coef; + shifted = Math.Round(shifted, 0, MidpointRounding.AwayFromZero); - while (remaining > 0) - { - var nextDigitDecimal = remaining % radix; - remaining = remaining / radix; + // if coef is infinity + if (shifted == 0) + return 0; - if (nextDigitDecimal < 10) - sb.Insert(0, nextDigitDecimal); - else - sb.Insert(0, (char)(nextDigitDecimal + 55)); + return shifted * coef; } - return sb.ToString(); + // Double can store at most 15 digits and anything below that is float artefact + return Math.Round(value, (int)Math.Min(digits, 15), MidpointRounding.AwayFromZero); } } } diff --git a/ClosedXML/Excel/CalcEngine/Functions/XLMatrix.cs b/ClosedXML/Excel/CalcEngine/Functions/XLMatrix.cs index aec7dcae9..5e1a93272 100644 --- a/ClosedXML/Excel/CalcEngine/Functions/XLMatrix.cs +++ b/ClosedXML/Excel/CalcEngine/Functions/XLMatrix.cs @@ -1,5 +1,6 @@ +#nullable disable + using System; -using System.Linq; using System.Text.RegularExpressions; namespace ClosedXML.Excel.CalcEngine.Functions @@ -18,10 +19,11 @@ public XLMatrix(int iRows, int iCols) // XLMatrix Class constructor { rows = iRows; cols = iCols; - mat = new double[rows,cols]; + mat = new double[rows, cols]; } + public XLMatrix(Double[,] arr) - :this(arr.GetLength(0), arr.GetLength(1)) + : this(arr.GetLength(0), arr.GetLength(1)) { var roCount = arr.GetLength(0); var coCount = arr.GetLength(1); @@ -40,6 +42,21 @@ public XLMatrix(Double[,] arr) set { mat[iRow, iCol] = value; } } + public Boolean IsSingular() + { + for (var row = 0; row < rows; row++) + { + for (var col = 0; col < cols; col++) + { + var element = mat[row, col]; + if (double.IsNaN(element) || double.IsInfinity(element)) + return true; + } + } + + return false; + } + public Boolean IsSquare() { return (rows == cols); @@ -105,9 +122,9 @@ public void SetCol(XLMatrix v, int k) for (var i = k + 1; i < rows; i++) { - L[i, k] = U[i, k]/U[k, k]; + L[i, k] = U[i, k] / U[k, k]; for (var j = k; j < cols; j++) - U[i, j] = U[i, j] - L[i, k]*U[k, j]; + U[i, j] = U[i, j] - L[i, k] * U[k, j]; } } } @@ -144,7 +161,6 @@ public XLMatrix Invert() // Function returns the inverted matrix return inv; } - public double Determinant() // Function for determinant { if (L == null) MakeLU(); @@ -180,8 +196,8 @@ public XLMatrix Duplicate() // Function returns the copy of this matrix for (var i = 0; i < n; i++) { x[i, 0] = b[i, 0]; - for (var j = 0; j < i; j++) x[i, 0] -= A[i, j]*x[j, 0]; - x[i, 0] = x[i, 0]/A[i, i]; + for (var j = 0; j < i; j++) x[i, 0] -= A[i, j] * x[j, 0]; + x[i, 0] = x[i, 0] / A[i, i]; } return x; } @@ -195,8 +211,8 @@ public XLMatrix Duplicate() // Function returns the copy of this matrix for (var i = n - 1; i > -1; i--) { x[i, 0] = b[i, 0]; - for (var j = n - 1; j > i; j--) x[i, 0] -= A[i, j]*x[j, 0]; - x[i, 0] = x[i, 0]/A[i, i]; + for (var j = n - 1; j > i; j--) x[i, 0] -= A[i, j] * x[j, 0]; + x[i, 0] = x[i, 0] / A[i, i]; } return x; } @@ -357,7 +373,7 @@ private static XLMatrix StrassenMultiply(XLMatrix A, XLMatrix B) // Smart matrix for (var i = 0; i < R.rows; i++) for (var j = 0; j < R.cols; j++) for (var k = 0; k < A.cols; k++) - R[i, j] += A[i, k]*B[k, j]; + R[i, j] += A[i, k] * B[k, j]; return R; } @@ -368,11 +384,10 @@ private static XLMatrix StrassenMultiply(XLMatrix A, XLMatrix B) // Smart matrix size *= 2; n++; } - - var h = size/2; + var h = size / 2; - var mField = new XLMatrix[n,9]; + var mField = new XLMatrix[n, 9]; /* * 8x8, 8x8, 8x8, ... @@ -383,7 +398,7 @@ private static XLMatrix StrassenMultiply(XLMatrix A, XLMatrix B) // Smart matrix for (var i = 0; i < n - 4; i++) // rows { - var z = (int) Math.Pow(2, n - i - 1); + var z = (int)Math.Pow(2, n - i - 1); for (var j = 0; j < 9; j++) mField[i, j] = new XLMatrix(z, z); } @@ -425,17 +440,17 @@ private static XLMatrix StrassenMultiply(XLMatrix A, XLMatrix B) // Smart matrix // C12 for (var i = 0; i < Math.Min(h, R.rows); i++) // rows - for (var j = h; j < Math.Min(2*h, R.cols); j++) // cols + for (var j = h; j < Math.Min(2 * h, R.cols); j++) // cols R[i, j] = mField[0, 1 + 3][i, j - h] + mField[0, 1 + 5][i, j - h]; // C21 - for (var i = h; i < Math.Min(2*h, R.rows); i++) // rows + for (var i = h; i < Math.Min(2 * h, R.rows); i++) // rows for (var j = 0; j < Math.Min(h, R.cols); j++) // cols R[i, j] = mField[0, 1 + 2][i - h, j] + mField[0, 1 + 4][i - h, j]; // C22 - for (var i = h; i < Math.Min(2*h, R.rows); i++) // rows - for (var j = h; j < Math.Min(2*h, R.cols); j++) // cols + for (var i = h; i < Math.Min(2 * h, R.rows); i++) // rows + for (var j = h; j < Math.Min(2 * h, R.cols); j++) // cols R[i, j] = mField[0, 1 + 1][i - h, j - h] - mField[0, 1 + 2][i - h, j - h] + mField[0, 1 + 3][i - h, j - h] + mField[0, 1 + 6][i - h, j - h]; @@ -445,10 +460,10 @@ private static XLMatrix StrassenMultiply(XLMatrix A, XLMatrix B) // Smart matrix // function for square matrix 2^N x 2^N private static void StrassenMultiplyRun(XLMatrix A, XLMatrix B, XLMatrix C, int l, XLMatrix[,] f) - // A * B into C, level of recursion, matrix field + // A * B into C, level of recursion, matrix field { var size = A.rows; - var h = size/2; + var h = size / 2; if (size < 32) { @@ -456,7 +471,7 @@ private static void StrassenMultiplyRun(XLMatrix A, XLMatrix B, XLMatrix C, int for (var j = 0; j < C.cols; j++) { C[i, j] = 0; - for (var k = 0; k < A.cols; k++) C[i, j] += A[i, k]*B[k, j]; + for (var k = 0; k < A.cols; k++) C[i, j] += A[i, k] * B[k, j]; } return; } @@ -519,7 +534,7 @@ public static XLMatrix StupidMultiply(XLMatrix m1, XLMatrix m2) // Stupid matrix for (var i = 0; i < result.rows; i++) for (var j = 0; j < result.cols; j++) for (var k = 0; k < m1.cols; k++) - result[i, j] += m1[i, k]*m2[k, j]; + result[i, j] += m1[i, k] * m2[k, j]; return result; } @@ -528,11 +543,11 @@ private static XLMatrix Multiply(double n, XLMatrix m) // Multiplication by cons var r = new XLMatrix(m.rows, m.cols); for (var i = 0; i < m.rows; i++) for (var j = 0; j < m.cols; j++) - r[i, j] = m[i, j]*n; + r[i, j] = m[i, j] * n; return r; } - private static XLMatrix Add(XLMatrix m1, XLMatrix m2) + private static XLMatrix Add(XLMatrix m1, XLMatrix m2) { if (m1.rows != m2.rows || m1.cols != m2.cols) throw new ArgumentException("Matrices must have the same dimensions!"); @@ -591,4 +606,4 @@ public static string NormalizeMatrixString(string matStr) // From Andy - thank y return Multiply(n, m); } } -} \ No newline at end of file +} diff --git a/ClosedXML/Excel/CalcEngine/OneOf.cs b/ClosedXML/Excel/CalcEngine/OneOf.cs index 0a8e9b17f..e9a936827 100644 --- a/ClosedXML/Excel/CalcEngine/OneOf.cs +++ b/ClosedXML/Excel/CalcEngine/OneOf.cs @@ -1,21 +1,22 @@ -using System; +using System; +using System.Diagnostics.CodeAnalysis; namespace ClosedXML.Excel.CalcEngine { internal readonly struct OneOf { private readonly bool _isT0; - private readonly T0 _t0; - private readonly T1 _t1; + private readonly T0? _t0; + private readonly T1? _t1; - private OneOf(bool isT0, T0 t0, T1 t1) + private OneOf(bool isT0, T0? t0, T1? t1) { _isT0 = isT0; _t0 = t0; _t1 = t1; } - public bool TryPickT0(out T0 t0, out T1 t1) + public bool TryPickT0([NotNullWhen(true)] out T0? t0, [NotNullWhen(false)] out T1? t1) { t0 = _t0; t1 = _t1; @@ -30,7 +31,7 @@ public bool TryPickT0(out T0 t0, out T1 t1) public static implicit operator OneOf(T1 t1) => FromT1(t1); - public TResult Match(Func transformT0, Func transformT1) + public TResult Match(Func transformT0, Func transformT1) { return _isT0 ? transformT0(_t0) : transformT1(_t1); } diff --git a/ClosedXML/Excel/CalcEngine/Reference.cs b/ClosedXML/Excel/CalcEngine/Reference.cs index 1a503cfcf..6be9b14ab 100644 --- a/ClosedXML/Excel/CalcEngine/Reference.cs +++ b/ClosedXML/Excel/CalcEngine/Reference.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; @@ -46,6 +46,21 @@ public Reference(IXLRanges ranges) /// internal IReadOnlyList Areas { get; } + /// + /// Get total number of cells coverted by all areas (double counts overlapping areas). + /// + internal int NumberOfCells + { + get + { + var size = 0; + for (var i = 0; i < Areas.Count; ++i) + size += Areas[i].NumberOfCells; + + return size; + } + } + /// /// An iterator over all nonblank cells of the range. Some cells can be iterated /// over multiple times (e.g. a union of two ranges with overlapping cells). @@ -71,7 +86,7 @@ public IEnumerable GetCellsValues(CalcContext ctx) public static OneOf RangeOp(Reference lhs, Reference rhs, XLWorksheet contextWorksheet) { var lhsWorksheets = lhs.Areas.Count == 1 - ? lhs.Areas.Select(a => a.Worksheet).Where(ws => ws is not null).ToList() + ? lhs.Areas.Select(a => a.Worksheet).Where(ws => ws is not null).ToList()! : lhs.Areas.Select(a => a.Worksheet ?? contextWorksheet).Where(ws => ws is not null).Distinct().ToList(); if (lhsWorksheets.Count() > 1) return XLError.IncompatibleValue; @@ -79,7 +94,7 @@ public static OneOf RangeOp(Reference lhs, Reference rhs, XL var lhsWorksheet = lhsWorksheets.SingleOrDefault(); var rhsWorksheets = rhs.Areas.Count == 1 - ? rhs.Areas.Select(a => a.Worksheet).Where(ws => ws is not null).ToList() + ? rhs.Areas.Select(a => a.Worksheet).Where(ws => ws is not null).ToList()! : rhs.Areas.Select(a => a.Worksheet ?? contextWorksheet).Where(ws => ws is not null).Distinct().ToList(); if (rhsWorksheets.Count() > 1) return XLError.IncompatibleValue; @@ -196,7 +211,7 @@ internal bool TryGetSingleCellValue(out ScalarValue value, CalcContext ctx) internal OneOf ToArray(CalcContext context) { if (Areas.Count != 1) - throw new NotImplementedException(); + return XLError.IncompatibleValue; var area = Areas.Single(); diff --git a/ClosedXML/Excel/CalcEngine/ScalarValue.cs b/ClosedXML/Excel/CalcEngine/ScalarValue.cs index 4fb02531c..063167432 100644 --- a/ClosedXML/Excel/CalcEngine/ScalarValue.cs +++ b/ClosedXML/Excel/CalcEngine/ScalarValue.cs @@ -1,5 +1,8 @@ -using System; +#nullable disable + +using System; using System.Globalization; +using ClosedXML.Extensions; namespace ClosedXML.Excel.CalcEngine { @@ -45,6 +48,14 @@ private ScalarValue(byte index, bool logical, double number, string text, XLErro public bool IsBlank => _index == BlankValue; + public bool IsLogical => _index == LogicalValue; + + public bool IsNumber => _index == NumberValue; + + public bool IsText => _index == TextValue; + + public bool IsError => _index == ErrorValue; + public static ScalarValue From(bool logical) => new(LogicalValue, logical, default, default, default); public static ScalarValue From(double number) => new(NumberValue, default, number, default, default); @@ -67,6 +78,42 @@ public static ScalarValue From(string text) public static implicit operator ScalarValue(XLError error) => From(error); + public static implicit operator ScalarValue(XLCellValue cellValue) + { + return cellValue.Type switch + { + XLDataType.Blank => ScalarValue.Blank, + XLDataType.Boolean => cellValue.GetBoolean(), + XLDataType.Number => cellValue.GetNumber(), + XLDataType.Text => cellValue.GetText(), + XLDataType.Error => cellValue.GetError(), + XLDataType.DateTime => cellValue.GetDateTime().ToSerialDateTime(), + XLDataType.TimeSpan => cellValue.GetTimeSpan().ToSerialDateTime(), + _ => throw new InvalidOperationException() + }; + } + + public Boolean GetLogical() => IsLogical ? _logical : throw new InvalidCastException(); + + public Double GetNumber() => IsNumber ? _number : throw new InvalidCastException(); + + public string GetText() => IsText ? _text : throw new InvalidCastException(); + + public XLError GetError() => IsError ? _error : throw new InvalidCastException(); + + internal XLCellValue ToCellValue() + { + return _index switch + { + BlankValue => 0, // The result value of a formula calculation can be blank, but result of formula in a cell value is never blank, but 0. + LogicalValue => _logical, + NumberValue => _number, + TextValue => _text, + ErrorValue => _error, + _ => throw new InvalidOperationException() + }; + } + public TResult Match(Func transformBlank, Func transformLogical, Func transformNumber, Func transformText, Func transformError) { return _index switch @@ -151,22 +198,228 @@ public OneOf ToNumber(CultureInfo culture) }; } - private static OneOf TextToNumber(string text, CultureInfo culture) + /// + /// Parse text to a scalar value. Generally used in formulas or autofilter. + /// + /// Text to parse. + /// Culture used for parsing numbers or dates. + /// Parsed scalar value. + public static ScalarValue Parse(string text, CultureInfo culture) + { + if (text is null) + return Blank; + if (text == String.Empty) + return Blank; + if (StringComparer.OrdinalIgnoreCase.Equals("TRUE", text)) + return true; + if (StringComparer.OrdinalIgnoreCase.Equals("FALSE", text)) + return false; + if (TextToNumber(text, culture).TryPickT0(out var number, out _)) + return number; + if (XLErrorParser.TryParseError(text, out var error)) + return error; + + return text; + } + + public static OneOf TextToNumber(string text, CultureInfo culture) + { + if (string.IsNullOrWhiteSpace(text)) + return XLError.IncompatibleValue; + + // Numbers. The parsing method recognizes braces as negative number, includes currency parsing. + // Format 1 '0' + // 2 '0.00' + // 3 '#,##0' + // 4 '#,##0.00' + // 11 '0.00E+00' + // 48 '##0.0E+0' + if (double.TryParse(text, NumberStyles.Any, culture, out var number)) + return number; + + // Percents. Percent sign can be at both sides. + // Format 9 '0%' + // 10 '0.00%' + var textSpan = text.AsSpan(); // Avoid extra allocations for trimming/substrings if not match + var textSpanTrimmedEnd = textSpan.TrimEnd(); + var percentSymbol = culture.NumberFormat.PercentSymbol.AsSpan(); + if (textSpanTrimmedEnd.EndsWith(percentSymbol)) + return ParsePercent(text, 0, textSpanTrimmedEnd.Length - percentSymbol.Length, culture); + + var textSpanTrimmedStart = textSpan.TrimStart(); + if (textSpanTrimmedStart.StartsWith(percentSymbol)) + { + var newStart = text.Length - textSpanTrimmedStart.Length + percentSymbol.Length; + return ParsePercent(text, newStart, text.Length - newStart, culture); + } + + // Fractions + // Format 12 '# ?/?' + // 13 '# ??/??' + if (FractionParser.TryParse(text, out var fraction)) + return fraction; + + if (ToSerialDateTime(text, culture, out var serialDateTime)) + return serialDateTime; + + return XLError.IncompatibleValue; + + static OneOf ParsePercent(string text, int start, int length, CultureInfo c) + { + text = text.Substring(start, length); + if (double.TryParse(text, NumberStyles.Float + | NumberStyles.AllowThousands + | NumberStyles.AllowParentheses, c, out var percents)) + return percents / 100; + + // other formats don't use '%' sign, but text has it, so just stop for invalid inputs like 'hundred%' + return XLError.IncompatibleValue; + } + } + + public static bool ToSerialDateTime(string text, CultureInfo culture, out double serialDateTime) + { + const DateTimeStyles dateStyle = DateTimeStyles.NoCurrentDateDefault | DateTimeStyles.AllowInnerWhite | DateTimeStyles.AllowTrailingWhite; + + // This date varies by the culture. Keep first before other standard patterns. Must be for both yy and yyyy. + // Format 14 : short date (for en 'm/d/yyyy') + // Format 22 : short date + hours (for en 'm/d/yyyy h:mm') + if (DateTimeParser.TryParseCultureDate(text, culture, out var dateFormat14Or22)) + { + return ToSerialDate(dateFormat14Or22, out serialDateTime); + } + + // Date with names of months. The names of months differ across cultures. + // Format 15 'd-mmm-yy' + if (DateTime.TryParseExact(text, new[] { "d-MMM-yyyy", "d-MMMM-yyyy", "d-MMM-yy", "d-MMMM-yy" }, culture, dateStyle, out var dateFormat15)) + { + return ToSerialDate(dateFormat15, out serialDateTime); + } + + // Since format doesn't have a year, it uses current year + // Format 16 'd-mmm' + if (DateTime.TryParseExact(text, new[] { "d-MMM", "d-MMMM" }, culture, dateStyle, out var dateFormat16)) + { + return ToSerialDate(dateFormat16, out serialDateTime); + } + + // Month and a number. In some cultures, the culture date parsing will interpret this pattern as MMM-dd, but + // that depends on culture date patterns above. Use MMM and MMMM to encompass both abbreviation and full name. + // Format 17 'mmm-yy' + if (DateTime.TryParseExact(text, new[] { "MMM-y", "MMMM-y" }, culture, dateStyle, out var dateFormat17)) + { + if (dateFormat17.Year != DateTime.Now.Year && dateFormat17.Year >= 2030) + dateFormat17 = dateFormat17.AddYears(-100); + + return ToSerialDate(dateFormat17, out serialDateTime); + } + + // Format 18 'h:mm AM/PM', works for both localized and AM/PM literal + // Format 19 'h:mm:ss AM/PM' + if (DateTimeParser.TryParseTimeOfDay(text, culture, out var dateFormat18Or19)) + { + serialDateTime = dateFormat18Or19.ToOADate(); + return true; + } + + // Time span uses a different parser from time of a day. + // Format 20 'h:mm' + // 21 'h:mm:ss' + // 47 'mm:ss.0' + if (TimeSpanParser.TryParseTime(text, culture, out var timeSpan)) + { + serialDateTime = timeSpan.ToSerialDateTime(); + return true; + } + + serialDateTime = default; + return false; + + static bool ToSerialDate(DateTime dateTime, out double serialDate) + { + if (dateTime.Year < 1900) + { + serialDate = default; + return false; + } + + // Excel says 1900 was a leap year :( Replicate an incorrect behavior thanks + // to Lotus 1-2-3 decision from 1983... + var oDate = dateTime.ToOADate(); + const int nonExistent1900Feb29SerialDate = 60; + serialDate = oDate <= nonExistent1900Feb29SerialDate ? oDate - 1 : oDate; + return true; + } + } + + public bool TryPickLogical(out bool logical) { - return double.TryParse(text, NumberStyles.Float, culture, out var number) - ? number - : XLError.IncompatibleValue; + if (_index == LogicalValue) + { + logical = _logical; + return true; + } + + logical = default; + return false; } public bool TryPickNumber(out double number) + { + return TryPickNumber(out number, out _); + } + + public bool TryPickNumber(out double number, out XLError error) { if (_index == NumberValue) { number = _number; + error = default; return true; } number = default; + error = IsError ? _error : XLError.IncompatibleValue; + return false; + } + + /// + /// Try to pick a number (interpret blank as number 0). + /// + public bool TryPickNumberOrBlank(out double number, out XLError error) + { + if (_index == NumberValue) + { + number = _number; + error = default; + return true; + } + + // This is mostly useful for unified approach area + array. Literal array + // can't contain blanks, but area can. In most cases, blank is interpreted as 0. + if (_index == BlankValue) + { + number = 0; + error = default; + return true; + } + + number = default; + error = IsError ? _error : XLError.IncompatibleValue; + return false; + } + + public bool TryPickText(out string text, out XLError error) + { + if (_index == TextValue) + { + text = _text; + error = default; + return true; + } + + text = default; + error = _index == ErrorValue ? _error : XLError.IncompatibleValue; return false; } @@ -181,5 +434,62 @@ public bool TryPickError(out XLError error) error = default; return false; } + + /// + /// Does this value have same type as the other one? + /// + public bool HaveSameType(ScalarValue other) => _index == other._index; + + /// + /// Get the logical value, if it is either blank (false), logical or number (0 = false, otherwise true)a text TRUE or FALSE (case insensitive). + /// + /// Used for coercion in functions. + public bool TryCoerceLogicalOrBlankOrNumberOrText(out Boolean value, out XLError error) + { + switch (_index) + { + case BlankValue: + value = false; + error = default; + return true; + case LogicalValue: + value = _logical; + error = default; + return true; + case NumberValue: + value = _number != 0; + error = default; + return true; + case TextValue when (StringComparer.OrdinalIgnoreCase.Equals(_text, "TRUE")): + value = true; + error = default; + return true; + case TextValue when (StringComparer.OrdinalIgnoreCase.Equals(_text, "FALSE")): + value = false; + error = default; + return true; + case ErrorValue: + value = default; + error = _error; + return false; + default: + value = default; + error = XLError.IncompatibleValue; + return false; + } + } + + public override string ToString() + { + return _index switch + { + BlankValue => "Blank", + LogicalValue => _logical.ToString().ToUpper(), + NumberValue => _number.ToString(CultureInfo.InvariantCulture), + TextValue => _text, + ErrorValue => _error.ToDisplayString(), + _ => throw new InvalidOperationException("Invalid type of scalar value.") + }; + } } } diff --git a/ClosedXML/Excel/CalcEngine/ScalarValueComparer.cs b/ClosedXML/Excel/CalcEngine/ScalarValueComparer.cs new file mode 100644 index 000000000..a000f7c18 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/ScalarValueComparer.cs @@ -0,0 +1,64 @@ +#nullable disable + +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel.CalcEngine +{ + /// + /// A comparer of a scalar logic. Each comparer with it's logic can be accessed through a static property. + /// + internal class ScalarValueComparer : IComparer + { + private readonly StringComparer _stringComparer; + + /// + /// Compare scalar values according to logic of "Sort" data in Excel, though texts are compared case insensitive. + /// + /// + /// Order is + /// + /// Type Number, from low to high + /// Type Text, from low to high (non-culture specific, ordinal compare) + /// Type Logical, FALSE, then TRUE. + /// Type Error, all error values are treated as equal (at least they don't change order). + /// Type Blank, all values are treated as equal. + /// + /// + public static ScalarValueComparer SortIgnoreCase { get; } = new(StringComparer.OrdinalIgnoreCase); + + private ScalarValueComparer(StringComparer stringComparer) + { + _stringComparer = stringComparer; + } + + public int Compare(ScalarValue x, ScalarValue y) + { + var xTypeOrder = GetTypeOrder(in x); + var yTypeOrder = GetTypeOrder(in y); + var typeCompare = xTypeOrder - yTypeOrder; + if (typeCompare != 0) + return typeCompare; + + // Both types are same + if (x.IsLogical) + return x.GetLogical().CompareTo(y.GetLogical()); + if (x.IsNumber) + return x.GetNumber().CompareTo(y.GetNumber()); + if (x.IsText) + return _stringComparer.Compare(x.GetText(), y.GetText()); + + // Blank and errors are always treated as equal + return 0; + + static int GetTypeOrder(in ScalarValue value) + { + if (value.IsNumber) return 0; + if (value.IsText) return 1; + if (value.IsLogical) return 2; + if (value.IsError) return 3; + return 4; /* Blank */ + } + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/TimeSpanParser.cs b/ClosedXML/Excel/CalcEngine/TimeSpanParser.cs new file mode 100644 index 000000000..29098e7f7 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/TimeSpanParser.cs @@ -0,0 +1,177 @@ +#nullable disable + +using System; +using System.Globalization; + +namespace ClosedXML.Excel.CalcEngine +{ + /// + /// A parser of timespan format used by excel during coercion from text to number. parsing methods + /// don't allow for several features required by excel (e.g. seconds/minutes over 60, hours over 24). + /// Parser can parse following formats from ECMA-376, Part 1, §18.8.30. due to standard text-to-number coercion: + /// + /// Format 20 - h:mm. + /// Format 21 - h:mm:ss. + /// Format 47 - mm:ss.0 (format is incorrectly described as mmss.0 in the standard, + /// but fixed in an implementation errata). + /// + /// Timespan is never interpreted through format 45 (mm:ss), instead preferring the format 20 (h:mm). + /// Timespan is never interpreted through format 46 ([h]:mm:ss], such values are covered by format 21 (h:mm:ss). + /// + /// + /// Note that the decimal fraction differs format 20 and 47, thus mere addition of decimal + /// place means significantly different values. Parser also copies features of Excel, like whitespaces around + /// a decimal place (10:20 . 5 is allowed). + /// + /// 20:30 is detected as format 20 and the first number is interpreted as hours, thus the serial time is 0.854167. + /// 20:30.0 is detected as format 47 and the first number is interpreted as minutes, thus the serial time is 0.014236111. + /// + /// + internal static class TimeSpanParser + { + public static bool TryParseTime(string s, CultureInfo ci, out TimeSpan result) + { + var timeSeparator = ci.DateTimeFormat.TimeSeparator; + var decimalSeparator = ci.NumberFormat.NumberDecimalSeparator; + result = default; + var i = 0; + SkipWhitespace(ref i, s); + if (!TryReadNumber(ref i, s, out var hoursOrMinutes)) + return false; + + SkipWhitespace(ref i, s); + + if (!InputMatches(ref i, s, timeSeparator)) + return false; + + SkipWhitespace(ref i, s); + if (i == s.Length) // Special case ' 10 : ' + { + result = new TimeSpan(hoursOrMinutes, 0, 0); + return true; + } + + if (!TryReadNumber(ref i, s, out var minutesOrSeconds)) + return false; + + SkipWhitespace(ref i, s); + + if (i == s.Length) // Case '10:00' + { + result = new TimeSpan(hoursOrMinutes, minutesOrSeconds, 0); + return hoursOrMinutes < 24 || minutesOrSeconds < 60; + } + + if (InputMatches(ref i, s, decimalSeparator)) + { + SkipWhitespace(ref i, s); + var ms = ReadFractionInMs(ref i, s); // '10:20.' is allowed without digits + SkipWhitespace(ref i, s); + result = new TimeSpan(0, 0, hoursOrMinutes, minutesOrSeconds, ms); + return i == s.Length && + (hoursOrMinutes < 60 || minutesOrSeconds < 60); // No check for min/sec over limit + } + + // Longer path for h:m:s[.f] + if (!InputMatches(ref i, s, + timeSeparator)) // There is some other character after '10:00', but only ':' ('10:20:0') + return false; + + SkipWhitespace(ref i, s); + if (i == s.Length) // Case ' 10 : 0 : ' + { + result = new TimeSpan(hoursOrMinutes, minutesOrSeconds, 0); + return hoursOrMinutes < 24 || minutesOrSeconds < 60; + } + + if (!TryReadNumber(ref i, s, out var seconds)) // Seconds + return false; + + // At lost two can be over limit + if ((hoursOrMinutes >= 24 && minutesOrSeconds >= 60) + || (hoursOrMinutes >= 24 && seconds >= 60) + || (minutesOrSeconds >= 60 && seconds >= 60)) + return false; + + + SkipWhitespace(ref i, s); + if (i == s.Length) // Case ' 1 : 0 : 0 . ' + { + result = new TimeSpan(hoursOrMinutes, minutesOrSeconds, seconds); + return true; + } + + if (!InputMatches(ref i, s, + decimalSeparator)) // The only allowed character is a decimal separator for '1:0:0.' + return false; + + SkipWhitespace(ref i, s); + var milliseconds = ReadFractionInMs(ref i, s); + SkipWhitespace(ref i, s); + + if (i == s.Length) // Case ' 1 : 0 : 0 . 0 ' + { + result = new TimeSpan(0, hoursOrMinutes, minutesOrSeconds, seconds, milliseconds); + return (hoursOrMinutes < 24 && minutesOrSeconds < 60) + || (hoursOrMinutes < 24 && seconds < 60) + || (minutesOrSeconds < 60 && seconds < 60); // Just one 1 field under limit is enough + } + + return false; // There was some unexpected chars at the end + + static bool TryReadNumber(ref int i, string t, out int num) + { + var start = i; + num = 0; + var digitCount = 0; + while (i < t.Length && t[i] >= '0' && t[i] <= '9') + { + num = num * 10 + t[i] - '0'; + digitCount++; + i++; + } + + if (digitCount == 0 || num > 9999) + return false; + if (t[start] == '0' && digitCount > 2) + return false; + return true; + } + + static int ReadFractionInMs(ref int i, string t) + { + var num = 0; + var digitCount = 0; + while (i < t.Length && t[i] >= '0' && t[i] <= '9') + { + num = num * 10 + t[i] - '0'; + digitCount++; + i++; + } + + // Maximum resolution is 1 ms of pattern + return (int)Math.Round(num / Math.Pow(10, digitCount - 3), MidpointRounding.AwayFromZero); + } + + static void SkipWhitespace(ref int i, string t) + { + while (i < t.Length && t[i] == ' ') i++; + } + + static bool InputMatches(ref int i, string t, string expected) + { + for (var expectedIdx = 0; expectedIdx < expected.Length; ++expectedIdx) + { + var inputIdx = i + expectedIdx; + if (inputIdx == t.Length || expected[expectedIdx] != t[inputIdx]) + { + return false; + } + } + + i += expected.Length; // Branch can differ depending on the input (':' vs '.'), so move only when input matches + return true; + } + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/Visitors/CollectVisitor.cs b/ClosedXML/Excel/CalcEngine/Visitors/CollectVisitor.cs new file mode 100644 index 000000000..400dbcf69 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Visitors/CollectVisitor.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using ClosedXML.Parser; + +namespace ClosedXML.Excel.CalcEngine.Visitors; + +internal abstract class CollectVisitor : IAstFactory +{ + public virtual object? LogicalValue(TContext context, SymbolRange range, bool value) + { + return default; + } + + public virtual object? NumberValue(TContext context, SymbolRange range, double value) + { + return default; + } + + public virtual object? TextValue(TContext context, SymbolRange range, string text) + { + return default; + } + + public virtual object? ErrorValue(TContext context, SymbolRange range, ReadOnlySpan error) + { + return default; + } + + public virtual object? ArrayNode(TContext context, SymbolRange range, int rows, int columns, IReadOnlyList elements) + { + return default; + } + + public virtual object? BlankNode(TContext context, SymbolRange range) + { + return default; + } + + public virtual object? LogicalNode(TContext context, SymbolRange range, bool value) + { + return default; + } + + public virtual object? ErrorNode(TContext context, SymbolRange range, ReadOnlySpan error) + { + return default; + } + + public virtual object? NumberNode(TContext context, SymbolRange range, double value) + { + return default; + } + + public virtual object? TextNode(TContext context, SymbolRange range, string text) + { + return default; + } + + public virtual object? Reference(TContext context, SymbolRange range, ReferenceArea reference) + { + return default; + } + + public virtual object? SheetReference(TContext context, SymbolRange range, string sheet, ReferenceArea reference) + { + return default; + } + + public virtual object? BangReference(TContext context, SymbolRange range, ReferenceArea reference) + { + return default; + } + + public virtual object? Reference3D(TContext context, SymbolRange range, string firstSheet, string lastSheet, ReferenceArea reference) + { + return default; + } + + public virtual object? ExternalSheetReference(TContext context, SymbolRange range, int workbookIndex, string sheet, + ReferenceArea reference) + { + return default; + } + + public virtual object? ExternalReference3D(TContext context, SymbolRange range, int workbookIndex, string firstSheet, string lastSheet, + ReferenceArea reference) + { + return default; + } + + public virtual object? Function(TContext context, SymbolRange range, ReadOnlySpan functionName, IReadOnlyList arguments) + { + return default; + } + + public virtual object? Function(TContext context, SymbolRange range, string sheetName, ReadOnlySpan functionName, IReadOnlyList args) + { + return default; + } + + public virtual object? ExternalFunction(TContext context, SymbolRange range, int workbookIndex, string sheetName, + ReadOnlySpan functionName, IReadOnlyList arguments) + { + return default; + } + + public virtual object? ExternalFunction(TContext context, SymbolRange range, int workbookIndex, ReadOnlySpan functionName, + IReadOnlyList arguments) + { + return default; + } + + public virtual object? CellFunction(TContext context, SymbolRange range, RowCol cell, IReadOnlyList arguments) + { + return default; + } + + public virtual object? StructureReference(TContext context, SymbolRange range, StructuredReferenceArea area, string? firstColumn, + string? lastColumn) + { + return default; + } + + public virtual object? StructureReference(TContext context, SymbolRange range, string table, StructuredReferenceArea area, + string? firstColumn, string? lastColumn) + { + return default; + } + + public virtual object? ExternalStructureReference(TContext context, SymbolRange range, int workbookIndex, string table, + StructuredReferenceArea area, string? firstColumn, string? lastColumn) + { + return default; + } + + public virtual object? Name(TContext context, SymbolRange range, string name) + { + return default; + } + + public virtual object? SheetName(TContext context, SymbolRange range, string sheet, string name) + { + return default; + } + + public virtual object? BangName(TContext context, SymbolRange range, string name) + { + return default; + } + + public virtual object? ExternalName(TContext context, SymbolRange range, int workbookIndex, string name) + { + return default; + } + + public virtual object? ExternalSheetName(TContext context, SymbolRange range, int workbookIndex, string sheet, string name) + { + return default; + } + + public virtual object? BinaryNode(TContext context, SymbolRange range, BinaryOperation operation, object? leftNode, object? rightNode) + { + return default; + } + + public virtual object? Unary(TContext context, SymbolRange range, UnaryOperation operation, object? node) + { + return default; + } + + public virtual object? Nested(TContext context, SymbolRange range, object? node) + { + return default; + } +} diff --git a/ClosedXML/Excel/CalcEngine/Visitors/FormulaReferences.cs b/ClosedXML/Excel/CalcEngine/Visitors/FormulaReferences.cs new file mode 100644 index 000000000..54ab9ff28 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Visitors/FormulaReferences.cs @@ -0,0 +1,106 @@ +using ClosedXML.Parser; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel.CalcEngine.Visitors; + +/// +/// A collection of all references in the book (not others) found in a formula. +/// Created by . +/// +internal class FormulaReferences +{ + private readonly string _formula; + + private FormulaReferences(string formula) + { + _formula = formula; + } + + /// + /// Is there a #REF! anywhere in the formula? + /// + internal bool ContainsRefError { get; set; } + + /// + /// Areas without a sheet found in the formula. + /// + internal HashSet References { get; } = new(); + + /// + /// Areas with a sheet found in the formula. + /// + internal HashSet SheetReferences { get; } = new(); + + internal HashSet<(string Table, string Column, string Symbol)> StructuredReferences { get; } = new(); + + internal static FormulaReferences ForFormula(string formula) + { + var references = new FormulaReferences(formula); + FormulaParser.CellFormulaA1(formula, references, CollectRefsFactory.Instance); + return references; + } + + internal bool ContainsSheet(string worksheetName) + { + return SheetReferences.Any(x => XLHelper.SheetComparer.Equals(x.Sheet, worksheetName)); + } + + internal XLRanges GetExternalRanges(XLWorkbook workbook, XLSheetPoint anchor) + { + var list = new XLRanges(); + foreach (var reference in SheetReferences) + { + if (workbook.TryGetWorksheet(reference.Sheet, out XLWorksheet sheet)) + { + var rangeAddress = reference.Reference.ToRangeAddress(sheet, anchor); + list.Add(sheet.Range(rangeAddress)); + } + } + + foreach (var (tableName, column, _) in StructuredReferences) + { + if (workbook.TryGetTable(tableName, out var table)) + list.Add(table.DataRange.Column(column)); + } + + return list; + } + + /// + /// Factory to get all references (cells, tables, names) in local workbook. + /// + private class CollectRefsFactory : CollectVisitor + { + public static readonly CollectRefsFactory Instance = new(); + + public override object? ErrorNode(FormulaReferences context, SymbolRange range, ReadOnlySpan error) + { + context.ContainsRefError = true; + return base.ErrorNode(context, range, error); + } + + public override object? Reference(FormulaReferences context, SymbolRange range, ReferenceArea reference) + { + context.References.Add(new XLReference(reference)); + return base.Reference(context, range, reference); + } + + public override object? SheetReference(FormulaReferences context, SymbolRange range, string sheet, ReferenceArea reference) + { + context.SheetReferences.Add(new XLSheetReference(sheet, new XLReference(reference))); + return base.SheetReference(context, range, sheet, reference); + } + + public override object? StructureReference(FormulaReferences context, SymbolRange range, string table, StructuredReferenceArea area, + string? firstColumn, string? lastColumn) + { + // TODO: Temporary placeholder, extract range detection from CalculationVisitor + if (firstColumn is not null) + context.StructuredReferences.Add((table, firstColumn, context._formula.Substring(range.Start, range.End - range.Start))); + + return base.StructureReference(context, range, table, area, firstColumn, lastColumn); + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/Visitors/FormulaTransformation.cs b/ClosedXML/Excel/CalcEngine/Visitors/FormulaTransformation.cs new file mode 100644 index 000000000..5d8f5d028 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Visitors/FormulaTransformation.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; +using ClosedXML.Parser; + +namespace ClosedXML.Excel.CalcEngine.Visitors; + +internal static class FormulaTransformation +{ + private static readonly Lazy FutureFunctionSet = new(() => PrefixTree.Build(XLConstants.FutureFunctionMap.Value.Keys)); + + private static readonly RenameFunctionsVisitor RemapFutureFunctions = new(XLConstants.FutureFunctionMap); + + /// + /// Add necessary prefixes to a user-supplied future functions without a prefix (e.g. + /// acot(A5)/2 to _xlfn.ACOT(A5)/2). + /// + internal static string FixFutureFunctions(string formula, string sheetName, XLSheetPoint origin) + { + // A preliminary check that formula might contain future function. There are two reasons to do this first: + // * Although parsing is relatively cheap, it's not free. Checking for string is far cheaper. + // * Risk management, parser might fail for some formulas and limit fallout in such case. + if (!MightContainFutureFunction(formula.AsSpan())) + return formula; + + return FormulaConverter.ModifyA1(formula, sheetName, origin.Row, origin.Column, RemapFutureFunctions); + } + + private static bool MightContainFutureFunction(ReadOnlySpan formula) + { + for (var i = 0; i < formula.Length; ++i) + { + if (FutureFunctionSet.Value.IsPrefixOf(formula[i..])) + return true; + } + + return false; + } + + /// + /// All functions must have chars in the .-_ range (trie range). + /// + private readonly record struct PrefixTree + { + private const char LowestChar = '.'; + private const char HighestChar = '_'; + + /// + /// Indicates the node represents a full prefix. Leaves are always ends and middle nodes + /// sometimes (e.g. AB and ABC). + /// + private bool IsEnd { get; init; } + + /// + /// Something transitions to this tree. + /// + [MemberNotNullWhen(false, nameof(Transitions))] + private bool IsLeaf => Transitions is null; + + /// + /// Index is a character minus . The possible range of characters + /// is from to . + /// + private PrefixTree[]? Transitions { get; init; } + + public static PrefixTree Build(IEnumerable names) + { + var root = new PrefixTree { Transitions = new PrefixTree[HighestChar - LowestChar + 1] }; + foreach (var name in names) + root.Insert(name.AsSpan()); + + return root; + } + + public bool IsPrefixOf(ReadOnlySpan text) + { + var current = this; + foreach (var c in text) + { + if (current.IsEnd) + return true; + + if (current.Transitions is null) + return false; + + var upperChar = char.ToUpperInvariant(c); + if (upperChar is < LowestChar or > HighestChar) + return false; + + current = current.Transitions[upperChar - LowestChar]; + } + + return current.IsEnd; + } + + private void Insert(ReadOnlySpan functionName) + { + // Prev is necessary to update previous list due to immutability + Debug.Assert(functionName.Length > 0); + var prevTransitions = System.Array.Empty(); + var prevIndex = -1; + var curNode = this; + foreach (var c in functionName) + { + // All future function names are uppercase and in range, no need to transform. + var transitionIndex = c - LowestChar; + if (curNode.IsLeaf) + { + // Current node is a leaf and thus has no transitions. Add them (kind of complicated thanks to readonly struct). + var currentTransitions = new PrefixTree[HighestChar - LowestChar + 1]; + prevTransitions[prevIndex] = prevTransitions[prevIndex] with { Transitions = currentTransitions }; + prevTransitions = currentTransitions; + + // Move along the to a new node + curNode = currentTransitions[transitionIndex]; + } + else + { + prevTransitions = curNode.Transitions; + curNode = curNode.Transitions[transitionIndex]; + } + + prevIndex = transitionIndex; + } + + prevTransitions[prevIndex] = prevTransitions[prevIndex] with { IsEnd = true }; + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/Visitors/RenameFunctionsVisitor.cs b/ClosedXML/Excel/CalcEngine/Visitors/RenameFunctionsVisitor.cs new file mode 100644 index 000000000..34560e2a4 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Visitors/RenameFunctionsVisitor.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using ClosedXML.Parser; + +namespace ClosedXML.Excel.CalcEngine.Visitors; + +/// +/// A visitor for that maps one name of a function to another. +/// +internal class RenameFunctionsVisitor : RefModVisitor +{ + /// + /// Case insensitive dictionary of function names. + /// + private readonly Lazy> _functionMap; + + internal RenameFunctionsVisitor(Lazy> functionMap) + { + _functionMap = functionMap; + } + + protected override ReadOnlySpan ModifyFunction(ModContext ctx, ReadOnlySpan functionName) + { + if (_functionMap.Value.TryGetValue(functionName.ToString(), out var mapped)) + { + return mapped.AsSpan(); + } + + return functionName; + } +} diff --git a/ClosedXML/Excel/CalcEngine/Visitors/RenameRefModVisitor.cs b/ClosedXML/Excel/CalcEngine/Visitors/RenameRefModVisitor.cs new file mode 100644 index 000000000..1e78edd22 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Visitors/RenameRefModVisitor.cs @@ -0,0 +1,44 @@ +using ClosedXML.Parser; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel.CalcEngine.Visitors; + +/// +/// A factory to rename named reference object (sheets, tables ect.). +/// +internal class RenameRefModVisitor : RefModVisitor +{ + private readonly Dictionary? _sheets; + private readonly Dictionary? _tables; + + /// + /// A mapping of sheets, from old name (key) to a new name (value). + /// The null value indicates sheet has been deleted. + /// + internal IReadOnlyDictionary Sheets + { + init => _sheets = value.ToDictionary(x => x.Key, x => x.Value, XLHelper.SheetComparer); + } + + internal IReadOnlyDictionary Tables + { + init => _tables = value.ToDictionary(x => x.Key, x => x.Value, XLHelper.NameComparer); + } + + protected override string? ModifySheet(ModContext ctx, string sheetName) + { + if (_sheets is not null && _sheets.TryGetValue(sheetName, out var newName)) + return newName; + + return sheetName; + } + + protected override string? ModifyTable(ModContext ctx, string tableName) + { + if (_tables is not null && _tables.TryGetValue(tableName, out var newName)) + return newName; + + return tableName; + } +} diff --git a/ClosedXML/Excel/CalcEngine/Wildcard.cs b/ClosedXML/Excel/CalcEngine/Wildcard.cs new file mode 100644 index 000000000..6eaea6c81 --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/Wildcard.cs @@ -0,0 +1,125 @@ +using System; + +namespace ClosedXML.Excel.CalcEngine; + +/// +/// A wildcard is at most 255 chars long text. It can contain * which indicates any number characters (including zero) +/// and ? which indicates any single character. If you need to find * or ? in a text, prefix them with +/// an escape character ~. +/// +internal readonly struct Wildcard +{ + private readonly string _pattern; + private readonly bool _unpairedTilda; + + public Wildcard(string pattern) + { + _pattern = pattern; + var tildes = 0; + while (tildes < _pattern.Length && _pattern[_pattern.Length - tildes - 1] == '~') + tildes++; + + _unpairedTilda = tildes % 2 == 1; + } + + /// + /// Search for the wildcard anywhere in the text. + /// + /// Text used to search for a pattern. + /// zero-based index of a first character in a text that matches to a pattern or -1, if match wasn't found. + public int Search(ReadOnlySpan input) + { + var pattern = _pattern.AsSpan(); + if (_pattern.Length > 255) + return -1; + + if (_unpairedTilda) + pattern = pattern[..^1]; + + for (var i = 0; i <= input.Length; i++) + { + var (isMatch, _) = MatchFromStart(pattern, input[i..]); + if (isMatch) + return i; + } + + return -1; + } + + /// + /// Match the pattern against input. + /// + /// Pattern matches whole input. + public bool Matches(ReadOnlySpan input) + { + var pattern = _pattern.AsSpan(); + if (_pattern.Length > 255) + return false; + + if (_unpairedTilda) + pattern = pattern[..^1]; + + var (isMatch, inputEndIndex) = MatchFromStart(pattern, input); + return isMatch && inputEndIndex == input.Length; + } + + /// + /// Does the start of an input match the pattern? + /// + private static (bool IsMatch, int InputEndIndex) MatchFromStart(ReadOnlySpan pattern, ReadOnlySpan input) + { + var inputIndex = 0; + var patternIndex = 0; + var starIndex = -1; // Index of a last processed '*' in the pattern + var matchIndex = 0; // Input index for last processed '*'. Basically a bookmark for backtracking. + + while (inputIndex < input.Length) + { + if (patternIndex < pattern.Length) + { + if (pattern[patternIndex] == '?') + { + inputIndex++; + patternIndex++; + continue; + } + + if (pattern[patternIndex] == '*') + { + starIndex = patternIndex; + matchIndex = inputIndex; + patternIndex++; + continue; + } + + if (pattern[patternIndex] == '~' && patternIndex + 1 < pattern.Length) + patternIndex++; + + if (char.ToUpperInvariant(pattern[patternIndex]) == char.ToUpperInvariant(input[inputIndex])) + { + inputIndex++; + patternIndex++; + continue; + } + } + + // Pattern didn't match the input. If there was a previous '*', backtrack and try next position in input. + if (starIndex != -1) + { + matchIndex++; + inputIndex = matchIndex; + patternIndex = starIndex + 1; + continue; + } + + // No match for pattern char or pattern is complete while input has characters left + return (patternIndex == pattern.Length, inputIndex); + } + + // The input has been fully matched. Check for remaining '*' in the pattern + while (patternIndex < pattern.Length && pattern[patternIndex] == '*') + patternIndex++; + + return (patternIndex == pattern.Length, inputIndex); + } +} diff --git a/ClosedXML/Excel/CalcEngine/XLAddressComparer.cs b/ClosedXML/Excel/CalcEngine/XLAddressComparer.cs index ca2c1c62c..0241fb1dd 100644 --- a/ClosedXML/Excel/CalcEngine/XLAddressComparer.cs +++ b/ClosedXML/Excel/CalcEngine/XLAddressComparer.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; namespace ClosedXML.Excel.CalcEngine diff --git a/ClosedXML/Excel/CalcEngine/XLCalcEngine.cs b/ClosedXML/Excel/CalcEngine/XLCalcEngine.cs index 3f14c8864..98e3a950c 100644 --- a/ClosedXML/Excel/CalcEngine/XLCalcEngine.cs +++ b/ClosedXML/Excel/CalcEngine/XLCalcEngine.cs @@ -1,219 +1,389 @@ +using ClosedXML.Excel.CalcEngine.Functions; using System; -using System.Collections.Generic; using System.Globalization; using System.Linq; +using ClosedXML.Excel.CalcEngine.Exceptions; namespace ClosedXML.Excel.CalcEngine { - internal class XLCalcEngine : CalcEngine + /// + /// CalcEngine parses strings and returns Expression objects that can + /// be evaluated. + /// + /// + /// This class has three extensibility points: + /// Use the RegisterFunction method to define custom functions. + /// + internal class XLCalcEngine : ISheetListener, IWorkbookListener { - public XLCalcEngine(CultureInfo culture) : base(culture) - { } + private readonly CultureInfo _culture; + private readonly FormulaParser _parser; + private readonly CalculationVisitor _visitor; + private DependencyTree? _dependencyTree; + private XLCalculationChain? _chain; + + public XLCalcEngine(CultureInfo culture) + { + _culture = culture; + var funcRegistry = GetFunctionTable(); + _parser = new FormulaParser(funcRegistry); + _visitor = new CalculationVisitor(funcRegistry); + _dependencyTree = null; + _chain = null; + } /// - /// Get cells that could be used as input of a formula, that could affect the calculated value. + /// Parses a string into an . /// - /// Doesn't work for ranges determined by reference functions and reference operators, e.g. A1:IF(SomeCondition,B1,C1). - /// Formula to analyze. - /// Worksheet used for ranges without sheet. - /// All cells (including newly created blank ones) that are referenced in the formula. - /// . - public bool TryGetPrecedentCells(string expression, XLWorksheet worksheet, out ICollection uniqueCells) - { - // This sucks and doesn't work for adding/removing named ranges/worksheets. Also, it creates new cells for all found ranges. - if (string.IsNullOrWhiteSpace(expression)) - { - uniqueCells = System.Array.Empty(); - return true; - } + /// String to parse. + /// An formula that can be evaluated. + public Formula Parse(string expression) + { + return _parser.GetAst(expression, isA1: true); + } - var remotelyReliable = TryGetPrecedentAreas(expression, worksheet, out var precedentAreas); - if (!remotelyReliable) + /// + /// Add an array formula to the calc engine to manage dirty tracking and evaluation. + /// + internal void AddArrayFormula(XLSheetRange range, XLCellFormula arrayFormula, XLWorksheet sheet) + { + if (_chain is not null && _dependencyTree is not null) { - uniqueCells = null; - return false; + _dependencyTree.AddFormula(new XLBookArea(sheet.Name, range), arrayFormula, sheet.Workbook); + _chain.AppendArea(sheet.SheetId, range); } - var visitedCells = new HashSet(new XLAddressComparer(true)); - - var precedentCells = new XLCells(usedCellsOnly: false, XLCellsUsedOptions.Contents); - foreach (var precedentArea in precedentAreas) - precedentCells.Add(precedentArea); + } - uniqueCells = new List(); - foreach (var cell in precedentCells) + /// + /// Add a formula to the calc engine to manage dirty tracking and evaluation. + /// + internal void AddNormalFormula(XLBookPoint point, string sheetName, XLCellFormula formula, XLWorkbook workbook) + { + if (_chain is not null && _dependencyTree is not null) { - if (!visitedCells.Contains(cell.Address)) - { - visitedCells.Add(cell.Address); - uniqueCells.Add(cell); - } + var pointArea = new XLBookArea(sheetName, new XLSheetRange(point.Point, point.Point)); + _dependencyTree.AddFormula(pointArea, formula, workbook); + _chain.AddLast(point); } - - return true; } - private bool TryGetPrecedentAreas(string expression, XLWorksheet worksheet, out ICollection precedentAreas) + /// + /// Remove formula from dependency tree (=precedents won't mark + /// it as dirty) and remove from the chain. + /// Note that even if formula is used by many cells (e.g. array formula), + /// it is fully removed from dependency tree, but each cells referencing + /// the formula must be removed individually from calc chain. + /// + internal void RemoveFormula(XLBookPoint point, XLCellFormula formula) { - var formula = Parse(expression); - var ctx = new PrecedentAreasContext(worksheet); - var rootValue = formula.AstRoot.Accept(ctx, FormulaRangesVisitor.Default); - if (ctx.HasReferenceErrors/* || ctx.UsesNamedRanges */) + if (_chain is not null && _dependencyTree is not null) { - precedentAreas = null; - return false; + _dependencyTree.RemoveFormula(formula); + _chain.Remove(point); } - - if (rootValue.TryPickT0(out var rootReference, out var _)) - ctx.AddReference(rootReference); - - precedentAreas = ctx.FoundReferences - .SelectMany(x => x.Areas) - .Select(referenceArea => referenceArea.Worksheet is null - ? referenceArea.WithWorksheet(worksheet) - : referenceArea) - .ToList(); - return true; } - private class PrecedentAreasContext + internal void OnAddedSheet(XLWorksheet sheet) { - public PrecedentAreasContext(XLWorksheet worksheet) - { - Worksheet = worksheet; - FoundReferences = new List(); - } + Purge(sheet.Workbook.WorksheetsInternal); + } - public XLWorksheet Worksheet { get; } + internal void OnDeletingSheet(XLWorksheet sheet) + { + Purge(sheet.Workbook.WorksheetsInternal); + } - public List FoundReferences { get; } + public void OnInsertAreaAndShiftDown(XLWorksheet sheet, XLSheetRange area) + { + Purge(sheet.Workbook.WorksheetsInternal); + } - /// - /// Unable to determine all references, e.g. sheet doesn't exist. - /// - public bool HasReferenceErrors { get; set; } + public void OnInsertAreaAndShiftRight(XLWorksheet sheet, XLSheetRange area) + { + Purge(sheet.Workbook.WorksheetsInternal); + } - public bool UsesNamedRanges { get; set; } + public void OnDeleteAreaAndShiftLeft(XLWorksheet sheet, XLSheetRange deletedArea) + { + Purge(sheet.Workbook.WorksheetsInternal); + } - public void AddReference(Reference reference) => FoundReferences.Add(reference); + public void OnDeleteAreaAndShiftUp(XLWorksheet sheet, XLSheetRange deletedArea) + { + Purge(sheet.Workbook.WorksheetsInternal); } - /// - /// Get all ranges in the formula. Note that just because range - /// is in the formula, it doesn't mean it is actually used during evaluation. - /// Because named ranges can change, the result might change between visits. - /// - private class FormulaRangesVisitor : IFormulaVisitor> + private void Purge(XLWorksheets sheets) { - public static readonly FormulaRangesVisitor Default = new(); + _dependencyTree = null; + _chain = null; - public OneOf Visit(PrecedentAreasContext ctx, ReferenceNode node) + // Mark everything as dirty, because there can be stale values + foreach (var sheet in sheets) { - if (node.Prefix is null) - return new Reference(new XLRangeAddress(null, node.Address)); - - if (ctx.Worksheet.Workbook.TryGetWorksheet(node.Prefix?.Sheet, out var ws)) - return new Reference(new XLRangeAddress((XLWorksheet)ws, node.Address)); - - ctx.HasReferenceErrors = true; - return XLError.CellReference; + sheet.Internals.CellsCollection.FormulaSlice.MarkDirty(XLSheetRange.Full); } + } - public OneOf Visit(PrecedentAreasContext ctx, NameNode node) - { - ctx.UsesNamedRanges = true; - - if (!node.TryGetNameRange(ctx.Worksheet, out var range)) - return XLError.NameNotRecognized; - - // TODO: This ignores all other ways a name could reference other cells, like A1+5 - if (!range.IsValid) - { - ctx.HasReferenceErrors = true; - return XLError.CellReference; - } - - return new Reference(range.Ranges); + internal void MarkDirty(XLWorksheet sheet, XLSheetPoint point) + { + MarkDirty(sheet, new XLSheetRange(point, point)); + } + internal void MarkDirty(XLWorksheet sheet, XLSheetRange area) + { + if (_dependencyTree is not null) + { + var bookArea = new XLBookArea(sheet.Name, area); + _dependencyTree.MarkDirty(bookArea); } + } - public OneOf Visit(PrecedentAreasContext ctx, BinaryNode node) + /// + /// Recalculate a workbook or a sheet. + /// + internal void Recalculate(XLWorkbook wb, uint? recalculateSheetId) + { + // Lazy, so initialize chain from wb, if it is empty + if (_chain is null || _dependencyTree is null) { - var leftArg = node.LeftExpression.Accept(ctx, this); - - var rightArg = node.RightExpression.Accept(ctx, this); - - var isLeftReference = leftArg.TryPickT0(out var leftReference, out var leftError); - var isRightReference = rightArg.TryPickT0(out var rightReference, out var rightError); + _chain = XLCalculationChain.CreateFrom(wb); + _dependencyTree = DependencyTree.CreateFrom(wb); + } - if (!isLeftReference && !isRightReference) - return XLError.CellReference; + var sheetIdMap = wb.WorksheetsInternal + .ToDictionary( + sheet => sheet.SheetId, + sheet => (sheet, sheet.Internals.CellsCollection.ValueSlice, sheet.Internals.CellsCollection.FormulaSlice)); - if (isLeftReference && !isRightReference) - { - ctx.AddReference(leftReference); - return rightError; - } - - if (!isLeftReference && isRightReference) + // Each outer loop moves chain one cell ahead. + while (_chain.MoveAhead()) + { + // Inner loop that pushes supporting formulas ahead of current. + // It ends when a cell has been calculated and thus chain can move ahead. + while (true) { - ctx.AddReference(rightReference); - return leftError; + var current = _chain.Current; + var sheetId = current.SheetId; + + // Skip dirty cells from sheets that are not being recalculated + if (recalculateSheetId is not null && sheetId != recalculateSheetId.Value) + { + // Even though cell is dirty, it's in the ignored sheet and + // thus chain can move ahead. + break; + } + + if (!sheetIdMap.TryGetValue(sheetId, out var sheetInfo)) + { + throw new InvalidOperationException($"Unable to find sheet with sheetId {sheetId} for a point ${current.Point}."); + } + + if (_chain.IsCurrentInCycle) + { + throw new InvalidOperationException($"Formula in a cell '${sheetInfo.Sheet.Name}'!${current.Point} is part of a cycle."); + } + + var cellFormula = sheetInfo.FormulaSlice.Get(current.Point); + if (cellFormula is null) + { + throw new InvalidOperationException($"Calculation chain contains a '${sheetInfo.Sheet.Name}'!${current.Point}, but the cell doesn't contain formula."); + } + + if (!cellFormula.IsDirty) + break; + + try + { + ApplyFormula(cellFormula, current.Point, sheetInfo.Sheet, sheetInfo.ValueSlice, + recalculateSheetId); + cellFormula.IsDirty = false; + + // Break out of the inner loop, a dirty cell has been + // calculated and thus chain can move ahead. + break; + } + catch (GettingDataException ex) + { + _chain.MoveToCurrent(ex.Point); + } } - - // Don't add resulting reference into the ctx here, because it still might be turned into an error later (some ranges have many operations A1:B5:C3) - return node.Operation switch - { - BinaryOp.Range => Reference.RangeOp(leftReference, rightReference, ctx.Worksheet), - BinaryOp.Union => Reference.UnionOp(leftReference, rightReference), - BinaryOp.Intersection => throw new NotImplementedException("Range intersection not implemented."), - _ => XLError.CellReference // Binary operation on reference arguments - }; } - public OneOf Visit(PrecedentAreasContext ctx, ScalarNode node) - { - return XLError.CellReference; - } + // Super important to clean up the chain for next recalculation. + // Chain contains shared data and not cleaning it would cause hard + // to diagnose issues. + _chain.Reset(); + } - public OneOf Visit(PrecedentAreasContext ctx, UnaryNode node) + private void ApplyFormula(XLCellFormula formula, XLSheetPoint appliedPoint, XLWorksheet sheet, ValueSlice valueSlice, uint? recalculateSheetId) + { + var formulaText = formula.A1; + if (formula.Type == FormulaType.Normal) { - var value = node.Expression.Accept(ctx, this); - if (!value.TryPickT0(out var reference, out var error)) - return error; - ctx.AddReference(reference); - return XLError.CellReference; + var single = EvaluateFormula( + formulaText, + sheet.Workbook, + sheet, + new XLAddress(sheet, appliedPoint.Row, appliedPoint.Column, true, true), + recalculateSheetId: recalculateSheetId); + valueSlice.SetCellValue(appliedPoint, single.ToCellValue()); } - - public OneOf Visit(PrecedentAreasContext ctx, FunctionNode node) + else if (formula.Type == FormulaType.Array) { - foreach (var param in node.Parameters) + // The point can be any point in an array, so we can't use it. + var range = formula.Range; + var leftTopCorner = range.FirstPoint; + var masterCell = sheet.Cell(leftTopCorner.Row, leftTopCorner.Column); + var array = EvaluateArrayFormula(formulaText, masterCell, recalculateSheetId); + + // The array from formula can be smaller or larger than the + // range of cells it should fit into. Broadcast it to the size. + var result = array.Broadcast(range.Height, range.Width); + + // Copy value to the value slice + for (var rowIdx = 0; rowIdx < result.Height; ++rowIdx) { - var paramResult = param.Accept(ctx, this); - if (paramResult.TryPickT0(out var reference, out _)) - ctx.AddReference(reference); + for (var colIdx = 0; colIdx < result.Width; ++colIdx) + { + var cellValue = result[rowIdx, colIdx]; + var row = range.FirstPoint.Row + rowIdx; + var column = range.FirstPoint.Column + colIdx; + valueSlice.SetCellValue(new XLSheetPoint(row, column), cellValue.ToCellValue()); + } } - return XLError.CellReference; } - - public OneOf Visit(PrecedentAreasContext ctx, NotSupportedNode node) + else { - return XLError.CellReference; + throw new NotImplementedException($"Evaluation of formula type '{formula.Type}' is not supported."); } + } - public OneOf Visit(PrecedentAreasContext ctx, StructuredReferenceNode node) + /// + /// Evaluates a normal formula. + /// + /// Expression to evaluate. + /// Workbook where is formula being evaluated. + /// Worksheet where is formula being evaluated. + /// Address of formula. + /// Should the data necessary for this formula (not deeper ones) + /// be calculated recursively? Used only for non-cell calculations. + /// + /// If set, calculation will allow dirty reads from other sheets than the passed one. + /// + /// The value of the expression. + /// + /// If you are going to evaluate the same expression several times, + /// it is more efficient to parse it only once using the + /// method and then using the Expression.Evaluate method to evaluate + /// the parsed expression. + /// + internal ScalarValue EvaluateFormula(string expression, XLWorkbook? wb = null, XLWorksheet? ws = null, IXLAddress? address = null, bool recursive = false, uint? recalculateSheetId = null) + { + var ctx = new CalcContext(this, _culture, wb, ws, address, recursive) { - throw new NotImplementedException("Structured references are not implemented."); + RecalculateSheetId = recalculateSheetId + }; + var result = EvaluateFormula(expression, ctx); + if (ctx.UseImplicitIntersection) + { + result = result.Match( + () => AnyValue.Blank, + logical => logical, + number => number, + text => text, + error => error, + array => array[0, 0].ToAnyValue(), + reference => reference); } - public OneOf Visit(PrecedentAreasContext ctx, PrefixNode node) + return ToCellContentValue(result, ctx); + } + + private Array EvaluateArrayFormula(string expression, XLCell masterCell, uint? recalculateSheetId) + { + var ctx = new CalcContext(this, _culture, masterCell) { - throw new InvalidOperationException("PrefixNode shouldn't be visited."); - } + IsArrayCalculation = true, + RecalculateSheetId = recalculateSheetId + }; + var result = EvaluateFormula(expression, ctx); + if (result.TryPickSingleOrMultiValue(out var single, out var multi, ctx)) + return new ScalarArray(single, 1, 1); + + return multi; + } + + internal AnyValue EvaluateName(string nameFormula, XLWorksheet ws) + { + var ctx = new CalcContext(this, _culture, ws.Workbook, ws, null); + return EvaluateFormula(nameFormula, ctx); + } + + private AnyValue EvaluateFormula(string expression, CalcContext ctx) + { + var formula = Parse(expression); + var result = formula.AstRoot.Accept(ctx, _visitor); + return result; + } + + // build/get static keyword table + private FunctionRegistry GetFunctionTable() + { + var fr = new FunctionRegistry(); + + // register built-in functions (and constants) + Engineering.Register(fr); + Information.Register(fr); + Logical.Register(fr); + Lookup.Register(fr); + MathTrig.Register(fr); + Text.Register(fr); + Statistical.Register(fr); + DateAndTime.Register(fr); + Financial.Register(fr); + + return fr; + } - public OneOf Visit(PrecedentAreasContext ctx, FileNode node) + /// + /// Convert any kind of formula value to value returned as a content of a cell. + /// + /// bool - represents a logical value. + /// double - represents a number and also date/time as serial date-time. + /// string - represents a text value. + /// - represents a formula calculation error. + /// + /// + private static ScalarValue ToCellContentValue(AnyValue value, CalcContext ctx) + { + if (value.TryPickScalar(out var scalar, out var collection)) + return scalar; + + if (collection.TryPickT0(out var array, out var reference)) { - throw new InvalidOperationException("FileNode shouldn't be visited."); + return array![0, 0]; } + + if (reference!.TryGetSingleCellValue(out var cellValue, ctx)) + return cellValue; + + var intersected = reference.ImplicitIntersection(ctx.FormulaAddress); + if (!intersected.TryPickT0(out var singleCellReference, out var error)) + return error; + + if (!singleCellReference!.TryGetSingleCellValue(out var singleCellValue, ctx)) + throw new InvalidOperationException("Got multi cell reference instead of single cell reference."); + + return singleCellValue; + } + + void IWorkbookListener.OnSheetRenamed(string oldSheetName, string newSheetName) + { + if (_dependencyTree is not null) + _dependencyTree.RenameSheet(oldSheetName, newSheetName); } } + + internal delegate AnyValue CalcEngineFunction(CalcContext ctx, Span arg); } diff --git a/ClosedXML/Excel/CalcEngine/XLCalculationChain.cs b/ClosedXML/Excel/CalcEngine/XLCalculationChain.cs new file mode 100644 index 000000000..7239ec8db --- /dev/null +++ b/ClosedXML/Excel/CalcEngine/XLCalculationChain.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace ClosedXML.Excel.CalcEngine +{ + /// + /// + /// A calculation chain of formulas. Contains all formulas in the workbook. + /// + /// + /// Calculation chain is an ordering of all cells that have value calculated + /// by a formula (note that one formula can determine value of multiple cells, + /// e.g. array). Formulas are calculated in specified order and if currently + /// processed formula needs data from a cell whose value is dirty (i.e. it + /// is determined by a not-yet-calculated formula), the current formula is + /// stopped and the required formula is placed before the current one and starts + /// to be processed. Once it is done, the original formula is starts to be processed + /// again. It might have encounter another not-yet-calculated formula or it + /// will finish and the calculation chain moves to the next one. + /// + /// + /// Chain can be traversed through , , + /// and , but only one traversal + /// can go on at the same time due to shared info about cycle detection. + /// + /// + internal class XLCalculationChain + { + /// + /// Key to the that is the head of the chain. + /// Null, when chain is empty. + /// + private XLBookPoint? _head; + + /// + /// Key to the that is the tail of the chain. + /// Null, when chain is empty. + /// + private XLBookPoint? _tail; + + /// + /// + /// Doubly circular linked list containing all points with value + /// calculated by a formula. The chain is "looped", so it doesn't + /// have to deal with nulls for . + /// + /// + /// There is always exactly one loop, no cycles. The formulas might + /// cause cycles due to dependencies, but that is manifested by + /// constantly switching the links in a loop. + /// + private readonly Dictionary _nodeMap = new(); + + private XLBookPoint? _current; + + /// + /// 1 based position of , if there is a traversal + /// in progress (0 otherwise). + /// + private int _currentPosition; + + /// + /// The address of a current of the chain. + /// + internal XLBookPoint Current => _current!.Value; + + /// + /// Is there a cycle in the chain? Detected when a link has appeared + /// as a current more than once and the current hasn't moved in the + /// meantime. + /// + internal bool IsCurrentInCycle { get; private set; } + + /// + /// Create a new chain filled with all formulas from the workbook. + /// + internal static XLCalculationChain CreateFrom(XLWorkbook wb) + { + var chain = new XLCalculationChain(); + foreach (var sheet in wb.WorksheetsInternal) + { + var formulaSlice = sheet.Internals.CellsCollection.FormulaSlice; + using var e = formulaSlice.GetForwardEnumerator(XLSheetRange.Full); + while (e.MoveNext()) + chain.AddLast(new XLBookPoint(sheet.SheetId, e.Point)); + } + + return chain; + } + + /// + /// Add a new link at the beginning of a chain. + /// + private void AddFirst(XLBookPoint point, int lastPosition) + { + if (_head is null || _tail is null) + { + Init(point); + return; + } + + Insert(point, lastPosition, _tail.Value, _head.Value); + _head = point; + } + + /// + internal void AddLast(XLBookPoint point) => AddLast(point, 0); + + /// + /// Add all cells from the area to the end of the chain. + /// + /// If chain already contains a cell from the area. + internal void AppendArea(uint sheetId, XLSheetRange range) + { + for (var row = range.TopRow; row <= range.BottomRow; ++row) + { + for (var col = range.LeftColumn; col <= range.RightColumn; ++col) + { + AddLast(new XLBookPoint(sheetId, new XLSheetPoint(row, col))); + } + } + } + + /// + /// Append formula at the end of the chain. + /// + private void AddLast(XLBookPoint point, int lastPosition) + { + if (_head is null || _tail is null) + { + Init(point); + return; + } + + Insert(point, lastPosition, _tail.Value, _head.Value); + _tail = point; + } + + /// + /// Initialize empty chain with a single link chain. + /// + private void Init(XLBookPoint point) + { + Debug.Assert(_nodeMap.Count == 0 && _head is null && _tail is null); + _nodeMap.Add(point, new Link(point, point, 0)); + _head = _tail = point; + } + + /// + /// Insert a link into the between + /// and . + /// Don't update head or tail. + /// + private void Insert(XLBookPoint point, int lastPosition, XLBookPoint prev, XLBookPoint next) + { + _nodeMap.Add(point, new Link(prev, next, lastPosition)); + + var prevLink = _nodeMap[prev]; + _nodeMap[prev] = new Link(prevLink.Previous, point, prevLink.LastPosition); + + var nextLink = _nodeMap[next]; + _nodeMap[next] = new Link(point, nextLink.Next, nextLink.LastPosition); + } + + /// + /// Add a link for after the link for + /// . + /// + /// + /// The anchor point after which will be the new point added. + /// + /// Point to add to the chain. + /// The last position of the point in the chain. + internal void AddAfter(XLBookPoint anchor, XLBookPoint point, int lastPosition) + { + var prevLink = _nodeMap[anchor]; + var next = prevLink.Next; + Insert(point, lastPosition, anchor, next); + + if (anchor == _tail!.Value) + _tail = point; + } + + /// + /// Remove point from the chain. + /// + /// Link to remove. + /// Last position of the removed link. + /// Point is not a part of the chain. + internal int Remove(XLBookPoint point) + { + if (!_nodeMap.TryGetValue(point, out var pointLink)) + throw PointNotInChain(point); + + // Point is in the chain and there is exactly one link -> clear all. + if (_nodeMap.Count == 1) + { + Clear(); + return pointLink.LastPosition; + } + + if (point == _head!.Value) + _head = pointLink.Next; + + if (point == _tail!.Value) + _tail = pointLink.Previous; + + var prevLink = _nodeMap[pointLink.Previous]; + Debug.Assert(prevLink.Next == point); + _nodeMap[pointLink.Previous] = new Link(prevLink.Previous, pointLink.Next, prevLink.LastPosition); + + var nextLink = _nodeMap[pointLink.Next]; + Debug.Assert(nextLink.Previous == point); + _nodeMap[pointLink.Next] = new Link(pointLink.Previous, nextLink.Next, nextLink.LastPosition); + + _nodeMap.Remove(point); + return pointLink.LastPosition; + } + + /// + /// Clear whole chain. + /// + internal void Clear() + { + _nodeMap.Clear(); + _head = null; + _tail = null; + } + + /// + /// Enumerate all links in the chain. + /// + internal IEnumerable<(XLBookPoint Point, int LastPosition)> GetLinks() + { + if (_head is null) + yield break; + + var current = _head.Value; + do + { + var link = _nodeMap[current]; + yield return new ValueTuple(current, link.LastPosition); + current = link.Next; + } while (current != _head.Value); + } + + internal void Reset() + { + if (_current is null) + return; + + var point = _current.Value; + var link = _nodeMap[point]; + while (link.LastPosition != 0) + { + _nodeMap[point] = new Link(link.Previous, link.Next, 0); + point = link.Next; + link = _nodeMap[point]; + } + + _current = null; + _currentPosition = 0; + } + + /// + /// Mark current link as complete and move ahead to the next link. + /// + /// + /// true if the enumerator moved ahead, false if + /// there are no more links and chain has looped completely. + /// + internal bool MoveAhead() + { + // First move + if (_current is null) + { + var isChainEmpty = _head is null; + if (isChainEmpty) + return false; + + _current = _head; + _currentPosition = 1; + return true; + } + + // Subsequent move + var currentPoint = _current.Value; + if (!_nodeMap.TryGetValue(currentPoint, out var currentLink)) + throw PointNotInChain(currentPoint); + + // Clear up the last position, the current point is being moved to done + // and clearing will ensure next traversal won't be affected. + if (currentLink.LastPosition != 0) + _nodeMap[currentPoint] = new Link(currentLink.Previous, currentLink.Next, 0); + + var nextPoint = currentLink.Next; + Debug.Assert(_nodeMap[nextPoint].Previous == currentPoint); + if (nextPoint == _head!.Value) + { + // Whole chain has been calculated. + return false; + } + + // Since we moved, the new last position is greater than all others + // and thus can't be in the cycle. + IsCurrentInCycle = false; + _current = nextPoint; + _currentPosition++; + return true; + } + + /// + /// Move the before the current point + /// as the new current to be calculated. + /// + /// + /// The point of a chain to moved to the current. Should always be in + /// the chain after the current. + /// + internal void MoveToCurrent(XLBookPoint pointToMove) + { + if (_current is null) + throw new InvalidOperationException("Enumerator not at a link."); + + var currentPoint = _current.Value; + + // If we are not moving anything, adding and removing doesn't + // change chain, plus we avoid problems with moving in a + // single/double link chain. + if (currentPoint == pointToMove) + { + // But it basically means that currentPoint depends on pointToMove + // thus cell depends on itself and that is a cycle. + IsCurrentInCycle = true; + return; + } + + // If head is also current, moving before the current means moving before head + var pointToMoveLastPosition = Remove(pointToMove); + if (_head == currentPoint) + { + AddFirst(pointToMove, pointToMoveLastPosition); + } + else + { + // Current is not a head = move a link after prev of current. + var anchor = _nodeMap[currentPoint].Previous; + AddAfter(anchor, pointToMove, pointToMoveLastPosition); + } + + var shiftedLink = _nodeMap[currentPoint]; + _nodeMap[currentPoint] = new Link(shiftedLink.Previous, shiftedLink.Next, _currentPosition); + + IsCurrentInCycle = _currentPosition == pointToMoveLastPosition; + _current = pointToMove; + } + + private InvalidOperationException PointNotInChain(XLBookPoint point) + { + var exception = new InvalidOperationException($"Book point {point} is not in the chain."); + exception.Data.Add("Chain", string.Join(", ", _nodeMap.Select(n => $"{n.Key}(prev:{n.Value.Previous},next:{n.Value.Next})"))); + return exception; + } + + private readonly struct Link + { + internal readonly XLBookPoint Previous; + + internal readonly XLBookPoint Next; + + /// + /// + /// What was the 1-based position of the link in the chain the last + /// time the link has been current. Only used when link is pushed + /// to the back, otherwise it's 0. + /// + /// + /// The last position of a link is only updated when + /// + /// + /// Link is moved from current to the back - that means link + /// will be moved to current again at some point in the future + /// and if chain hasn't processed even one link in the meantime, + /// there is a cycle. + /// + /// + /// Link is marked as done and current moves past it. The last + /// position should be cleared as not to confuse next traversal. + /// + /// + /// Chain traversal is reset - links in front of current may still + /// have set their last position, because other links have been + /// moved to the current as a supporting links. + /// + /// + /// + /// + /// Used for cycle detection. + internal readonly int LastPosition; + + public Link(XLBookPoint previous, XLBookPoint next, int lastPosition) + { + Previous = previous; + Next = next; + LastPosition = lastPosition; + } + } + } +} diff --git a/ClosedXML/Excel/CalcEngine/Error.cs b/ClosedXML/Excel/CalcEngine/XLError.cs similarity index 82% rename from ClosedXML/Excel/CalcEngine/Error.cs rename to ClosedXML/Excel/CalcEngine/XLError.cs index 110ad6f32..4f7eaadd0 100644 --- a/ClosedXML/Excel/CalcEngine/Error.cs +++ b/ClosedXML/Excel/CalcEngine/XLError.cs @@ -1,52 +1,59 @@ -namespace ClosedXML.Excel.CalcEngine +#nullable disable + +namespace ClosedXML.Excel { /// /// A formula error. /// + /// + /// Keep order of errors in same order as value returned by ERROR.TYPE, + /// because it is used for comparison in some case (e.g. AutoFilter). + /// Values are off by 1, so default produces a valid error. + /// public enum XLError { /// - /// #REF! - a formula refers to a cell that's not valid. + /// #NULL! - Intended to indicate when two areas are required to intersect, but do not. /// - /// When unable to find a sheet or a cell. - CellReference, + /// The space is an intersection operator. + /// SUM(B1 C1) tries to intersect B1:B1 area and C1:C1 area, but since there are no intersecting cells, the result is #NULL. + NullValue = 0, + + /// + /// #DIV/0! - Intended to indicate when any number (including zero) or any error code is divided by zero. + /// + DivisionByZero = 1, /// /// #VALUE! - Intended to indicate when an incompatible type argument is passed to a function, or an incompatible type operand is used with an operator. /// /// Passing a non-number text to a function that requires a number, trying to get an area from non-contiguous reference. Creating an area from different sheets Sheet1!A1:Sheet2!A2 - IncompatibleValue, + IncompatibleValue = 2, /// - /// #DIV/0! - Intended to indicate when any number (including zero) or any error code is divided by zero. + /// #REF! - a formula refers to a cell that's not valid. /// - DivisionByZero, + /// When unable to find a sheet or a cell. + CellReference = 3, /// /// #NAME? - Intended to indicate when what looks like a name is used, but no such name has been defined. /// /// Only for named ranges, not sheets. /// TestRange*10 when the named range doesn't exist will result in an error. - NameNotRecognized, - - /// - /// #N/A - Intended to indicate when a designated value is not available. - /// - /// The value is used for extra cells of an array formula that is applied on an array of a smaller size that the array formula. - NoValueAvailable, - - /// - /// #NULL! - Intended to indicate when two areas are required to intersect, but do not. - /// - /// The space is an intersection operator. - /// SUM(B1 C1) tries to intersect B1:B1 area and C1:C1 area, but since there are no intersecting cells, the result is #NULL. - NullValue, + NameNotRecognized = 4, /// /// #NUM! - Intended to indicate when an argument to a function has a compatible type, but has a value that is outside the domain over which that function is defined. /// /// This is known as a domain error. /// ASIN(10) - the ASIN accepts only argument -1..1 (an output of SIN), so the resulting value is #NUM!. - NumberInvalid + NumberInvalid = 5, + + /// + /// #N/A - Intended to indicate when a designated value is not available. + /// + /// The value is used for extra cells of an array formula that is applied on an array of a smaller size that the array formula. + NoValueAvailable = 6 } } diff --git a/ClosedXML/Excel/CalcEngine/XLRangeAddressComparer.cs b/ClosedXML/Excel/CalcEngine/XLRangeAddressComparer.cs deleted file mode 100644 index a6f75940a..000000000 --- a/ClosedXML/Excel/CalcEngine/XLRangeAddressComparer.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; - -namespace ClosedXML.Excel.CalcEngine -{ - internal class XLRangeAddressComparer : IEqualityComparer - { - private readonly XLAddressComparer _addressComparer; - - public XLRangeAddressComparer(bool ignoreFixed) - { - _addressComparer = new XLAddressComparer(ignoreFixed); - } - - public bool Equals(IXLRangeAddress x, IXLRangeAddress y) - { - return (x == null && y == null) || - (x != null && y != null && - _addressComparer.Equals(x.FirstAddress, y.FirstAddress) && - _addressComparer.Equals(x.LastAddress, y.LastAddress)); - } - - public int GetHashCode(IXLRangeAddress obj) - { - return new - { - FirstHash = _addressComparer.GetHashCode(obj.FirstAddress), - LastHash = _addressComparer.GetHashCode(obj.LastAddress), - }.GetHashCode(); - } - } -} diff --git a/ClosedXML/Excel/Cells/FormulaSlice.cs b/ClosedXML/Excel/Cells/FormulaSlice.cs new file mode 100644 index 000000000..0bc4f3f5e --- /dev/null +++ b/ClosedXML/Excel/Cells/FormulaSlice.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using ClosedXML.Excel.CalcEngine; + +namespace ClosedXML.Excel +{ + internal class FormulaSlice : ISlice + { + private readonly XLWorksheet _sheet; + private readonly XLCalcEngine _engine; + private readonly Slice _formulas = new(); + + public FormulaSlice(XLWorksheet sheet) + { + _sheet = sheet; + _engine = sheet.Workbook.CalcEngine; + } + + public bool IsEmpty => _formulas.IsEmpty; + + public int MaxColumn => _formulas.MaxColumn; + + public int MaxRow => _formulas.MaxRow; + + public Dictionary.KeyCollection UsedColumns => _formulas.UsedColumns; + + public IEnumerable UsedRows => _formulas.UsedRows; + + public void Clear(XLSheetRange range) + { + _formulas.Clear(range); + } + + public void DeleteAreaAndShiftLeft(XLSheetRange rangeToDelete) + { + _formulas.DeleteAreaAndShiftLeft(rangeToDelete); + } + + public void DeleteAreaAndShiftUp(XLSheetRange rangeToDelete) + { + _formulas.DeleteAreaAndShiftUp(rangeToDelete); + } + + public IEnumerator GetEnumerator(XLSheetRange range, bool reverse = false) + { + return _formulas.GetEnumerator(range, reverse); + } + + public void InsertAreaAndShiftDown(XLSheetRange range) + { + _formulas.InsertAreaAndShiftDown(range); + } + + public void InsertAreaAndShiftRight(XLSheetRange range) + { + _formulas.InsertAreaAndShiftRight(range); + } + + public bool IsUsed(XLSheetPoint address) + { + return _formulas.IsUsed(address); + } + + public void Swap(XLSheetPoint sp1, XLSheetPoint sp2) + { + var value1 = _formulas[sp1]; + var value2 = _formulas[sp2]; + + value1 = value1?.GetMovedTo(sp1, sp2); + value2 = value2?.GetMovedTo(sp2, sp1); + + Set(sp1, value2); + Set(sp2, value1); + } + + internal XLCellFormula? Get(XLSheetPoint point) + { + return _formulas[point]; + } + + internal void Set(XLSheetPoint point, XLCellFormula? formula) + { + // Can't ref, because it is an alias for a memory and thus wouldn't hold old formula. + var original = _formulas[point]; + if (ReferenceEquals(original, formula)) + return; + + _formulas.Set(point, formula); + + // Remove first, so calc chain doesn't choke on two formulas + // in one cell when changing a formula of a cell. + var bookPoint = new XLBookPoint(_sheet.SheetId, point); + if (original is not null) + _engine.RemoveFormula(bookPoint, original); + + if (formula is not null) + _engine.AddNormalFormula(bookPoint, _sheet.Name, formula, _sheet.Workbook); + } + + /// + /// Set all cells in a to the array formula. + /// + /// + /// This method doesn't check that formula doesn't damage other array formulas. + /// + internal void SetArray(XLSheetRange range, XLCellFormula? arrayFormula) + { + for (var row = range.TopRow; row <= range.BottomRow; ++row) + { + for (var col = range.LeftColumn; col <= range.RightColumn; ++col) + { + var point = new XLSheetPoint(row, col); + var original = _formulas[point]; + + _formulas.Set(point, arrayFormula); + + // The formula removal removes formula from dependency tree + // (number of cells formula affects doesn't matter) and also + // removes point from the calc chain. Therefore, it works for + // array and normal formulas. + var bookPoint = new XLBookPoint(_sheet.SheetId, point); + if (original is not null) + _engine.RemoveFormula(bookPoint, original); + } + } + + if (arrayFormula is not null) + _engine.AddArrayFormula(range, arrayFormula, _sheet); + } + + internal Slice.Enumerator GetForwardEnumerator(XLSheetRange range) + { + return new Slice.Enumerator(_formulas!, range); + } + + /// + /// Mark all formulas in a range as dirty. + /// + internal void MarkDirty(XLSheetRange range) + { + using var enumerator = GetForwardEnumerator(range); + while (enumerator.MoveNext()) + { + enumerator.Current.IsDirty = true; + } + } + } +} diff --git a/ClosedXML/Excel/Cells/ISheetListener.cs b/ClosedXML/Excel/Cells/ISheetListener.cs new file mode 100644 index 000000000..2c7cd980b --- /dev/null +++ b/ClosedXML/Excel/Cells/ISheetListener.cs @@ -0,0 +1,36 @@ +namespace ClosedXML.Excel +{ + /// + /// An interface for components reacting on changes in a worksheet. + /// + internal interface ISheetListener + { + /// + /// A handler called after the area was put into the sheet and cells shifted down. + /// + /// Sheet where change happened. + /// Area that has been inserted. The original cells were shifted down. + void OnInsertAreaAndShiftDown(XLWorksheet sheet, XLSheetRange area); + + /// + /// A handler called after the area was put into the sheet and cells shifted right. + /// + /// Sheet where change happened. + /// Area that has been inserted. The original cells were shifted right. + void OnInsertAreaAndShiftRight(XLWorksheet sheet, XLSheetRange area); + + /// + /// A handler called after the area was deleted from the sheet and cells shifted left. + /// + /// Sheet where change happened. + /// Range that has been deleted and cells to the right were shifted left. + void OnDeleteAreaAndShiftLeft(XLWorksheet sheet, XLSheetRange deletedRange); + + /// + /// A handler called after the area was deleted from the sheet and cells shifted up. + /// + /// Sheet where change happened. + /// Range that has been deleted and cells below were shifted up. + void OnDeleteAreaAndShiftUp(XLWorksheet sheet, XLSheetRange deletedRange); + } +} diff --git a/ClosedXML/Excel/Cells/ISlice.cs b/ClosedXML/Excel/Cells/ISlice.cs new file mode 100644 index 000000000..b4d613967 --- /dev/null +++ b/ClosedXML/Excel/Cells/ISlice.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; + +namespace ClosedXML.Excel +{ + /// + /// An interface for methods of without specified type of an element. + /// + internal interface ISlice + { + /// + /// Is at least one cell in the slice used? + /// + bool IsEmpty { get; } + + /// + /// Get maximum used column in the slice or 0, if no column is used. + /// + int MaxColumn { get; } + + /// + /// Get maximum used row in the slice or 0, if no row is used. + /// + int MaxRow { get; } + + /// + /// A set of columns that have at least one used cell. Order of columns is non-deterministic. + /// + Dictionary.KeyCollection UsedColumns { get; } + + /// + /// A set of rows that have at least one used cell. Order of rows is non-deterministic. + /// + IEnumerable UsedRows { get; } + + /// + /// Clear all values in the range and mark them as unused. + /// + void Clear(XLSheetRange range); + + /// + /// Clear all values in the and shift all values right of the deleted area to the deleted place. + /// + void DeleteAreaAndShiftLeft(XLSheetRange rangeToDelete); + + /// + /// Clear all values in the and shift all values below the deleted area to the deleted place. + /// + void DeleteAreaAndShiftUp(XLSheetRange rangeToDelete); + + /// + /// Get all used points in a slice. + /// + /// Range to iterate over. + /// false = left to right, top to bottom. true = right to left, bottom to top. + IEnumerator GetEnumerator(XLSheetRange range, bool reverse = false); + + /// + /// Shift all values at the and all cells below it + /// down by of the . + /// The insert area is cleared. + /// + void InsertAreaAndShiftDown(XLSheetRange range); + + /// + /// Shift all values at the and all cells right of it + /// to the right by of the . + /// The insert area is cleared. + /// + void InsertAreaAndShiftRight(XLSheetRange range); + + /// + /// Does slice contains a non-default value at specified point? + /// + bool IsUsed(XLSheetPoint address); + + /// + /// Swap content of two points. + /// + void Swap(XLSheetPoint sp1, XLSheetPoint sp2); + } +} diff --git a/ClosedXML/Excel/Cells/IWorkbookListener.cs b/ClosedXML/Excel/Cells/IWorkbookListener.cs new file mode 100644 index 000000000..47daa467c --- /dev/null +++ b/ClosedXML/Excel/Cells/IWorkbookListener.cs @@ -0,0 +1,18 @@ +namespace ClosedXML.Excel; + +/// +/// Listener for components that need to be notified about structural changes of a workbook +/// (adding/removing sheet, renaming). See for similar listener about +/// structural changes of a sheet. +/// +internal interface IWorkbookListener +{ + /// + /// Method is called when sheet has already been renamed. Each component is responsible only + /// for changing data in itself, not other components. The goal is to separate concerns so + /// each component is not too dependent on others and can achieve the goal in efficient manner. + /// + /// Old sheet name. + /// New sheet name, different from old one. + void OnSheetRenamed(string oldSheetName, string newSheetName); +} diff --git a/ClosedXML/Excel/Cells/IXLCell.cs b/ClosedXML/Excel/Cells/IXLCell.cs index 8b31132ee..b6b51d74b 100644 --- a/ClosedXML/Excel/Cells/IXLCell.cs +++ b/ClosedXML/Excel/Cells/IXLCell.cs @@ -1,17 +1,70 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections; using System.Collections.Generic; using System.Data; +using System.Globalization; namespace ClosedXML.Excel { - public enum XLDataType { Text, Number, Boolean, DateTime, TimeSpan } + /// + /// A value that is in the cell. + /// + public enum XLDataType + { + /// + /// The value is a blank (either blank cells or the omitted optional argument of a function, e.g. IF(TRUE,,). + /// + /// Keep as the first, so the default values are blank. + Blank = 0, + + /// + /// The value is a logical value. + /// + Boolean = 1, + + /// + /// The value is a double-precision floating points number, excluding , + /// or . + /// + Number = 2, + + /// + /// A text or a rich text. Can't be null and can be at most 32767 characters long. + /// + Text = 3, + + /// + /// The value is one of . + /// + Error = 4, + + /// + /// The value is a , represented as a serial date time number. + /// + /// + /// Serial date time 60 is a 1900-02-29, nonexistent day kept for compatibility, + /// but unrepresentable by DateTime. Don't use. + /// + DateTime = 5, + + /// + /// The value is a , represented in a serial date time (24 hours is 1, 36 hours is 1.5 ect.). + /// + TimeSpan = 6, + } public enum XLTableCellType { None, Header, Data, Total } public interface IXLCell { + /// + /// Is this cell the active cell of + /// the worksheet? Setting false deactivates cell only when the + /// cell is currently active. + /// Boolean Active { get; set; } /// Gets this cell's address, relative to the worksheet. @@ -19,10 +72,13 @@ public interface IXLCell IXLAddress Address { get; } /// - /// Calculated value of cell formula. Is used for decreasing number of computations performed. + /// Get the value of a cell without evaluation of a formula. If the cell contains + /// a formula, it returns the last calculated value or a blank value. If the cell + /// doesn't contain a formula, it returns same value as . /// May hold invalid value when flag is True. /// - Object CachedValue { get; } + /// Can be useful to decrease a number of formula evaluations. + XLCellValue CachedValue { get; } /// /// Returns the current region. The current region is a range bounded by any combination of blank rows and blank columns @@ -33,28 +89,39 @@ public interface IXLCell IXLRange CurrentRegion { get; } /// - /// Gets or sets the type of this cell's data. - /// Changing the data type will cause ClosedXML to covert the current value to the new data type. - /// An exception will be thrown if the current value cannot be converted to the new data type. + /// Gets the type of this cell's data. /// /// /// The type of the cell's data. /// - /// - XLDataType DataType { get; set; } + XLDataType DataType { get; } /// /// Gets or sets the cell's formula with A1 references. /// + /// + /// Setter trims the formula and if formula starts with an =, it is removed. If the + /// formula contains unprefixed future function (e.g. CONCAT), it will be correctly + /// prefixed (e.g. _xlfn.CONCAT). + /// /// The formula with A1 references. String FormulaA1 { get; set; } /// /// Gets or sets the cell's formula with R1C1 references. /// + /// + /// Setter trims the formula and if formula starts with an =, it is removed. If the + /// formula contains unprefixed future function (e.g. CONCAT), it will be correctly + /// prefixed (e.g. _xlfn.CONCAT). + /// /// The formula with R1C1 references. String FormulaR1C1 { get; set; } + /// + /// An indication that value of this cell is calculated by a array formula + /// that calculates values for cells in the referenced address. Null if not part of such formula. + /// IXLRangeAddress FormulaReference { get; set; } Boolean HasArrayFormula { get; } @@ -73,6 +140,7 @@ public interface IXLCell /// /// Flag indicating that previously calculated cell value may be not valid anymore and has to be re-evaluated. + /// Only cells with formula may return true, value cells always return false. /// Boolean NeedsRecalculation { get; } @@ -92,20 +160,28 @@ public interface IXLCell IXLStyle Style { get; set; } /// - /// Gets or sets the cell's value. To get or set a strongly typed value, use the GetValue<T> and SetValue methods. - /// ClosedXML will try to detect the data type through parsing. If it can't then the value will be left as a string. - /// If the object is an IEnumerable, ClosedXML will copy the collection's data into a table starting from this cell. - /// If the object is a range, ClosedXML will copy the range starting from this cell. - /// Setting the value to an object (not IEnumerable/range) will call the object's ToString() method. - /// If the value starts with a single quote, ClosedXML will assume the value is a text variable and will prefix the value with a single quote in Excel too. + /// Gets or sets the cell's value. + /// + /// Getter will return value of a cell or value of formula. Getter will evaluate a formula, if the cell + /// , before returning up-to-date value. + /// + /// + /// Setter will clear a formula, if the cell contains a formula. + /// If the value is a text that starts with a single quote, setter will prefix the value with a single quote through + /// in Excel too and the value of cell is set to to non-quoted text. + /// /// - /// - /// The object containing the value(s) to set. - /// - Object Value { get; set; } + XLCellValue Value { get; set; } IXLWorksheet Worksheet { get; } + /// + /// Should the cell show phonetic (i.e. furigana) above the rich text of the cell? + /// It shows phonetic runs in the rich text, it is not autogenerated. Default + /// is false. + /// + Boolean ShowPhonetic { get; set; } + IXLConditionalFormat AddConditionalFormat(); /// @@ -164,6 +240,14 @@ public interface IXLCell IXLCell CopyFrom(String otherCell); + /// + /// Copy range content to an area of same size starting at the cell. + /// Original content of cells is overwritten. + /// + /// Range whose content to copy. + /// This cell. + IXLCell CopyFrom(IXLRangeBase rangeBase); + IXLCell CopyTo(IXLCell target); IXLCell CopyTo(String target); @@ -194,13 +278,6 @@ public interface IXLCell /// How to shift the surrounding cells. void Delete(XLShiftDeletedCells shiftDeleteCells); - /// - /// Gets the cell's value converted to Boolean. - /// ClosedXML will try to covert the current value to Boolean. - /// An exception will be thrown if the current value cannot be converted to Boolean. - /// - Boolean GetBoolean(); - /// /// Returns the comment for the cell or create a new instance if there is no comment on the cell. /// @@ -212,54 +289,103 @@ public interface IXLCell IXLDataValidation GetDataValidation(); /// - /// Gets the cell's value converted to DateTime. - /// ClosedXML will try to covert the current value to DateTime. - /// An exception will be thrown if the current value cannot be converted to DateTime. + /// Gets the cell's value as a Boolean. /// - DateTime GetDateTime(); + /// Shortcut for Value.GetBoolean() + /// If the value of the cell is not a logical. + Boolean GetBoolean(); /// - /// Gets the cell's value converted to Double. - /// ClosedXML will try to covert the current value to Double. - /// An exception will be thrown if the current value cannot be converted to Double. + /// Gets the cell's value as a Double. /// + /// Shortcut for Value.GetNumber() + /// If the value of the cell is not a number. Double GetDouble(); /// - /// Gets the cell's value formatted depending on the cell's data type and style. + /// Gets the cell's value as a String. /// - String GetFormattedString(); + /// Shortcut for Value.GetText(). Returned value is never null. + /// If the value of the cell is not a text. + String GetText(); /// - /// Returns a hyperlink for the cell, if any, or creates a new instance is there is no hyperlink. + /// Gets the cell's value as a XLError. /// - XLHyperlink GetHyperlink(); + /// Shortcut for Value.GetError() + /// If the value of the cell is not an error. + XLError GetError(); /// - /// Returns the value of the cell if it formatted as a rich text. + /// Gets the cell's value as a DateTime. /// - IXLRichText GetRichText(); + /// Shortcut for Value.GetDateTime() + /// If the value of the cell is not a DateTime. + DateTime GetDateTime(); + + /// + /// Gets the cell's value as a TimeSpan. + /// + /// Shortcut for Value.GetTimeSpan() + /// If the value of the cell is not a TimeSpan. + TimeSpan GetTimeSpan(); + + /// + /// Try to get cell's value converted to the T type. + /// + /// Supported types: + /// + /// Boolean - uses a logic of + /// Number (s/byte, u/short, u/int, u/long, float, double, or decimal) + /// - uses a logic of and succeeds, + /// if the value fits into the target type. + /// String - sets the result to a text representation of a cell value (using current culture). + /// DateTime - uses a logic of + /// TimeSpan - uses a logic of + /// XLError - if the value is of type , it will return the value. + /// Enum - tries to parse a value to a member by comparing the text of a cell value and a member name. + /// + /// + /// + /// If the is a nullable value type and the value of cell is blank or empty string, return null value. + /// + /// + /// If the cell value can't be determined because formula function is not implemented, the method always returns false. + /// + /// + /// The requested type into which will the value be converted. + /// Value to store the value. + /// true if the value was converted and the result is in the , false otherwise. + Boolean TryGetValue(out T value); /// - /// Gets the cell's value converted to a String. + /// + /// + /// Conversion logic is identical with . + /// The requested type into which will the value be converted. + /// If the value can't be converted to the type of T + T GetValue(); + + /// + /// Return cell's value represented as a string. Doesn't use cell's formatting or style. /// String GetString(); /// - /// Gets the cell's value converted to TimeSpan. - /// ClosedXML will try to covert the current value to TimeSpan. - /// An exception will be thrown if the current value cannot be converted to TimeSpan. + /// Gets the cell's value formatted depending on the cell's data type and style. /// - TimeSpan GetTimeSpan(); + /// Culture used to format the string. If null (default value), use current culture. + String GetFormattedString(CultureInfo culture = null); /// - /// Gets the cell's value converted to the T type. - /// ClosedXML will try to covert the current value to the T type. - /// An exception will be thrown if the current value cannot be converted to the T type. + /// Returns a hyperlink for the cell, if any, or creates a new instance is there is no hyperlink. /// - /// The return type. - /// - T GetValue(); + XLHyperlink GetHyperlink(); + + /// + /// Returns the value of the cell if it formatted as a rich text. + /// + IXLRichText GetRichText(); IXLCells InsertCellsAbove(int numberOfRows); @@ -280,7 +406,6 @@ public interface IXLCell /// /// The IEnumerable data. /// if set to true the data will be transposed before inserting. - /// IXLRange InsertData(IEnumerable data, Boolean transpose); /// @@ -370,9 +495,6 @@ public interface IXLCell Boolean IsEmpty(); - [Obsolete("Use the overload with XLCellsUsedOptions")] - Boolean IsEmpty(Boolean includeFormats); - Boolean IsEmpty(XLCellsUsedOptions options); Boolean IsMerged(); @@ -383,15 +505,6 @@ public interface IXLCell IXLCell SetActive(Boolean value = true); - /// - /// Sets the type of this cell's data. - /// Changing the data type will cause ClosedXML to covert the current value to the new data type. - /// An exception will be thrown if the current value cannot be converted to the new data type. - /// - /// Type of the data. - /// - IXLCell SetDataType(XLDataType dataType); - [Obsolete("Use GetDataValidation to access the existing rule, or CreateDataValidation() to create a new one.")] IXLDataValidation SetDataValidation(); @@ -399,19 +512,26 @@ public interface IXLCell IXLCell SetFormulaR1C1(String formula); - void SetHyperlink(XLHyperlink hyperlink); - +#nullable enable /// - /// Sets the cell's value. - /// If the object is an IEnumerable ClosedXML will copy the collection's data into a table starting from this cell. - /// If the object is a range ClosedXML will copy the range starting from this cell. - /// Setting the value to an object (not IEnumerable/range) will call the object's ToString() method. - /// ClosedXML will try to translate it to the corresponding type, if it can't then the value will be left as a string. + /// Set hyperlink of a cell. When user clicks on a cell with hyperlink, + /// the Excel opens the target or moves cursor to the target cells in a + /// worksheet. The text of hyperlink is a cell value, the hyperlink + /// target and tooltip are defined by the + /// parameter. /// - /// - /// The object containing the value(s) to set. - /// - IXLCell SetValue(T value); + /// + /// If the cell uses worksheet style, the method also sets + /// hyperlink font color from theme and the underline property. + /// + /// The new cell hyperlink. Use null to + /// remove the hyperlink. + void SetHyperlink(XLHyperlink? hyperlink); +#nullable disable + + /// + /// This cell. + IXLCell SetValue(XLCellValue value); XLTableCellType TableCellType(); @@ -419,11 +539,8 @@ public interface IXLCell /// Returns a string that represents the current state of the cell according to the format. /// /// A: address, F: formula, NF: number format, BG: background color, FG: foreground color, V: formatted value - /// string ToString(string format); - Boolean TryGetValue(out T value); - IXLColumn WorksheetColumn(); IXLRow WorksheetRow(); diff --git a/ClosedXML/Excel/Cells/IXLCells.cs b/ClosedXML/Excel/Cells/IXLCells.cs index 714890869..842afd293 100644 --- a/ClosedXML/Excel/Cells/IXLCells.cs +++ b/ClosedXML/Excel/Cells/IXLCells.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; @@ -7,28 +9,13 @@ public interface IXLCells : IEnumerable { /// /// Sets the cells' value. - /// If the object is an IEnumerable ClosedXML will copy the collection's data into a table starting from each cell. - /// If the object is a range ClosedXML will copy the range starting from each cell. - /// Setting the value to an object (not IEnumerable/range) will call the object's ToString() method. - /// ClosedXML will try to translate it to the corresponding type, if it can't then the value will be left as a string. + /// + /// Setter will clear a formula, if the cell contains a formula. + /// If the value is a text that starts with a single quote, setter will prefix the value with a single quote through + /// in Excel too and the value of cell is set to to non-quoted text. + /// /// - /// - /// The object containing the value(s) to set. - /// - Object Value { set; } - - /// - /// Sets the type of the cells' data. - /// Changing the data type will cause ClosedXML to covert the current value to the new data type. - /// An exception will be thrown if the current value cannot be converted to the new data type. - /// - /// - /// The type of the cell's data. - /// - /// - XLDataType DataType { set; } - - IXLCells SetDataType(XLDataType dataType); + XLCellValue Value { set; } /// /// Clears the contents of these cells. diff --git a/ClosedXML/Excel/Cells/SharedStringTable.cs b/ClosedXML/Excel/Cells/SharedStringTable.cs new file mode 100644 index 000000000..52c770f1b --- /dev/null +++ b/ClosedXML/Excel/Cells/SharedStringTable.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace ClosedXML.Excel +{ + /// + /// A class that holds all texts in a workbook. Each text can be either a simple + /// string or a . + /// + internal class SharedStringTable + { + /// + /// Table of Id to text. Some ids are empty (entry.RefCount = 0) and + /// are tracked in . + /// + private readonly List _table = new(); + + /// + /// List of indexes in that are unused. + /// + private readonly List _freeIds = new(); + + /// + /// text -> id + /// + private readonly Dictionary _reverseDict = new(); + + /// + /// Number of texts the table holds reference to. + /// + internal int Count => _table.Count - _freeIds.Count; + + /// + /// Get a string for specified id. Doesn't matter if it is a plain text or a rich text. In both cases, return text. + /// + internal string this[int id] + { + get + { + var potentialText = _table[id].Text.Value; + if (potentialText is string text) + return text; + + if (potentialText is XLImmutableRichText richText) + return richText.Text; + + throw new ArgumentException($"Id {id} has no text."); + } + } + + /// + /// The principle is that every entry is a text, but only some are rich text. + /// This tries to get a rich text, if it is one. If it is just plain text, return null. + /// + internal XLImmutableRichText? GetRichText(int id) + { + var text = _table[id].Text.Value; + if (text is null) + throw new ArgumentException($"Id {id} has no text."); + + return text as XLImmutableRichText; + } + + /// + /// Get id for a text and increase a number of references to the text by one. + /// + /// Id of a text in the SST. + internal int IncreaseRef(string text, bool inline) => IncreaseTextRef(new Text(text, inline)); + + /// + internal int IncreaseRef(XLImmutableRichText text, bool inline) => IncreaseTextRef(new Text(text, inline)); + + /// + /// Decrease reference count of a text and free if necessary. + /// + internal void DecreaseRef(int id) + { + var entry = _table[id]; + if (entry.Text.Value is null) + throw new InvalidOperationException("Trying to release a text that doesn't have a reference."); + + if (entry.RefCount > 1) + { + _table[id] = new Entry(entry.Text, entry.RefCount - 1); + return; + } + + _table[id] = new Entry(Text.Empty, 0); + _freeIds.Add(id); + _reverseDict.Remove(entry.Text); + } + + /// + /// Get a map that takes the actual string id and returns an continuous sequence (i.e. no gaps). + /// If an id if free (no ref count), the id is mapped to -1. + /// + internal List GetConsecutiveMap() + { + var map = new List(_table.Count); + var mappedStringId = 0; + for (var i = 0; i < _table.Count; ++i) + { + var entry = _table[i]; + var isShared = + entry.RefCount > 0 && // Only used entry can be written to sst + !entry.Text.Inline; // Inline texts shouldn't be written to sst + map.Add(isShared ? mappedStringId++ : -1); + } + + return map; + } + + private int IncreaseTextRef(Text text) + { + if (!_reverseDict.TryGetValue(text, out var id)) + { + id = AddText(text); + _reverseDict.Add(text, id); + return id; + } + + var entry = _table[id]; + _table[id] = new Entry(entry.Text, entry.RefCount + 1); + return id; + } + + private int AddText(Text text) + { + if (_freeIds.Count > 0) + { + // List only changes size, not underlaying array, if last element is removed. + var lastIndex = _freeIds.Count - 1; + var id = _freeIds[lastIndex]; + _freeIds.RemoveAt(lastIndex); + _table[id] = new Entry(text, 1); + return id; + } + + var lastTableIndex = _table.Count; + _table.Add(new Entry(text, 1)); + return lastTableIndex; + } + + /// + /// A struct to hold a text. It also needs a flag for inline/shared, because they have to be different + /// in the table. If there was no inline/shared flag, there would be no way to easily determine whether + /// a text should be written to sst or it should be inlined. + /// + [DebuggerDisplay("{Value} (Shared:{!Inline})")] + private readonly struct Text : IEquatable + { + internal static readonly Text Empty = new(null, false); + + /// + /// Either a string, XLImmutableRichText or null if == 0. + /// + internal readonly object? Value; + + /// + /// Must be as flag for inline string, so the default value is false => ShareString is true by default + /// + internal readonly bool Inline; + + internal Text(object? value, bool inline) + { + Value = value; + Inline = inline; + } + + public override bool Equals(object obj) => obj is Text other && Equals(other); + + public bool Equals(Text other) => Equals(Value, other.Value) && Inline == other.Inline; + + public override int GetHashCode() + { + unchecked + { + return ((Value is not null ? Value.GetHashCode() : 0) * 397) ^ Inline.GetHashCode(); + } + } + } + + [DebuggerDisplay("{Text.Value}:{RefCount} (Shared:{!Text.Inline})")] + private readonly struct Entry + { + internal readonly Text Text; + + /// + /// How many objects (cells, pivot cache entries...) reference the text. + /// + internal readonly int RefCount; + + internal Entry(Text text, int refCount) + { + Text = text; + RefCount = refCount; + } + } + } +} diff --git a/ClosedXML/Excel/Cells/Slice.Lut.cs b/ClosedXML/Excel/Cells/Slice.Lut.cs new file mode 100644 index 000000000..10300b42d --- /dev/null +++ b/ClosedXML/Excel/Cells/Slice.Lut.cs @@ -0,0 +1,330 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace ClosedXML.Excel +{ + internal partial class Slice + { + /// + /// + /// Memory efficient look up table. The table is 2-level structure, + /// where elements of the the top level are potentially nullable + /// references to buckets of up-to 32 items in bottom level. + /// + /// + /// Both level can increase size through doubling, through + /// only the top one can be indefinite size. + /// + /// + private sealed class Lut + { + private const int BottomLutBits = 5; + private const int BottomLutMask = (1 << BottomLutBits) - 1; + + /// + /// The default value lut ref returns for elements not defined in the lut. + /// + private static readonly T DefaultValue = default; + + /// + /// A sparse array of values in the lut. The top level always allocated at least one element. + /// + private LutBucket[] _buckets = new LutBucket[1]; + + /// + /// Get maximal node that is used. Return -1 if LUT is unused. + /// + internal int MaxUsedIndex { get; private set; } = -1; + + /// + /// Does LUT contains at least one used element? + /// + internal bool IsEmpty => MaxUsedIndex < 0; + + /// + /// Get a value at specified index. + /// + /// Index, starting at 0. + /// Reference to an element at index, if the element is used, otherwise . + internal ref readonly T Get(int index) + { + var (topIdx, bottomIdx) = SplitIndex(index); + if (topIdx >= _buckets.Length) + return ref DefaultValue; + + if (!IsUsed(topIdx, bottomIdx)) + return ref DefaultValue; + + var nodes = _buckets[topIdx].Nodes; + return ref nodes[bottomIdx]; + } + + /// + /// Does the index set a mask of used index (=was value set and not cleared)? + /// + internal bool IsUsed(int index) + { + var (topIdx, bottomIdx) = SplitIndex(index); + if (topIdx >= _buckets.Length) + return false; + + return IsUsed(topIdx, bottomIdx); + } + + /// + /// Set/clar an element at index to a specified value. + /// The used flag will be if the value is default or not. + /// + internal void Set(int index, T value) + { + var (topIdx, bottomIdx) = SplitIndex(index); + + SetValue(value, topIdx, bottomIdx); + + var valueIsDefault = EqualityComparer.Default.Equals(value, DefaultValue); + if (valueIsDefault) + ClearBitmap(topIdx, bottomIdx); + else + SetBitmap(topIdx, bottomIdx); + + if (_buckets[topIdx].Bitmap == 0) + _buckets[topIdx] = new LutBucket(null, 0); + + RecalculateMaxIndex(index); + } + + private void SetValue(T value, int topIdx, int bottomIdx) + { + var topSize = _buckets.Length; + if (topIdx >= topSize) + { + do + { + topSize *= 2; + } while (topIdx >= topSize); + + Array.Resize(ref _buckets, topSize); + } + + var bucket = _buckets[topIdx]; + var bottomBucketExists = bucket.Nodes is not null; + if (!bottomBucketExists) + { + var initialSize = 4; + while (bottomIdx >= initialSize) + initialSize *= 2; + + _buckets[topIdx] = bucket = new LutBucket(new T[initialSize], 0); + } + else + { + // Bottom exists, but might not be large enough + var bottomSize = bucket.Nodes.Length; + if (bottomIdx >= bottomSize) + { + do + { + bottomSize *= 2; + } while (bottomIdx >= bottomSize); + + var bucketNodes = bucket.Nodes; + Array.Resize(ref bucketNodes, bottomSize); + _buckets[topIdx] = bucket = new LutBucket(bucketNodes, bucket.Bitmap); + } + } + + bucket.Nodes[bottomIdx] = value; + } + + private static (int TopLevelIndex, int BottomLevelIndex) SplitIndex(int index) + { + var topIdx = index >> BottomLutBits; + var bottomIdx = index & BottomLutMask; + return (topIdx, bottomIdx); + } + + private bool IsUsed(int topIdx, int bottomIdx) + => (_buckets[topIdx].Bitmap & (1 << bottomIdx)) != 0; + + private void SetBitmap(int topIdx, int bottomIdx) + => _buckets[topIdx] = new LutBucket(_buckets[topIdx].Nodes, _buckets[topIdx].Bitmap | (uint)1 << bottomIdx); + + private void ClearBitmap(int topIdx, int bottomIdx) + => _buckets[topIdx] = new LutBucket(_buckets[topIdx].Nodes, _buckets[topIdx].Bitmap & ~((uint)1 << bottomIdx)); + + private void RecalculateMaxIndex(int index) + { + if (MaxUsedIndex <= index) + MaxUsedIndex = CalculateMaxIndex(); + } + + private int CalculateMaxIndex() + { + for (var bucketIdx = _buckets.Length - 1; bucketIdx >= 0; --bucketIdx) + { + var bitmap = _buckets[bucketIdx].Bitmap; + if (bitmap != 0) + { + return (bucketIdx << BottomLutBits) + bitmap.GetHighestSetBit(); + } + } + + return -1; + } + + /// + /// A bucket of bottom layer of LUT. Each bucket has up-to 32 elements. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private readonly struct LutBucket + { + public readonly T[] Nodes; + + /// + /// + /// A bitmap array that indicates which nodes have a set/no-default values values + /// (1 = value has been set and there is an element in the , + /// 0 = value hasn't been set and might exist or not). + /// If the element at some index is not is not set and lut is asked for a value, + /// it should return . + /// + /// + /// The length of the bitmap array is same as the , for each + /// bottom level bucket, the element of index 0 in the bucket is represented by + /// lowest bit, element 31 is represented by highest bit. + /// + /// + /// This is useful to make a distinction between a node that is empty + /// and a node that had it's value se to . + /// + /// + public readonly uint Bitmap; + + internal LutBucket(T[] nodes, uint bitmap) + { + Nodes = nodes; + Bitmap = bitmap; + } + } + + /// + /// Enumerator of LUT used values from low index to high. + /// + internal struct LutEnumerator + { + private readonly Lut _lut; + private readonly int _endIdx; + private int _idx; + + /// + /// Create a new enumerator from subset of elements. + /// + /// Lookup table to traverse. + /// First desired index, included. + /// Last desired index, included. + internal LutEnumerator(Lut lut, int startIdx, int endIdx) + { + Debug.Assert(startIdx <= endIdx); + _lut = lut; + _idx = startIdx - 1; + _endIdx = endIdx; + } + + public ref T Current => ref _lut._buckets[_idx >> BottomLutBits].Nodes[_idx & BottomLutMask]; + + /// + /// Index of current element in the LUT. Only valid, if enumerator is valid. + /// + public int Index => _idx; + + public bool MoveNext() + { + var usedIndex = GetNextUsedIndexAtOrLater(_idx + 1); + if (usedIndex > _endIdx) + return false; + + _idx = usedIndex; + return true; + } + + private int GetNextUsedIndexAtOrLater(int index) + { + var buckets = _lut._buckets; + var (topIdx, bottomIdx) = SplitIndex(index); + + while (topIdx < buckets.Length) + { + var setBitIndex = buckets[topIdx].Bitmap.GetLowestSetBitAbove(bottomIdx); + if (setBitIndex >= 0) + return topIdx * 32 + setBitIndex; + + ++topIdx; + bottomIdx = 0; + } + + // We are the end of LUT + return int.MaxValue; + } + } + + /// + /// Enumerator of LUT used values from high index to low index. + /// + internal struct ReverseLutEnumerator + { + private readonly Lut _lut; + private readonly int _startIdx; + private int _idx; + + internal ReverseLutEnumerator(Lut lut, int startIdx, int endIdx) + { + Debug.Assert(startIdx <= endIdx); + _lut = lut; + _idx = endIdx + 1; + _startIdx = startIdx; + } + + public ref T Current => ref _lut._buckets[_idx >> BottomLutBits].Nodes[_idx & BottomLutMask]; + + public int Index => _idx; + + public bool MoveNext() + { + var usedIndex = GetPrevIndexAtOrBefore(_idx - 1); + if (usedIndex < _startIdx) + return false; + + _idx = usedIndex; + return true; + } + + private int GetPrevIndexAtOrBefore(int index) + { + var buckets = _lut._buckets; + var (topIdx, bottomIdx) = SplitIndex(index); + if (topIdx >= buckets.Length) + { + topIdx = buckets.Length - 1; + bottomIdx = 31; + } + + while (topIdx >= 0) + { + var setBitIndex = buckets[topIdx].Bitmap.GetHighestSetBitBelow(bottomIdx); + if (setBitIndex >= 0) + return topIdx * 32 + setBitIndex; + + --topIdx; + bottomIdx = 31; + } + + return int.MinValue; + } + } + } + } +} diff --git a/ClosedXML/Excel/Cells/Slice.cs b/ClosedXML/Excel/Cells/Slice.cs new file mode 100644 index 000000000..ac6362d9e --- /dev/null +++ b/ClosedXML/Excel/Cells/Slice.cs @@ -0,0 +1,392 @@ +#nullable disable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; + +namespace ClosedXML.Excel +{ + /// + /// Slice is a sparse array that stores a part of cell information (e.g. only values, + /// only styles ...). Slice has same size as a worksheet. If some cells are pushed out + /// of the permitted range, they are gone. + /// + /// + /// This is a ref return, so if the underlaying value + /// changes, the returned value also changes. To avoid, + /// just don't use ref and structs will be copied. + /// + /// The type of data stored in the slice. + internal partial class Slice : ISlice + { + private static readonly Lut Dummy = new(); + private readonly TElement _defaultValue = default; + + /// + /// The content of the slice. Note that LUT uses index that starts from 0, + /// so rows and columns must be adjusted to retrieved the value. + /// + private readonly Lut> _data; + + /// + /// Key is column number, value is number of cells in the column that are used. + /// + private readonly Dictionary _columnUsage = new(); + + internal Slice() + { + _data = new(); + } + + /// + /// Get the slice value at the specified point of the sheet. + /// + internal ref readonly TElement this[XLSheetPoint point] => ref this[point.Row, point.Column]; + + /// + /// Get the slice value at the specified point of the sheet. + /// + internal ref readonly TElement this[int row, int column] + { + get + { + var rowLut = _data.Get(row - 1); + if (rowLut is null) + return ref _defaultValue; + + return ref rowLut.Get(column - 1); + } + } + + /// + public bool IsEmpty => MaxRow == 0; + + /// + public int MaxColumn { get; private set; } + + /// + public int MaxRow => _data.MaxUsedIndex + 1; + + /// + public IEnumerable UsedRows + { + get + { + var rowsEnumerator = new Lut>.LutEnumerator(_data, XLHelper.MinRowNumber - 1, XLHelper.MaxRowNumber - 1); + while (rowsEnumerator.MoveNext()) + { + if (!rowsEnumerator.Current.IsEmpty) + yield return rowsEnumerator.Index + 1; + } + } + } + + /// + public Dictionary.KeyCollection UsedColumns => _columnUsage.Keys; + + /// + public void Clear(XLSheetRange range) + { + var enumerator = new Enumerator(this, range); + while (enumerator.MoveNext()) + { + Set(enumerator.Point, in _defaultValue); + } + } + + /// + public void DeleteAreaAndShiftLeft(XLSheetRange rangeToDelete) + { + Clear(rangeToDelete); + + var noCellsToShift = rangeToDelete.LastPoint.Column == XLHelper.MaxColumnNumber; + if (noCellsToShift) + return; + + var shiftDistance = rangeToDelete.Width; + var shiftRange = rangeToDelete.RightRange(); + var cellEnumerator = new Enumerator(this, shiftRange); + while (cellEnumerator.MoveNext()) + { + var srcPoint = cellEnumerator.Point; + var dstPoint = new XLSheetPoint(srcPoint.Row, srcPoint.Column - shiftDistance); + Set(dstPoint, in cellEnumerator.Current); + Set(srcPoint, in _defaultValue); + } + } + + /// + public void DeleteAreaAndShiftUp(XLSheetRange rangeToDelete) + { + Clear(rangeToDelete); + + var noCellsToShift = rangeToDelete.LastPoint.Row == XLHelper.MaxRowNumber; + if (noCellsToShift) + return; + + var shiftDistance = rangeToDelete.Height; + var shiftRange = rangeToDelete.BelowRange(); + var cellEnumerator = new Enumerator(this, shiftRange); + while (cellEnumerator.MoveNext()) + { + var srcPoint = cellEnumerator.Point; + var dstPoint = new XLSheetPoint(srcPoint.Row - shiftDistance, srcPoint.Column); + Set(dstPoint, in cellEnumerator.Current); + Set(srcPoint, in _defaultValue); + } + } + + /// + /// Get enumerator over used values of the range. + /// + public IEnumerator GetEnumerator(XLSheetRange range, bool reverse = false) + { + return !reverse ? new Enumerator(this, range) : new ReverseEnumerator(this, range); + } + + /// + public void InsertAreaAndShiftDown(XLSheetRange range) + { + var hasSpaceBelow = range.LastPoint.Row < XLHelper.MaxRowNumber; + if (!hasSpaceBelow) + { + Clear(range); + return; + } + + var shiftDistance = range.Height; + + // Purged range might contain some cells that wouldn't be overwritten during shift => clear. + var purgedRange = new XLSheetRange( + new XLSheetPoint(XLHelper.MaxRowNumber - shiftDistance + 1, range.FirstPoint.Column), + new XLSheetPoint(XLHelper.MaxRowNumber, range.LastPoint.Column)); + Clear(purgedRange); + + var shiftedRange = new XLSheetRange( + range.FirstPoint, + new XLSheetPoint(XLHelper.MaxRowNumber - shiftDistance, range.LastPoint.Column)); + var cellEnumerator = new ReverseEnumerator(this, shiftedRange); + while (cellEnumerator.MoveNext()) + { + var srcPoint = cellEnumerator.Point; + var dstPoint = new XLSheetPoint(srcPoint.Row + shiftDistance, srcPoint.Column); + Set(dstPoint, in cellEnumerator.Current); + Set(srcPoint, in _defaultValue); + } + } + + /// + public void InsertAreaAndShiftRight(XLSheetRange range) + { + var hasSpaceRight = range.LastPoint.Column < XLHelper.MaxColumnNumber; + if (!hasSpaceRight) + { + Clear(range); + return; + } + + var shiftDistance = range.Width; + + // Purged range might contain some cells that wouldn't be overwritten during shift => clear. + var purgedRange = new XLSheetRange( + new XLSheetPoint(range.FirstPoint.Row, XLHelper.MaxColumnNumber - shiftDistance + 1), + new XLSheetPoint(range.LastPoint.Row, XLHelper.MaxColumnNumber)); + Clear(purgedRange); + + var shiftedRange = new XLSheetRange( + range.FirstPoint, + new XLSheetPoint(range.LastPoint.Row, XLHelper.MaxColumnNumber - shiftDistance)); + var enumerator = new ReverseEnumerator(this, shiftedRange); + while (enumerator.MoveNext()) + { + var srcPoint = enumerator.Point; + var dstPoint = new XLSheetPoint(srcPoint.Row, srcPoint.Column + shiftDistance); + Set(dstPoint, in enumerator.Current); + Set(srcPoint, in _defaultValue); + } + } + + public bool IsUsed(XLSheetPoint address) + { + var rowLut = _data.Get(address.Row - 1); + if (rowLut is null) + return false; + + return rowLut.IsUsed(address.Column - 1); + } + + public void Swap(XLSheetPoint sp1, XLSheetPoint sp2) + { + var value1 = this[sp1]; + var value2 = this[sp2]; + Set(sp1, in value2); + Set(sp2, in value1); + } + + internal void Set(XLSheetPoint point, in TElement value) + => Set(point.Row, point.Column, in value); + + internal void Set(int row, int column, in TElement value) + { + var rowLut = _data.Get(row - 1); + if (rowLut is null) + { + rowLut = new Lut(); + _data.Set(row - 1, rowLut); + } + + var wasUsed = rowLut.IsUsed(column - 1); + rowLut.Set(column - 1, value); + var isUsed = rowLut.IsUsed(column - 1); + + if (wasUsed && !isUsed) + { + var newCount = DecrementColumnUsage(column); + if (newCount == 0 && MaxColumn == column) + { + MaxColumn = CalculateMaxColumn(); + } + + if (rowLut.IsEmpty) + _data.Set(row - 1, null); + } + + if (!wasUsed && isUsed) + { + IncrementColumnUsage(column); + if (column > MaxColumn) + MaxColumn = column; + } + } + + private int CalculateMaxColumn() + { + var maxColIdx = -1; + var rowEnumerator = new Lut>.LutEnumerator(_data, XLHelper.MinRowNumber - 1, XLHelper.MaxRowNumber - 1); + while (rowEnumerator.MoveNext()) + maxColIdx = Math.Max(maxColIdx, rowEnumerator.Current.MaxUsedIndex); + + return maxColIdx + 1; + } + + private int DecrementColumnUsage(int column) + { + if (!_columnUsage.TryGetValue(column, out var count)) + return 0; + + if (count > 1) + return _columnUsage[column] = count - 1; + + _columnUsage.Remove(column); + return 0; + } + + private void IncrementColumnUsage(int column) + { + if (_columnUsage.TryGetValue(column, out var value)) + _columnUsage[column] = value + 1; + else + _columnUsage.Add(column, 1); + } + + /// + /// Enumerator that returns used values from a specified range. + /// + [DebuggerDisplay("{Point}:{Current}")] + internal class Enumerator : IEnumerator + { + private readonly XLSheetRange _range; + private Lut.LutEnumerator _columnsEnumerator; + private Lut>.LutEnumerator _rowsEnumerator; + + internal Enumerator(Slice slice, XLSheetRange range) + { + _range = range; + + _columnsEnumerator = new Lut.LutEnumerator(Dummy, XLHelper.MaxColumnNumber + 1, XLHelper.MaxColumnNumber + 1); + _rowsEnumerator = new Lut>.LutEnumerator( + slice._data, + range.FirstPoint.Row - 1, + range.LastPoint.Row - 1); + } + + public ref readonly TElement Current => ref _columnsEnumerator.Current; + + public XLSheetPoint Point => new(_rowsEnumerator.Index + 1, _columnsEnumerator.Index + 1); + + /// + /// The movement is columns first, then rows. + /// + public bool MoveNext() + { + while (!_columnsEnumerator.MoveNext()) + { + if (!_rowsEnumerator.MoveNext()) + return false; + + _columnsEnumerator = new Lut.LutEnumerator( + _rowsEnumerator.Current, + _range.FirstPoint.Column - 1, + _range.LastPoint.Column - 1); + } + + return true; + } + + void IEnumerator.Reset() => throw new NotSupportedException(); + + XLSheetPoint IEnumerator.Current => Point; + + object IEnumerator.Current => Point; + + void IDisposable.Dispose() { } + } + + [DebuggerDisplay("{Point}:{Current}")] + private class ReverseEnumerator : IEnumerator + { + private readonly XLSheetRange _range; + private Lut.ReverseLutEnumerator _columnsEnumerator; + private Lut>.ReverseLutEnumerator _rowsEnumerator; + + internal ReverseEnumerator(Slice slice, XLSheetRange range) + { + _range = range; + _columnsEnumerator = new Lut.ReverseLutEnumerator(Dummy, -1, -1); + _rowsEnumerator = new Lut>.ReverseLutEnumerator( + slice._data, + range.FirstPoint.Row - 1, + range.LastPoint.Row - 1); + } + + public ref TElement Current => ref _columnsEnumerator.Current; + + public XLSheetPoint Point => new(_rowsEnumerator.Index + 1, _columnsEnumerator.Index + 1); + + public bool MoveNext() + { + while (!_columnsEnumerator.MoveNext()) + { + if (!_rowsEnumerator.MoveNext()) + return false; + + _columnsEnumerator = new Lut.ReverseLutEnumerator( + _rowsEnumerator.Current, + _range.FirstPoint.Column - 1, + _range.LastPoint.Column - 1); + } + return true; + } + + + void IEnumerator.Reset() => throw new NotSupportedException(); + + XLSheetPoint IEnumerator.Current => Point; + + object IEnumerator.Current => Point; + + public void Dispose() { } + } + } +} diff --git a/ClosedXML/Excel/Cells/ValueSlice.cs b/ClosedXML/Excel/Cells/ValueSlice.cs new file mode 100644 index 000000000..44f80e10b --- /dev/null +++ b/ClosedXML/Excel/Cells/ValueSlice.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel +{ + /// + /// A slice of a single worksheet for values of a cell. + /// + internal class ValueSlice : ISlice + { + private readonly Slice _values = new(); + private readonly SharedStringTable _sst; + + internal ValueSlice(SharedStringTable sst) + { + _sst = sst; + } + + public bool IsEmpty => _values.IsEmpty; + + public int MaxColumn => _values.MaxColumn; + + public int MaxRow => _values.MaxRow; + + public Dictionary.KeyCollection UsedColumns => _values.UsedColumns; + + public IEnumerable UsedRows => _values.UsedRows; + + public void Clear(XLSheetRange range) + { + DereferenceTextInRange(range); + _values.Clear(range); + } + + public void DeleteAreaAndShiftLeft(XLSheetRange rangeToDelete) + { + DereferenceTextInRange(rangeToDelete); + _values.DeleteAreaAndShiftLeft(rangeToDelete); + } + + public void DeleteAreaAndShiftUp(XLSheetRange rangeToDelete) + { + DereferenceTextInRange(rangeToDelete); + _values.DeleteAreaAndShiftUp(rangeToDelete); + } + + public IEnumerator GetEnumerator(XLSheetRange range, bool reverse = false) => _values.GetEnumerator(range, reverse); + + public void InsertAreaAndShiftDown(XLSheetRange range) + { + // Only pushed out references have to be dereferenced, other text references just move. + if (range.BottomRow < XLHelper.MaxRowNumber) + { + var belowRange = range.BelowRange(); + var pushedOutRows = Math.Min(range.Height, belowRange.Height); + var pushedOutRange = belowRange.SliceFromBottom(pushedOutRows); + DereferenceTextInRange(pushedOutRange); + } + + _values.InsertAreaAndShiftDown(range); + } + + public void InsertAreaAndShiftRight(XLSheetRange range) + { + // Only pushed out references have to be dereferenced, other text references just move. + if (range.RightColumn < XLHelper.MaxColumnNumber) + { + var rightRange = range.RightRange(); + var pushedOutColumns = Math.Min(range.Width, rightRange.Width); + var pushedOutRange = rightRange.SliceFromRight(pushedOutColumns); + DereferenceTextInRange(pushedOutRange); + } + + _values.InsertAreaAndShiftRight(range); + } + + public bool IsUsed(XLSheetPoint address) => _values.IsUsed(address); + + public void Swap(XLSheetPoint sp1, XLSheetPoint sp2) => _values.Swap(sp1, sp2); + + internal XLCellValue GetCellValue(XLSheetPoint point) + { + ref readonly var cellValue = ref _values[point]; + var type = cellValue.Type; + var value = cellValue.Value; + return type switch + { + XLDataType.Blank => Blank.Value, + XLDataType.Boolean => value != 0, + XLDataType.Number => value, + XLDataType.Text => _sst[(int)value], + XLDataType.Error => (XLError)value, + XLDataType.DateTime => XLCellValue.FromSerialDateTime(value), + XLDataType.TimeSpan => XLCellValue.FromSerialTimeSpan(value), + _ => throw new ArgumentOutOfRangeException() + }; + } + + internal void SetCellValue(XLSheetPoint point, XLCellValue cellValue) + { + ref readonly var original = ref _values[point]; + + double value; + if (cellValue.Type == XLDataType.Text) + { + if (original.Type == XLDataType.Text) + { + // Change references. Increase first and then decrease to have fewer shuffles assigning same value to a cell. + var originalStringId = (int)original.Value; + value = _sst.IncreaseRef(cellValue.GetText(), original.Inline); + _sst.DecreaseRef(originalStringId); + } + else + { + // The original value wasn't a text -> just increase ref count to a new text + value = _sst.IncreaseRef(cellValue.GetText(), original.Inline); + } + } + else + { + // New value isn't a text + if (original.Type == XLDataType.Text) + { + // Dereference original text + var originalStringId = (int)original.Value; + _sst.DecreaseRef(originalStringId); + } + + if (cellValue.IsUnifiedNumber) + value = cellValue.GetUnifiedNumber(); + else if (cellValue.IsBoolean) + value = cellValue.GetBoolean() ? 1 : 0; + else if (cellValue.IsError) + value = (int)cellValue.GetError(); + else + value = 0; // blank + } + + var modified = new XLValueSliceContent(value, cellValue.Type, original.Inline); + _values.Set(point, in modified); + } + + internal XLImmutableRichText? GetRichText(XLSheetPoint point) + { + ref readonly var cellValue = ref _values[point]; + if (cellValue.Type != XLDataType.Text) + return null; + + var value = cellValue.Value; + return _sst.GetRichText((int)value); + } + + internal void SetRichText(XLSheetPoint point, XLImmutableRichText richText) + { + if (richText is null) + throw new ArgumentNullException(nameof(richText)); + + ref readonly var original = ref _values[point]; + + // If original value was a text (no matter if plain or rich text), + // dereference because it's being replaced. + if (original.Type == XLDataType.Text) + { + var originalId = (int)original.Value; + _sst.DecreaseRef(originalId); + } + + var richTextId = _sst.IncreaseRef(richText, original.Inline); + var modified = new XLValueSliceContent(richTextId, XLDataType.Text, original.Inline); + _values.Set(point, modified); + } + + internal bool GetShareString(XLSheetPoint point) + { + return !_values[point].Inline; + } + + internal void SetShareString(XLSheetPoint point, bool shareString) + { + var inlineString = !shareString; + ref readonly var original = ref _values[point]; + if (original.Inline == inlineString) + return; + + var cellValue = original.Value; + if (original.Type == XLDataType.Text) + { + // Because inline is a part of SST, we have to update stringIds when inline flag changes. + var originalStringId = (int)cellValue; + var richText = _sst.GetRichText(originalStringId); + if (richText is not null) + { + // Cell is storing rich text + _sst.DecreaseRef(originalStringId); + cellValue = _sst.IncreaseRef(richText, inlineString); + } + else + { + // Cell is storing plain text. + var originalString = _sst[originalStringId]; + _sst.DecreaseRef(originalStringId); + cellValue = _sst.IncreaseRef(originalString, inlineString); + } + } + + var modified = new XLValueSliceContent(cellValue, original.Type, inlineString); + _values.Set(point, in modified); + } + + internal int GetShareStringId(XLSheetPoint point) + { + ref readonly var value = ref _values[point]; + if (value.Type != XLDataType.Text) + throw new InvalidOperationException($"Asking for a shared string id of a non-text cell {point}."); + + return (int)_values[point].Value; + } + + /// + /// Prepare for worksheet removal, dereference all tests in a slice. + /// + internal void DereferenceSlice() => DereferenceTextInRange(XLSheetRange.Full); + + private void DereferenceTextInRange(XLSheetRange range) + { + // Dereference all texts in the range, so the ref count is kept correct. + using var e = _values.GetEnumerator(range); + while (e.MoveNext()) + { + ref readonly var value = ref _values[e.Current]; + if (value.Type == XLDataType.Text) + { + _sst.DecreaseRef((int)value.Value); + var blank = new XLValueSliceContent(0, XLDataType.Blank, value.Inline); + _values.Set(e.Current, in blank); + } + } + } + + private readonly record struct XLValueSliceContent + { + /// + /// A cell value in a very compact representation. The value is interpreted depending on a type. + /// + internal readonly double Value; + + /// + /// Type of a cell . + /// + internal readonly XLDataType Type; + internal readonly bool Inline; + + internal XLValueSliceContent(double value, XLDataType type, bool inline) + { + Value = value; + Type = type; + Inline = inline; + } + } + } +} diff --git a/ClosedXML/Excel/Cells/XLCell.cs b/ClosedXML/Excel/Cells/XLCell.cs index dc2e5d2e8..e077d52bd 100644 --- a/ClosedXML/Excel/Cells/XLCell.cs +++ b/ClosedXML/Excel/Cells/XLCell.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.InsertData; +#nullable disable + +using ClosedXML.Excel.InsertData; using ClosedXML.Extensions; using System; using System.Collections; @@ -9,19 +11,15 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; +using ClosedXML.Graphics; +using ClosedXML.Parser; +using ClosedXML.Excel.CalcEngine.Visitors; namespace ClosedXML.Excel { [DebuggerDisplay("{Address}")] - internal class XLCell : XLStylizedBase, IXLCell, IXLStylized + internal sealed class XLCell : XLStylizedBase, IXLCell, IXLStylized { - public static readonly DateTime BaseDate = new DateTime(1899, 12, 30); - - private static readonly Regex A1Regex = new Regex( - @"(?<=\W)(\$?[a-zA-Z]{1,3}\$?\d{1,7})(?=\W)" // A1 - + @"|(?<=\W)(\$?\d{1,7}:\$?\d{1,7})(?=\W)" // 1:1 - + @"|(?<=\W)(\$?[a-zA-Z]{1,3}:\$?[a-zA-Z]{1,3})(?=\W)", RegexOptions.Compiled); // A:A - public static readonly Regex A1SimpleRegex = new Regex( // @"(?<=\W)" // Start with non word @"(?" // Start Group to pick @@ -55,95 +53,168 @@ internal class XLCell : XLStylizedBase, IXLCell, IXLStylized @"(\$?[a-zA-Z]{1,3}:\$?[a-zA-Z]{1,3})" // A:A , RegexOptions.Compiled); - private static readonly Regex R1C1Regex = new Regex( - @"(?<=\W)([Rr](?:\[-?\d{0,7}\]|\d{0,7})?[Cc](?:\[-?\d{0,7}\]|\d{0,7})?)(?=\W)" // R1C1 - + @"|(?<=\W)([Rr]\[?-?\d{0,7}\]?:[Rr]\[?-?\d{0,7}\]?)(?=\W)" // R:R - + @"|(?<=\W)([Cc]\[?-?\d{0,5}\]?:[Cc]\[?-?\d{0,5}\]?)(?=\W)", RegexOptions.Compiled); // C:C - private static readonly Regex utfPattern = new Regex(@"(? _cellsCollection.Worksheet; + + public XLAddress Address => new(Worksheet, _rowNumber, _columnNumber, false, false); + + internal XLSheetPoint SheetPoint => new(_rowNumber, _columnNumber); + + #region Slice fields + + /// + /// A flag indicating if a string should be stored in the shared table or inline. + /// + public bool ShareString { + get => _cellsCollection.ValueSlice.GetShareString(SheetPoint); + set => _cellsCollection.ValueSlice.SetShareString(SheetPoint, value); } - public XLCell(XLWorksheet worksheet, XLAddress address) - : this(worksheet, address, XLStyle.Default.Value) + /// + /// Overriden , because we can't store the value + /// in the cell. + /// + internal override XLStyleValue StyleValue { + get => Worksheet.GetStyleValue(SheetPoint); + private protected set => _cellsCollection.StyleSlice.Set(_rowNumber, _columnNumber, value); } - #endregion Constructor + internal int MemorySstId => _cellsCollection.ValueSlice.GetShareStringId(SheetPoint); - public XLWorksheet Worksheet { get; private set; } + internal XLImmutableRichText RichText => SliceRichText; - private int _rowNumber; - private int _columnNumber; - private bool _fixedRow; - private bool _fixedCol; + private XLCellValue SliceCellValue + { + get => _cellsCollection.ValueSlice.GetCellValue(SheetPoint); + set + { + _cellsCollection.ValueSlice.SetCellValue(SheetPoint, value); + Worksheet.Workbook.CalcEngine.MarkDirty(Worksheet, SheetPoint); + } + } - public XLAddress Address + private XLImmutableRichText SliceRichText { - get + get => _cellsCollection.ValueSlice.GetRichText(SheetPoint); + set => _cellsCollection.ValueSlice.SetRichText(SheetPoint, value); + } + + private XLComment SliceComment + { + get => _cellsCollection.MiscSlice[_rowNumber, _columnNumber].Comment; + set + { + ref readonly var original = ref _cellsCollection.MiscSlice[_rowNumber, _columnNumber]; + if (original.Comment != value) + { + var modified = original; + modified.Comment = value; + _cellsCollection.MiscSlice.Set(_rowNumber, _columnNumber, in modified); + } + } + } + + internal UInt32? CellMetaIndex + { + get => _cellsCollection.MiscSlice[_rowNumber, _columnNumber].CellMetaIndex; + set { - return new XLAddress(Worksheet, _rowNumber, _columnNumber, _fixedRow, _fixedCol); + ref readonly var original = ref _cellsCollection.MiscSlice[_rowNumber, _columnNumber]; + if (original.CellMetaIndex != value) + { + var modified = original; + modified.CellMetaIndex = value; + _cellsCollection.MiscSlice.Set(_rowNumber, _columnNumber, in modified); + } } - internal set + } + + internal UInt32? ValueMetaIndex + { + get => _cellsCollection.MiscSlice[_rowNumber, _columnNumber].ValueMetaIndex; + set { - if (value == null) - return; - _rowNumber = value.RowNumber; - _columnNumber = value.ColumnNumber; - _fixedRow = value.FixedRow; - _fixedCol = value.FixedColumn; + ref readonly var original = ref _cellsCollection.MiscSlice[_rowNumber, _columnNumber]; + if (original.ValueMetaIndex != value) + { + var modified = original; + modified.ValueMetaIndex = value; + _cellsCollection.MiscSlice.Set(_rowNumber, _columnNumber, in modified); + } } } - public string InnerText + /// + /// A formula in the cell. Null, if cell doesn't contain formula. + /// + internal XLCellFormula Formula { - get + get => _cellsCollection.FormulaSlice.Get(SheetPoint); + set { - if (HasRichText) - return _richText.ToString(); + _cellsCollection.FormulaSlice.Set(SheetPoint, value); - return string.Empty == _cellValue ? FormulaA1 : _cellValue; + // Because text values of evaluated formulas are stored in a worksheet part, mark it as inlined string and store in sst. + // If we are clearing formula, we should enable shareString back on, because it is a default position. + // If we are setting formula, we should disable shareString (=inline), because it must be written to the worksheet part + var clearFormula = value is null; + ShareString = clearFormula; + Worksheet.Workbook.CalcEngine.MarkDirty(Worksheet, SheetPoint); } } + #endregion Slice fields + internal XLComment GetComment() { - return _comment ?? CreateComment(); + return SliceComment ?? CreateComment(); } internal XLComment CreateComment(int? shapeId = null) { - _comment = new XLComment(this, shapeId: shapeId); - return _comment; + return SliceComment = new XLComment(this, shapeId: shapeId); + } + + public XLRichText GetRichText() + { + var sliceRichText = SliceRichText; + if (sliceRichText is not null) + return new XLRichText(this, sliceRichText); + + return CreateRichText(); + } + + public XLRichText CreateRichText() + { + var font = new XLFont(GetStyleForRead().Font.Key); + + // Don't include rich text string with 0 length to a new rich text + var richText = DataType == XLDataType.Blank + ? new XLRichText(this, font) + : new XLRichText(this, GetFormattedString(), font); + SliceRichText = XLImmutableRichText.Create(richText); + return richText; } #region IXLCell Members @@ -163,462 +234,313 @@ IXLRange IXLCell.AsRange() return AsRange(); } - public IXLCell SetValue(T value) - { - if (value is IEnumerable ie && !(value is string)) - { - this.InsertData(ie); - return this; - } - else - return SetValue(value, setTableHeader: true, checkMergedRanges: true); - } - - internal IXLCell SetValue(T value, bool setTableHeader, bool checkMergedRanges) + internal IXLCell SetValue(XLCellValue value, bool setTableHeader, bool checkMergedRanges) { if (checkMergedRanges && IsInferiorMergedCell()) return this; - if (value == null) - return this.Clear(XLClearOptions.Contents); + SetValueAndStyle(value); - _richText = null; - _formulaA1 = String.Empty; - _formulaR1C1 = null; - cachedValue = null; + FormulaA1 = null; if (setTableHeader) { - if (SetTableHeaderValue(value)) return this; - if (SetTableTotalsRowLabel(value)) return this; + var cellRange = new XLSheetRange(SheetPoint, SheetPoint); + foreach (var table in Worksheet.Tables) + table.RefreshFieldsFromCells(cellRange); } - var style = GetStyleForRead(); - - // For SetValue we set the cell value directly to the parameter - // as opposed to the other SetValue(object value) where we parse the string and try to deduce the value - - // If parsing was unsuccessful, we throw an ArgumentException - // because we are using SetValue (typed). - // Only in SetValue(object value) to we try to fall back to a value of a different type - if (!TrySetInternallySupportedType(ConvertOtherSupportedTypes(value), style, out var parsedValue)) - throw new ArgumentException($"Unable to set cell value to {value.ObjectToInvariantString()}"); - - SetInternalCellValueString(parsedValue, validate: true, parseToCachedValue: false); - return this; } /// - /// This method converts a wider set up supported types to internal types + /// Set value of a cell and its format (if necessary) from the passed value. + /// It doesn't clear formulas or checks merged cells or tables. /// - /// - internal static object ConvertOtherSupportedTypes(object value) + private void SetValueAndStyle(XLCellValue value) { - return value switch + var modifiedStyleValue = Worksheet.GetStyleForValue(value, SheetPoint); + if (modifiedStyleValue is not null) + StyleValue = modifiedStyleValue; + + // Modify value after style, because we might strip the ' + if (value.Type == XLDataType.Text) { - DBNull dbnull when dbnull.Equals(DBNull.Value) => string.Empty, - Guid _ or char _ or Enum _ => value.ObjectToInvariantString(), - DateTimeOffset dto => dto.DateTime, - _ => value, - }; - } + var text = value.GetText(); + if (text.Length > 0 && text[0] == '\'') + { + value = text.Substring(1); + } + } - /// - /// This method accepts only values of the supported internal types: String, DateTime, TimeSpan, Boolean and Numbers. - /// Any other types should be converted with first. - /// - /// - /// - /// - /// - private bool TrySetInternallySupportedType(T value, XLStyleValue style, out string internalValue) - { - internalValue = null; + SetOnlyValue(value); + } - switch (value) - { - case string s: - internalValue = s; - _dataType = XLDataType.Text; - if (s.Contains(Environment.NewLine) && !style.Alignment.WrapText) - Style.Alignment.WrapText = true; + public Boolean GetBoolean() => Value.GetBoolean(); - return true; + public Double GetDouble() => Value.GetNumber(); - case DateTime d when d >= BaseDate: - SetDateTimeFormat(style, d.Date == d); - internalValue = d.ToOADate().ToInvariantString(); - return true; + public string GetText() => Value.GetText(); - case TimeSpan ts: - SetTimeSpanFormat(style); - internalValue = ts.TotalDays.ToInvariantString(); - return true; + public XLError GetError() => Value.GetError(); - case bool b: - _dataType = XLDataType.Boolean; - internalValue = b ? "1" : "0"; - return true; + public DateTime GetDateTime() => Value.GetDateTime(); - default: - if (value.IsNumber()) - { - if ( - (value is double d1 && (double.IsNaN(d1) || double.IsInfinity(d1))) - || (value is float f && (float.IsNaN(f) || float.IsInfinity(f))) - ) - { - _dataType = XLDataType.Text; - internalValue = value.ToString(); - return true; - } - else - { - _dataType = XLDataType.Number; - internalValue = value.ObjectToInvariantString(); - return true; - } - } - return false; - } - } + public TimeSpan GetTimeSpan() => Value.GetTimeSpan(); - private string DeduceCellValueByParsing(string value, XLStyleValue style) + public Boolean TryGetValue(out T value) { - if (String.IsNullOrEmpty(value)) + XLCellValue currentValue; + try { - _dataType = XLDataType.Text; + currentValue = Value; } - else if (value[0] == '\'') + catch { - // If a user sets a cell value to a value starting with a single quote - // ensure the data type is text - // and that it will be prefixed with a quote in Excel too - - value = value.Substring(1, value.Length - 1); - - _dataType = XLDataType.Text; - if (value.Contains(Environment.NewLine) && !style.Alignment.WrapText) - Style.Alignment.WrapText = true; - - this.Style.SetIncludeQuotePrefix(); + // May fail for formula evaluation + value = default; + return false; } - else if (!string.Equals(value.Trim(), "NaN", StringComparison.OrdinalIgnoreCase) && - Double.TryParse(value, XLHelper.NumberStyle, XLHelper.ParseCulture, out Double _)) - _dataType = XLDataType.Number; - else if (TimeSpan.TryParse(value, out TimeSpan ts)) + + var targetType = typeof(T); + var isNullable = targetType.IsNullableType(); + if (isNullable && currentValue.TryConvert(out Blank _)) { - value = ts.ToInvariantString(); - SetTimeSpanFormat(style); + value = default; + return true; } - else if (DateTime.TryParse(value, out DateTime dt) && dt >= BaseDate) + + // JIT compiles a separate version for each T value type and one for all reference types + // Optimization then removes the double casting for value types. + var underlyingType = targetType.GetUnderlyingType(); + if (underlyingType == typeof(DateTime) && currentValue.TryConvert(out DateTime dateTime)) { - value = dt.ToOADate().ToInvariantString(); - SetDateTimeFormat(style, dt.Date == dt); + value = (T)(object)dateTime; + return true; } - else if (Boolean.TryParse(value, out Boolean b)) + + var culture = CultureInfo.CurrentCulture; + if (underlyingType == typeof(TimeSpan) && currentValue.TryConvert(out TimeSpan timeSpan, culture)) { - value = b ? "1" : "0"; - _dataType = XLDataType.Boolean; + value = (T)(object)timeSpan; + return true; } - else + + if (underlyingType == typeof(Boolean) && currentValue.TryConvert(out Boolean boolean)) { - _dataType = XLDataType.Text; - if (value.Contains(Environment.NewLine) && !style.Alignment.WrapText) - Style.Alignment.WrapText = true; + value = (T)(object)boolean; + return true; } - return value; - } - - public T GetValue() - { - if (TryGetValue(out T retVal)) - return retVal; - - throw new FormatException($"Cannot convert {this.Address.ToStringRelative(true)}'s value to " + typeof(T)); - } - - public string GetString() - { - return GetValue(); - } - - public double GetDouble() - { - return GetValue(); - } - - public bool GetBoolean() - { - return GetValue(); - } - - public DateTime GetDateTime() - { - return GetValue(); - } - - public TimeSpan GetTimeSpan() - { - return GetValue(); - } + if (TryGetStringValue(out value, currentValue)) return true; - public string GetFormattedString() - { - var format = GetFormat(); - try + if (underlyingType == typeof(XLError)) { - return Value.ToExcelFormat(format); + if (currentValue.IsError) + { + value = (T)(object)currentValue.GetError(); + return true; + } + + return false; } - catch { } - try + // Type code of an enum is a type of an integer, so do this check before numbers + if (underlyingType.IsEnum) { - return CachedValue.ToExcelFormat(format); + var strValue = currentValue.ToString(culture); + if (Enum.IsDefined(underlyingType, strValue)) + { + value = (T)Enum.Parse(underlyingType, strValue, ignoreCase: false); + return true; + } + value = default; + return false; } - catch { } - return _cellValue; - } + var typeCode = Type.GetTypeCode(underlyingType); - /// - /// Flag showing that the cell is in formula evaluation state. - /// - internal bool IsEvaluating { get; private set; } - - /// - /// Calculate a value of the specified formula. - /// - /// Cell formula to evaluate. - /// Null if formula is empty or null, calculated value otherwise. - private object RecalculateFormula(string fA1) - { - if (string.IsNullOrEmpty(fA1)) - return null; + // T is a floating point numbers + if (typeCode >= TypeCode.Single && typeCode <= TypeCode.Decimal) + { + if (!currentValue.TryConvert(out Double doubleValue, culture)) + return false; - if (IsEvaluating) - throw new InvalidOperationException($"Cell {Address} is a part of circular reference."); + if (typeCode == TypeCode.Single && doubleValue is < Single.MinValue or > Single.MaxValue) + return false; - if (fA1[0] == '{') - fA1 = fA1.Substring(1, fA1.Length - 2); + value = typeCode switch + { + TypeCode.Single => (T)(object)(Single)doubleValue, + TypeCode.Double => (T)(object)doubleValue, + TypeCode.Decimal => (T)(object)(Decimal)doubleValue, + _ => throw new NotSupportedException() + }; + return true; + } - string sName; - string cAddress; - if (fA1.Contains('!')) + // T is an integer + if (typeCode >= TypeCode.SByte && typeCode <= TypeCode.UInt64) { - sName = fA1.Substring(0, fA1.IndexOf('!')); - if (sName[0] == '\'') - sName = sName.Substring(1, sName.Length - 2); + if (!currentValue.TryConvert(out Double doubleValue, culture)) + return false; - cAddress = fA1.Substring(fA1.IndexOf('!') + 1); - } - else - { - sName = Worksheet.Name; - cAddress = fA1; - } + if (!doubleValue.Equals(Math.Truncate(doubleValue))) + return false; - if (Worksheet.Workbook.Worksheets.Contains(sName) - && XLHelper.IsValidA1Address(cAddress)) - { - try + var valueIsWithinBounds = typeCode switch { - IsEvaluating = true; - var referenceCell = Worksheet.Workbook.Worksheet(sName).Cell(cAddress); - if (referenceCell.IsEmpty(XLCellsUsedOptions.AllContents)) - return 0; - else - return referenceCell.Value; - } - finally + TypeCode.SByte => doubleValue >= SByte.MinValue && doubleValue <= SByte.MaxValue, + TypeCode.Byte => doubleValue >= Byte.MinValue && doubleValue <= Byte.MaxValue, + TypeCode.Int16 => doubleValue >= Int16.MinValue && doubleValue <= Int16.MaxValue, + TypeCode.UInt16 => doubleValue >= UInt16.MinValue && doubleValue <= UInt16.MaxValue, + TypeCode.Int32 => doubleValue >= Int32.MinValue && doubleValue <= Int32.MaxValue, + TypeCode.UInt32 => doubleValue >= UInt32.MinValue && doubleValue <= UInt32.MaxValue, + TypeCode.Int64 => doubleValue >= Int64.MinValue && doubleValue <= Int64.MaxValue, + TypeCode.UInt64 => doubleValue >= UInt64.MinValue && doubleValue <= UInt64.MaxValue, + _ => throw new NotSupportedException() + }; + if (!valueIsWithinBounds) + return false; + + value = typeCode switch { - IsEvaluating = false; - } + TypeCode.SByte => (T)(object)(SByte)doubleValue, + TypeCode.Byte => (T)(object)(Byte)doubleValue, + TypeCode.Int16 => (T)(object)(Int16)doubleValue, + TypeCode.UInt16 => (T)(object)(UInt16)doubleValue, + TypeCode.Int32 => (T)(object)(Int32)doubleValue, + TypeCode.UInt32 => (T)(object)(UInt32)doubleValue, + TypeCode.Int64 => (T)(object)(Int64)doubleValue, + TypeCode.UInt64 => (T)(object)(UInt64)doubleValue, + _ => throw new NotSupportedException() + }; + return true; } - object retVal; - try + return false; + } + + private static bool TryGetStringValue(out T value, XLCellValue currentValue) + { + if (typeof(T) == typeof(String)) { - IsEvaluating = true; + var s = currentValue.ToString(CultureInfo.CurrentCulture); + var matches = utfPattern.Matches(s); - if (Worksheet.Workbook.Worksheets.Contains(sName) - && XLHelper.IsValidA1Address(cAddress)) + if (matches.Count == 0) { - var referenceCell = Worksheet.Workbook.Worksheet(sName).Cell(cAddress); - if (referenceCell.IsEmpty(XLCellsUsedOptions.AllContents)) - return 0; - else - return referenceCell.Value; + value = (T)Convert.ChangeType(s, typeof(T)); + return true; } - retVal = Worksheet.CalcEngine.Evaluate(fA1, Worksheet.Workbook, Worksheet, Address); - } - finally - { - IsEvaluating = false; - } - - if (retVal is IEnumerable retValEnumerable && !(retVal is String)) - return retValEnumerable.Cast().First(); + var sb = new StringBuilder(); + var lastIndex = 0; - return retVal; - } + foreach (var match in matches.Cast()) + { + var matchString = match.Value; + var matchIndex = match.Index; + sb.Append(s.Substring(lastIndex, matchIndex - lastIndex)); - public void InvalidateFormula() - { - NeedsRecalculation = true; - Worksheet.Workbook.InvalidateFormulas(); - ModifiedAtVersion = Worksheet.Workbook.RecalculationCounter; - } + sb.Append((char)int.Parse(match.Groups[1].Value, NumberStyles.AllowHexSpecifier)); - /// - /// Perform an evaluation of cell formula. If cell does not contain formula nothing happens, if cell does not need - /// recalculation ( is False) nothing happens either, unless flag is specified. - /// Otherwise recalculation is performed, result value is preserved in and returned. - /// - /// Flag indicating whether a recalculation must be performed even is cell does not need it. - /// Null if cell does not contain a formula. Calculated value otherwise. - public Object Evaluate(Boolean force = false) - { - if (force || NeedsRecalculation) - { - if (HasFormula) - { - CachedValue = RecalculateFormula(FormulaA1); - UpdateCachedValueFromDataType(); + lastIndex = matchIndex + matchString.Length; } - else - CachedValue = null; - EvaluatedAtVersion = Worksheet.Workbook.RecalculationCounter; - NeedsRecalculation = false; - } - return CachedValue; - } + if (lastIndex < s.Length) + sb.Append(s.Substring(lastIndex)); - internal void SetInternalCellValueString(String cellValue) - { - SetInternalCellValueString(cellValue, validate: false, parseToCachedValue: this.HasFormula); + value = (T)Convert.ChangeType(sb.ToString(), typeof(T)); + return true; + } + value = default; + return false; } - private void SetInternalCellValueString(String cellValue, Boolean validate, Boolean parseToCachedValue) + public T GetValue() { - if (validate) - { - if (cellValue.Length > 32767) throw new ArgumentOutOfRangeException(nameof(cellValue), "Cells can hold a maximum of 32,767 characters."); - } - - this._cellValue = cellValue; + if (TryGetValue(out T retVal)) + return retVal; - if (parseToCachedValue) - CachedValue = ParseCellValueFromString(); + throw new InvalidCastException($"Cannot convert {Address.ToStringRelative(true)}'s value to " + typeof(T)); } - private void UpdateCachedValueFromDataType() + public String GetString() => Value.ToString(CultureInfo.CurrentCulture); + + public string GetFormattedString(CultureInfo culture = null) { - if (CachedValue is double d) - { - if (this.DataType == XLDataType.DateTime && d.IsValidOADateNumber()) - CachedValue = DateTime.FromOADate(d); - else if (this.DataType == XLDataType.TimeSpan) - CachedValue = XLHelper.GetTimeSpan(d); - } - else if (CachedValue is DateTime dt) + XLCellValue value; + try { - if (this.DataType == XLDataType.Number) - CachedValue = dt.ToOADate(); - else if (this.DataType == XLDataType.TimeSpan) - CachedValue = XLHelper.GetTimeSpan(dt.ToOADate()); + // Need to get actual value because formula might be out of date or value wasn't set at all + // Unimplemented functions and features throw exceptions + value = Value; } - else if (CachedValue is TimeSpan ts) + catch { - if (this.DataType == XLDataType.DateTime) - CachedValue = DateTime.FromOADate(ts.TotalDays); - else if (this.DataType == XLDataType.Number) - CachedValue = ts.TotalDays; + value = CachedValue; } + + return GetFormattedString(value, culture); } - internal void SetDataTypeFast(XLDataType dataType) + internal string GetFormattedString(XLCellValue value, CultureInfo culture = null) { - this._dataType = dataType; + culture ??= CultureInfo.CurrentCulture; + var format = GetFormat(); + return value.IsUnifiedNumber + ? value.GetUnifiedNumber().ToExcelFormat(format, culture) + : value.ToString(culture); } - private Object ParseCellValueFromString() + public void InvalidateFormula() { - return ParseCellValueFromString(_cellValue, _dataType, out String error); + if (Formula is null) + { + return; + } + + Formula.IsDirty = true; } - private Object ParseCellValueFromString(String cellValue, XLDataType dataType, out String error) + /// + /// Perform an evaluation of cell formula. If cell does not contain formula nothing happens, if cell does not need + /// recalculation ( is False) nothing happens either, unless flag is specified. + /// Otherwise recalculation is performed, result value is preserved in and returned. + /// + /// Flag indicating whether a recalculation must be performed even is cell does not need it. + /// Null if cell does not contain a formula. Calculated value otherwise. + public void Evaluate(Boolean force) { - error = ""; - if ("" == cellValue) - return ""; - - if (dataType == XLDataType.Boolean) + if (Formula is null) { - if (bool.TryParse(cellValue, out Boolean b)) - return b; - else if (cellValue == "0") - return false; - else if (cellValue == "1") - return true; - else - return !string.IsNullOrEmpty(cellValue); + return; } - if (dataType == XLDataType.DateTime) + var shouldRecalculate = force || NeedsRecalculation; + if (!shouldRecalculate) { - if (Double.TryParse(cellValue, XLHelper.NumberStyle, XLHelper.ParseCulture, out Double d)) - { - if (d.IsValidOADateNumber()) - return DateTime.FromOADate(d); - else - return d; - } - else if (DateTime.TryParse(cellValue, out DateTime dt)) - return dt; - else - { - error = string.Format("Cannot set data type to DateTime because '{0}' is not recognized as a date.", cellValue); - return null; - } + return; } - if (dataType == XLDataType.Number) - { - var v = cellValue; - Double factor = 1.0; - if (v.EndsWith("%")) - { - v = v.Substring(0, v.Length - 1); - factor = 1 / 100.0; - } - if (Double.TryParse(v, XLHelper.NumberStyle, CultureInfo.InvariantCulture, out Double d)) - return d * factor; - else - { - error = string.Format("Cannot set data type to Number because '{0}' is not recognized as a number.", cellValue); - return null; - } - } + // TODO: Only one cell, somehow + var wb = Worksheet.Workbook; + wb.CalcEngine.Recalculate(wb, null); + } - if (dataType == XLDataType.TimeSpan) - { - if (TimeSpan.TryParse(cellValue, out TimeSpan ts)) - return ts; - else if (Double.TryParse(cellValue, XLHelper.NumberStyle, XLHelper.ParseCulture, out Double d)) - return XLHelper.GetTimeSpan(d); - else - { - error = string.Format("Cannot set data type to TimeSpan because '{0}' is not recognized as a TimeSpan.", cellValue); - return null; - } - } + /// + /// Set only value, don't clear formula, don't set format. + /// Sets the value even for merged cells. + /// + internal void SetOnlyValue(XLCellValue value) + { + SliceCellValue = value; + } - return cellValue; + public IXLCell SetValue(XLCellValue value) + { + return SetValue(value, true, true); } public override string ToString() => ToString("A"); @@ -637,47 +559,18 @@ public string ToString(string format) }; } - public object Value + public XLCellValue Value { get { - if (!String.IsNullOrWhiteSpace(_formulaA1) || - !String.IsNullOrEmpty(_formulaR1C1)) + if (Formula is not null) { - return Evaluate(); + Evaluate(false); } - var cellValue = HasRichText ? _richText.ToString() : _cellValue; - return ParseCellValueFromString(cellValue, _dataType, out _); - } - set - { - if (IsInferiorMergedCell()) - return; - - FormulaA1 = String.Empty; - - if (value is XLCells) throw new ArgumentException("Cannot assign IXLCells object to the cell value."); - - if (SetTableHeaderValue(value)) return; - - if (SetRangeRows(value)) return; - - if (SetRangeColumns(value)) return; - - if (SetDataTable(value)) return; - - if (SetEnumerable(value)) return; - - if (SetRange(value)) return; - - if (!SetRichText(value)) - SetValue(value); - - CachedValue = null; - - if (_cellValue.Length > 32767) throw new ArgumentOutOfRangeException(nameof(value), "Cells can hold only 32,767 characters."); + return SliceCellValue; } + set => SetValue(value); } public IXLTable InsertTable(IEnumerable data) @@ -703,23 +596,7 @@ public IXLTable InsertTable(IEnumerable data, String tableName, Boolean cr public IXLTable InsertTable(IEnumerable data, String tableName, Boolean createTable, Boolean addHeadings, Boolean transpose) { var reader = InsertDataReaderFactory.Instance.CreateReader(data); - return InsertTableInternal(reader, tableName, createTable, addHeadings, transpose); - } - - private IXLTable InsertTableInternal(IInsertDataReader reader, String tableName, Boolean createTable, Boolean addHeadings, - Boolean transpose) - { - if (createTable && this.Worksheet.Tables.Any(t => t.Contains(this))) - throw new InvalidOperationException(String.Format("This cell '{0}' is already part of a table.", this.Address.ToString())); - - var range = InsertDataInternal(reader, addHeadings, transpose); - - if (createTable) - // Create a table and save it in the file - return tableName == null ? range.CreateTable() : range.CreateTable(tableName); - else - // Create a table, but keep it in memory. Saved file will contain only "raw" data and column headers - return tableName == null ? range.AsTable() : range.AsTable(tableName); + return Worksheet.InsertTable(SheetPoint, reader, tableName, createTable, addHeadings, transpose); } public IXLTable InsertTable(DataTable data) @@ -745,123 +622,16 @@ public IXLTable InsertTable(DataTable data, String tableName, Boolean createTabl if (XLHelper.IsValidA1Address(tableName) || XLHelper.IsValidRCAddress(tableName)) throw new InvalidOperationException($"Table name cannot be a valid Cell Address '{tableName}'."); - if (createTable && this.Worksheet.Tables.Any(t => t.Contains(this))) + if (createTable && this.Worksheet.Tables.Any(t => t.Contains(this))) throw new InvalidOperationException($"This cell '{this.Address}' is already part of a table."); var reader = InsertDataReaderFactory.Instance.CreateReader(data); - return InsertTableInternal(reader, tableName, createTable, addHeadings: true, transpose: false); - } - - internal XLRange InsertDataInternal(IInsertDataReader reader, Boolean addHeadings, Boolean transpose) - { - if (reader == null) - return null; - - var currentRowNumber = _rowNumber; - var currentColumnNumber = _columnNumber; - var maximumColumnNumber = currentColumnNumber; - var maximumRowNumber = currentRowNumber; - - if (transpose) - { - maximumColumnNumber += reader.GetRecordsCount() - 1; - maximumRowNumber += reader.GetPropertiesCount() - 1; - } - else - { - maximumColumnNumber += reader.GetPropertiesCount() - 1; - maximumRowNumber += reader.GetRecordsCount() - 1; - } - - // Inline functions to handle looping with transposing - ////////////////////////////////////////////////////// - void incrementFieldPosition() - { - if (transpose) - { - maximumRowNumber = Math.Max(maximumRowNumber, currentRowNumber); - currentRowNumber++; - } - else - { - maximumColumnNumber = Math.Max(maximumColumnNumber, currentColumnNumber); - currentColumnNumber++; - } - } - - void incrementRecordPosition() - { - if (transpose) - { - maximumColumnNumber = Math.Max(maximumColumnNumber, currentColumnNumber); - currentColumnNumber++; - } - else - { - maximumRowNumber = Math.Max(maximumRowNumber, currentRowNumber); - currentRowNumber++; - } - } - - void resetRecordPosition() - { - if (transpose) - currentRowNumber = _rowNumber; - else - currentColumnNumber = _columnNumber; - } - ////////////////////////////////////////////////////// - - var empty = maximumRowNumber <= _rowNumber || - maximumColumnNumber <= _columnNumber; - - if (!empty) - { - Worksheet.Range( - _rowNumber, - _columnNumber, - maximumRowNumber, - maximumColumnNumber) - .Clear(); - } - - if (addHeadings) - { - for (int i = 0; i < reader.GetPropertiesCount(); i++) - { - var propertyName = reader.GetPropertyName(i); - Worksheet.SetValue(propertyName, currentRowNumber, currentColumnNumber); - incrementFieldPosition(); - } - - incrementRecordPosition(); - } - - var data = reader.GetData(); - - foreach (var item in data) - { - resetRecordPosition(); - foreach (var value in item) - { - Worksheet.SetValue(value, currentRowNumber, currentColumnNumber); - incrementFieldPosition(); - } - incrementRecordPosition(); - } - - var range = Worksheet.Range( - _rowNumber, - _columnNumber, - maximumRowNumber, - maximumColumnNumber); - - return range; + return Worksheet.InsertTable(SheetPoint, reader, tableName, createTable, addHeadings: true, transpose: false); } public XLTableCellType TableCellType() { - var table = this.Worksheet.Tables.FirstOrDefault(t => t.AsRange().Contains(this)); + var table = this.Worksheet.Tables.FirstOrDefault(t => t.AsRange().Contains(this)); if (table == null) return XLTableCellType.None; if (table.ShowHeaderRow && table.HeadersRow().RowNumber().Equals(this._rowNumber)) return XLTableCellType.Header; @@ -884,7 +654,7 @@ public IXLRange InsertData(IEnumerable data, Boolean transpose) return null; var reader = InsertDataReaderFactory.Instance.CreateReader(data); - return InsertDataInternal(reader, addHeadings: false, transpose: transpose); + return Worksheet.InsertData(SheetPoint, reader, addHeadings: false, transpose: transpose); } public IXLRange InsertData(DataTable dataTable) @@ -893,94 +663,10 @@ public IXLRange InsertData(DataTable dataTable) return null; var reader = InsertDataReaderFactory.Instance.CreateReader(dataTable); - return InsertDataInternal(reader, addHeadings: false, transpose: false); - } - - public IXLCell SetDataType(XLDataType dataType) - { - DataType = dataType; - return this; + return Worksheet.InsertData(SheetPoint, reader, addHeadings: false, transpose: false); } - public XLDataType DataType - { - get { return _dataType; } - set - { - if (_dataType == value) return; - - if (HasRichText) - { - _cellValue = _richText.ToString(); - _richText = null; - } - - if (!string.IsNullOrEmpty(_cellValue)) - { - // If we're converting the DataType to Text, there are some quirky rules currently - if (value == XLDataType.Text) - { - var v = Value; - switch (v) - { - case DateTime d: - _cellValue = d.ToOADate().ToInvariantString(); - break; - - case TimeSpan ts: - _cellValue = ts.TotalDays.ToInvariantString(); - break; - - case Boolean b: - _cellValue = b ? "True" : "False"; - break; - - default: - _cellValue = v.ObjectToInvariantString(); - break; - } - } - else - { - var v = ParseCellValueFromString(_cellValue, value, out String error); - - if (!String.IsNullOrWhiteSpace(error)) - throw new ArgumentException(error, nameof(value)); - - _cellValue = v?.ObjectToInvariantString() ?? ""; - - var style = GetStyleForRead(); - switch (v) - { - case DateTime d: - _cellValue = d.ToOADate().ToInvariantString(); - - if (style.NumberFormat.Format.Length == 0 && style.NumberFormat.NumberFormatId == 0) - Style.NumberFormat.NumberFormatId = _cellValue.Contains('.') ? 22 : 14; - - break; - - case TimeSpan ts: - if (style.NumberFormat.Format.Length == 0 && style.NumberFormat.NumberFormatId == 0) - Style.NumberFormat.NumberFormatId = 46; - - break; - - case Boolean b: - _cellValue = b ? "1" : "0"; - break; - } - } - } - - _dataType = value; - - if (HasFormula && !NeedsRecalculation) - UpdateCachedValueFromDataType(); - else - CachedValue = null; - } - } + public XLDataType DataType => SliceCellValue.Type; public IXLCell Clear(XLClearOptions clearOptions = XLClearOptions.All) { @@ -1002,14 +688,10 @@ internal IXLCell Clear(XLClearOptions clearOptions, bool calledFromRange) if (clearOptions.HasFlag(XLClearOptions.Contents)) { SetHyperlink(null); - _richText = null; - _cellValue = String.Empty; + SliceCellValue = Blank.Value; FormulaA1 = String.Empty; } - if (clearOptions.HasFlag(XLClearOptions.DataType)) - _dataType = XLDataType.Text; - if (clearOptions.HasFlag(XLClearOptions.NormalFormats)) SetStyle(Worksheet.Style); @@ -1019,7 +701,7 @@ internal IXLCell Clear(XLClearOptions clearOptions, bool calledFromRange) } if (clearOptions.HasFlag(XLClearOptions.Comments)) - _comment = null; + SliceComment = null; if (clearOptions.HasFlag(XLClearOptions.Sparklines)) { @@ -1048,85 +730,72 @@ public void Delete(XLShiftDeletedCells shiftDeleteCells) public string FormulaA1 { - get - { - if (String.IsNullOrWhiteSpace(_formulaA1)) - { - if (!String.IsNullOrWhiteSpace(_formulaR1C1)) - { - _formulaA1 = GetFormulaA1(_formulaR1C1); - return FormulaA1; - } - - return String.Empty; - } - - if (_formulaA1.Trim()[0] == '=') - return _formulaA1.Substring(1); - - if (_formulaA1.Trim().StartsWith("{=")) - return "{" + _formulaA1.Substring(2); - - return _formulaA1; - } + get => Formula?.A1 ?? String.Empty; set { if (IsInferiorMergedCell()) return; - InvalidateFormula(); - - _formulaA1 = String.IsNullOrWhiteSpace(value) ? null : value; + var formula = value?.TrimFormulaEqual(); + if (!String.IsNullOrWhiteSpace(formula)) + { + var fixedFunctionsFormula = FormulaTransformation.FixFutureFunctions(formula, Worksheet.Name, SheetPoint); + Formula = XLCellFormula.NormalA1(fixedFunctionsFormula); + } + else + { + Formula = null; + } - _formulaR1C1 = null; + InvalidateFormula(); } } public string FormulaR1C1 { - get - { - if (String.IsNullOrWhiteSpace(_formulaR1C1)) - _formulaR1C1 = GetFormulaR1C1(FormulaA1); - - return _formulaR1C1; - } + get => Formula?.GetFormulaR1C1(SheetPoint) ?? String.Empty; set { if (IsInferiorMergedCell()) return; - InvalidateFormula(); - - _formulaR1C1 = String.IsNullOrWhiteSpace(value) ? null : value; + var formula = value?.TrimFormulaEqual(); + if (!String.IsNullOrWhiteSpace(formula)) + { + var formulaA1 = FormulaConverter.ToA1(formula, _rowNumber, _columnNumber); + var fixedFunctionsFormulaA1 = FormulaTransformation.FixFutureFunctions(formulaA1, Worksheet.Name, SheetPoint); + Formula = XLCellFormula.NormalA1(fixedFunctionsFormulaA1); + } + else + { + Formula = null; + } - _formulaA1 = null; + InvalidateFormula(); } } - public bool ShareString { get; set; } - public XLHyperlink GetHyperlink() { - return _hyperlink ?? CreateHyperlink(); + if (Worksheet.Hyperlinks.TryGet(SheetPoint, out var hyperlink)) + return hyperlink; + + return CreateHyperlink(); } - public void SetHyperlink(XLHyperlink hyperlink) +#nullable enable + /// + public void SetHyperlink(XLHyperlink? hyperlink) { - Worksheet.Hyperlinks.TryDelete(Address); - - _hyperlink = hyperlink; + if (Worksheet.Hyperlinks.TryGet(SheetPoint, out var existingHyperlink)) + Worksheet.Hyperlinks.Delete(existingHyperlink); - if (_hyperlink == null) return; - - _hyperlink.Worksheet = Worksheet; - _hyperlink.Cell = this; - - Worksheet.Hyperlinks.Add(_hyperlink); + if (hyperlink is null) + return; - if (SettingHyperlink) return; + Worksheet.Hyperlinks.Add(SheetPoint, hyperlink); if (GetStyleForRead().Font.FontColor.Equals(Worksheet.StyleValue.Font.FontColor)) Style.Font.FontColor = XLColor.FromTheme(XLThemeColor.Hyperlink); @@ -1135,6 +804,13 @@ public void SetHyperlink(XLHyperlink hyperlink) Style.Font.Underline = XLFontUnderlineValues.Single; } + internal void SetCellHyperlink(XLHyperlink hyperlink) + { + Worksheet.Hyperlinks.Clear(SheetPoint); + Worksheet.Hyperlinks.Add(SheetPoint, hyperlink); + } +#nullable disable + public XLHyperlink CreateHyperlink() { SetHyperlink(new XLHyperlink()); @@ -1179,110 +855,24 @@ public IXLCell AddToNamed(string rangeName, XLScope scope, string comment) return this; } - private bool _recalculationNeededLastValue; - - /// - /// Flag indicating that previously calculated cell value may be not valid anymore and has to be re-evaluated. - /// - public bool NeedsRecalculation - { - get - { - if (String.IsNullOrWhiteSpace(_formulaA1) && String.IsNullOrEmpty(_formulaR1C1)) - return false; - - if (NeedsRecalculationEvaluatedAtVersion == Worksheet.Workbook.RecalculationCounter) - return _recalculationNeededLastValue; - - bool cellWasModified = EvaluatedAtVersion < ModifiedAtVersion; - if (cellWasModified) - return NeedsRecalculation = true; - - if (!Worksheet.CalcEngine.TryGetPrecedentCells(_formulaA1, Worksheet, out var precedentCells)) - return NeedsRecalculation = true; - - var res = precedentCells.Any(cell => cell.ModifiedAtVersion > EvaluatedAtVersion || // the affecting cell was modified after this one was evaluated - cell.EvaluatedAtVersion > EvaluatedAtVersion || // the affecting cell was evaluated after this one (normally this should not happen) - cell.NeedsRecalculation); // the affecting cell needs recalculation (recursion to walk through dependencies) - - NeedsRecalculation = res; - return res; - } - internal set - { - _recalculationNeededLastValue = value; - NeedsRecalculationEvaluatedAtVersion = Worksheet.Workbook.RecalculationCounter; - } - } - - /// - /// The value of that workbook had at the moment of cell last modification. - /// If this value is greater than then cell needs re-evaluation, as well as all dependent cells do. - /// - private long ModifiedAtVersion { get; set; } - - /// - /// The value of that workbook had at the moment of cell formula evaluation. - /// If this value equals to it indicates that stores - /// correct value and no re-evaluation has to be performed. - /// - private long EvaluatedAtVersion { get; set; } - - /// - /// The value of that workbook had at the moment of determining whether the cell - /// needs re-evaluation (due to it has been edited or some of the affecting cells has). If this value equals to - /// it indicates that stores correct value and no check has to be performed. - /// - private long NeedsRecalculationEvaluatedAtVersion { get; set; } - - private Object cachedValue; - - public Object CachedValue - { - get - { - if (!HasFormula && cachedValue == null) - cachedValue = Value; - - return cachedValue; - } - private set - { - if (value != null && !HasFormula) - throw new InvalidOperationException("Cached values can be set only for cells with formulas"); - - cachedValue = value; - } - } - - public IXLRichText GetRichText() - { - return _richText ?? CreateRichText(); - } + /// + /// Flag indicating that previously calculated cell value may be not valid anymore and has to be re-evaluated. + /// + public bool NeedsRecalculation => Formula is not null && Formula.IsDirty; - public bool HasRichText - { - get { return _richText != null; } - } + public XLCellValue CachedValue => SliceCellValue; - public IXLRichText CreateRichText() - { - var style = GetStyleForRead(); - _richText = _cellValue.Length == 0 - ? new XLRichText(new XLFont(Style as XLStyle, style.Font)) - : new XLRichText(GetFormattedString(), new XLFont(Style as XLStyle, style.Font)); + IXLRichText IXLCell.GetRichText() => GetRichText(); - return _richText; - } + public bool HasRichText => SliceRichText is not null; - IXLComment IXLCell.GetComment() - { - return GetComment(); - } + IXLRichText IXLCell.CreateRichText() => CreateRichText(); + + IXLComment IXLCell.GetComment() => GetComment(); public bool HasComment { - get { return _comment != null; } + get { return SliceComment != null; } } IXLComment IXLCell.CreateComment() @@ -1309,17 +899,16 @@ public Boolean IsEmpty() return IsEmpty(XLCellsUsedOptions.AllContents); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public Boolean IsEmpty(Boolean includeFormats) - { - return IsEmpty(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - public Boolean IsEmpty(XLCellsUsedOptions options) { - if (InnerText.Length > 0) + var isValueEmpty = SliceCellValue.Type switch + { + XLDataType.Blank => true, + XLDataType.Text => SliceCellValue.GetText().Length == 0, + _ => false + }; + + if (!isValueEmpty || HasFormula) return false; if (options.HasFlag(XLCellsUsedOptions.NormalFormats)) @@ -1423,7 +1012,7 @@ public Boolean HasDataValidation /// The data validation rule applying to the current cell or null if there is no such rule. private IXLDataValidation FindDataValidation() { - Worksheet.DataValidations.TryGet(AsRange().RangeAddress, out var dataValidation); + Worksheet.DataValidations.TryGet(new XLRangeAddress(Address, Address), out var dataValidation); return dataValidation; } @@ -1458,11 +1047,11 @@ public IXLConditionalFormat AddConditionalFormat() public Boolean Active { - get { return Worksheet.ActiveCell == this; } + get => Worksheet.ActiveCell == SheetPoint; set { if (value) - Worksheet.ActiveCell = this; + Worksheet.ActiveCell = SheetPoint; else if (Active) Worksheet.ActiveCell = null; } @@ -1474,274 +1063,28 @@ public IXLCell SetActive(Boolean value = true) return this; } - public Boolean HasHyperlink - { - get { return _hyperlink != null; } - } - - public Boolean TryGetValue(out T value) - { - var targetType = typeof(T); - var underlyingType = targetType.GetUnderlyingType(); - var isNullable = targetType.IsNullableType(); - - Object currentValue; - try - { - currentValue = Value; - } - catch - { - // May fail for formula evaluation - value = default; - return false; - } - - if (isNullable && (currentValue == null || currentValue is string s && String.IsNullOrEmpty(s))) - { - value = default; - return true; - } - - if (targetType != typeof(String) // Strings are handled later and have some specifics to UTF handling - && currentValue is T t) - { - value = t; - return true; - } - - if (TryGetDateTimeValue(out value, currentValue)) return true; - - if (TryGetTimeSpanValue(out value, currentValue)) return true; - - if (TryGetBooleanValue(out value, currentValue)) return true; - - if (TryGetRichStringValue(out value)) return true; - - if (TryGetStringValue(out value, currentValue)) return true; - - if (TryGetHyperlink(out value)) return true; - - if (currentValue.IsNumber()) - { - try - { - value = (T)Convert.ChangeType(currentValue, underlyingType); - return true; - } - catch (Exception) - { - value = default; - return false; - } - } - - var strValue = currentValue.ToString(); - - if (underlyingType == typeof(sbyte)) return TryGetBasicValue(strValue, sbyte.TryParse, out value); - if (underlyingType == typeof(byte)) return TryGetBasicValue(strValue, byte.TryParse, out value); - if (underlyingType == typeof(short)) return TryGetBasicValue(strValue, short.TryParse, out value); - if (underlyingType == typeof(ushort)) return TryGetBasicValue(strValue, ushort.TryParse, out value); - if (underlyingType == typeof(int)) return TryGetBasicValue(strValue, int.TryParse, out value); - if (underlyingType == typeof(uint)) return TryGetBasicValue(strValue, uint.TryParse, out value); - if (underlyingType == typeof(long)) return TryGetBasicValue(strValue, long.TryParse, out value); - if (underlyingType == typeof(ulong)) return TryGetBasicValue(strValue, ulong.TryParse, out value); - if (underlyingType == typeof(float)) return TryGetBasicValue(strValue, float.TryParse, out value); - if (underlyingType == typeof(double)) return TryGetBasicValue(strValue, double.TryParse, out value); - if (underlyingType == typeof(decimal)) return TryGetBasicValue(strValue, decimal.TryParse, out value); - - if (underlyingType.IsEnum) - { - if (Enum.IsDefined(underlyingType, strValue)) - { - value = (T)Enum.Parse(underlyingType, strValue, ignoreCase: false); - return true; - } - value = default; - return false; - } - - try - { - value = (T)Convert.ChangeType(currentValue, targetType); - return true; - } - catch - { - value = default; - return false; - } - } - - private static bool TryGetDateTimeValue(out T value, object currentValue) - { - if (typeof(T) != typeof(DateTime) && typeof(T) != typeof(DateTime?)) - { - value = default; - return false; - } - - if (currentValue is T v) { value = v; return true; } - - if (currentValue.IsNumber()) - { - var dbl1 = Convert.ToDouble(currentValue); - if (dbl1.IsValidOADateNumber()) - { - value = (T)Convert.ChangeType(DateTime.FromOADate(dbl1), typeof(T)); - return true; - } - } - - if (DateTime.TryParse(currentValue.ToString(), out DateTime ts)) - { - value = (T)Convert.ChangeType(ts, typeof(T)); - return true; - } - - // If the cell value is a string, e.g. "42020", we could theoretically coerce it to a DateTime, but this seems to go against what is expected. - // Leaving this code block here, though. Maybe we revert our decision later. - - //if (Double.TryParse(currentValue.ObjectToInvariantString(), out Double dbl2) && dbl2.IsValidOADateNumber()) - //{ - // value = (T)Convert.ChangeType(DateTime.FromOADate(dbl2), typeof(T)); - // return true; - //} - - value = default; - return false; - } - - private static bool TryGetTimeSpanValue(out T value, object currentValue) - { - if (typeof(T) != typeof(TimeSpan) && typeof(T) != typeof(TimeSpan?)) - { - value = default; - return false; - } - - if (currentValue is T v) { value = v; return true; } - - if (!TimeSpan.TryParse(currentValue.ToString(), out TimeSpan ts)) - { - value = default; - return false; - } - - value = (T)Convert.ChangeType(ts, typeof(T)); - return true; - } - - private bool TryGetRichStringValue(out T value) - { - if (typeof(T) == typeof(IXLRichText)) - { - value = (T)GetRichText(); - return true; - } - value = default; - return false; - } - - private static bool TryGetStringValue(out T value, object currentValue) - { - if (typeof(T) == typeof(String)) - { - var s = currentValue.ToString(); - var matches = utfPattern.Matches(s); - - if (matches.Count == 0) - { - value = (T)Convert.ChangeType(s, typeof(T)); - return true; - } - - var sb = new StringBuilder(); - var lastIndex = 0; - - foreach (var match in matches.Cast()) - { - var matchString = match.Value; - var matchIndex = match.Index; - sb.Append(s.Substring(lastIndex, matchIndex - lastIndex)); - - sb.Append((char)int.Parse(match.Groups[1].Value, NumberStyles.AllowHexSpecifier)); - - lastIndex = matchIndex + matchString.Length; - } - - if (lastIndex < s.Length) - sb.Append(s.Substring(lastIndex)); - - value = (T)Convert.ChangeType(sb.ToString(), typeof(T)); - return true; - } - value = default; - return false; - } - - private static Boolean TryGetBooleanValue(out T value, object currentValue) - { - if (typeof(T) != typeof(Boolean) && typeof(T) != typeof(Boolean?)) - { - value = default; - return false; - } - - if (currentValue is T v) { value = v; return true; } - - if (!Boolean.TryParse(currentValue.ToString(), out Boolean b)) - { - value = default; - return false; - } + public Boolean HasHyperlink => Worksheet.Hyperlinks.TryGet(SheetPoint, out _); - value = (T)Convert.ChangeType(b, typeof(T)); - return true; - } - - private Boolean TryGetHyperlink(out T value) + /// + public Boolean ShowPhonetic { - if (typeof(T) == typeof(XLHyperlink)) + get => _cellsCollection.MiscSlice[_rowNumber, _columnNumber].HasPhonetic; + set { - var hyperlink = _hyperlink ?? Value as XLHyperlink; - if (hyperlink != null) + ref readonly var original = ref _cellsCollection.MiscSlice[_rowNumber, _columnNumber]; + if (original.HasPhonetic != value) { - value = (T)Convert.ChangeType(hyperlink, typeof(T)); - return true; + var modified = original; + modified.HasPhonetic = value; + _cellsCollection.MiscSlice.Set(_rowNumber, _columnNumber, in modified); } } - - value = default; - return false; - } - - private delegate Boolean ParseFunction(String s, NumberStyles style, IFormatProvider provider, out T result); - - private static Boolean TryGetBasicValue(String currentValue, ParseFunction parseFunction, out T value) - { - if (parseFunction.Invoke(currentValue, NumberStyles.Any, null, out U result)) - { - value = (T)Convert.ChangeType(result, typeof(T).GetUnderlyingType()); - return true; - } - - value = default; - return false; } #endregion IXLCell Members #region IXLStylized Members - public override IEnumerable Styles - { - get - { - yield return Style; - } - } - void IXLStylized.ModifyStyle(Func modification) { //XLCell cannot have children so the base method may be optimized @@ -1763,109 +1106,14 @@ public override IXLRanges RangesUsed } } - #endregion IXLStylized Members - - private Boolean SetTableHeaderValue(object value) - { - foreach (var table in Worksheet.Tables.Where(t => t.ShowHeaderRow)) - { - var cell = table.HeadersRow().CellsUsed(c => c.Address.Equals(this.Address)).FirstOrDefault(); - if (cell != null) - { - var oldName = cell.GetString(); - var field = table.Field(oldName); - field.Name = value.ToString(); - return true; - } - } - - return false; - } - - private Boolean SetTableTotalsRowLabel(object value) - { - foreach (var table in Worksheet.Tables.Where(t => t.ShowTotalsRow)) - { - var cell = table.TotalsRow().Cells(c => c.Address.Equals(this.Address)).FirstOrDefault(); - if (cell != null) - { - var field = table.Fields.First(f => f.Column.ColumnNumber() == cell.WorksheetColumn().ColumnNumber()); - field.TotalsRowFunction = XLTotalsRowFunction.None; - - SetInternalCellValueString(value.ObjectToInvariantString(), validate: true, parseToCachedValue: false); - - field.TotalsRowLabel = _cellValue; - this.DataType = XLDataType.Text; - return true; - } - } - - return false; - } - - private bool SetRangeColumns(object value) - { - if (value is XLRangeColumns columns) - { - var cell = this; - foreach (var column in columns) - { - cell.SetRange(column); - cell = cell.CellRight(); - } - return true; - } - else - - return SetColumns(value); - } - - private bool SetColumns(object value) - { - if (value is XLColumns columns) - { - var cell = this; - foreach (var column in columns) - { - cell.SetRange(column); - cell = cell.CellRight(); - } - return true; - } - else - return false; - } - - private bool SetRangeRows(object value) - { - if (value is XLRangeRows rows) - { - var cell = this; - foreach (var row in rows) - { - cell.SetRange(row); - cell = cell.CellBelow(); - } - return true; - } - else - return SetRows(value); - } + #endregion IXLStylized Members - private bool SetRows(object value) + /// + /// Ensure the cell has style set directly on the cell, not inherited from column/row/worksheet styles. + /// + internal void PingStyle() { - if (value is XLRows rows) - { - var cell = this; - foreach (var row in rows) - { - cell.SetRange(row); - cell = cell.CellBelow(); - } - return true; - } - else - return false; + StyleValue = StyleValue; } public XLRange AsRange() @@ -1902,17 +1150,6 @@ public void DeleteSparkline() Clear(XLClearOptions.Sparklines); } - private bool IsDateFormat() - { - var style = GetStyleForRead(); - return _dataType == XLDataType.Number - && String.IsNullOrWhiteSpace(style.NumberFormat.Format) - && ((style.NumberFormat.NumberFormatId >= 14 - && style.NumberFormat.NumberFormatId <= 22) - || (style.NumberFormat.NumberFormatId >= 45 - && style.NumberFormat.NumberFormatId <= 47)); - } - private string GetFormat() { var style = GetStyleForRead(); @@ -1928,98 +1165,83 @@ private string GetFormat() return style.NumberFormat.Format; } - private bool SetRichText(object value) + public IXLCell CopyFrom(IXLRangeBase rangeObject) { - if (value is XLRichText asRichString) - { - _richText = asRichString; - _dataType = XLDataType.Text; - return true; - } - else - return false; - } + if (rangeObject is null) + throw new ArgumentNullException(nameof(rangeObject)); - private Boolean SetRange(Object rangeObject) - { - var asRange = (rangeObject as XLRangeBase) - ?? (rangeObject as XLCell)?.AsRange(); + var asRange = (XLRangeBase)rangeObject; + var maxRows = asRange.RowCount(); + var maxColumns = asRange.ColumnCount(); - if (asRange != null) - { - var maxRows = asRange.RowCount(); - var maxColumns = asRange.ColumnCount(); + var lastRow = Math.Min(_rowNumber + maxRows - 1, XLHelper.MaxRowNumber); + var lastColumn = Math.Min(_columnNumber + maxColumns - 1, XLHelper.MaxColumnNumber); - var lastRow = Math.Min(_rowNumber + maxRows - 1, XLHelper.MaxRowNumber); - var lastColumn = Math.Min(_columnNumber + maxColumns - 1, XLHelper.MaxColumnNumber); + var targetRange = Worksheet.Range(_rowNumber, _columnNumber, lastRow, lastColumn); - var targetRange = Worksheet.Range(_rowNumber, _columnNumber, lastRow, lastColumn); + if (!(asRange is XLRow || asRange is XLColumn)) + { + targetRange.Clear(); + } - if (!(asRange is XLRow || asRange is XLColumn)) - { - targetRange.Clear(); - } + var minRow = asRange.RangeAddress.FirstAddress.RowNumber; + var minColumn = asRange.RangeAddress.FirstAddress.ColumnNumber; + var cellsUsed = asRange.CellsUsed(XLCellsUsedOptions.All + & ~XLCellsUsedOptions.ConditionalFormats + & ~XLCellsUsedOptions.DataValidation + & ~XLCellsUsedOptions.MergedRanges); + foreach (var sourceCell in cellsUsed) + { + Worksheet.Cell( + _rowNumber + sourceCell.Address.RowNumber - minRow, + _columnNumber + sourceCell.Address.ColumnNumber - minColumn + ).CopyFromInternal(sourceCell as XLCell, + XLCellCopyOptions.All + & ~XLCellCopyOptions.ConditionalFormats + & ~XLCellCopyOptions.DataValidations); //Conditional formats and data validation are copied separately + } - var minRow = asRange.RangeAddress.FirstAddress.RowNumber; - var minColumn = asRange.RangeAddress.FirstAddress.ColumnNumber; - var cellsUsed = asRange.CellsUsed(XLCellsUsedOptions.All - & ~XLCellsUsedOptions.ConditionalFormats - & ~XLCellsUsedOptions.DataValidation - & ~XLCellsUsedOptions.MergedRanges); - foreach (var sourceCell in cellsUsed) + var rangesToMerge = asRange.Worksheet.Internals.MergedRanges + .Where(mr => asRange.Contains(mr)) + .Select(mr => { - Worksheet.Cell( - _rowNumber + sourceCell.Address.RowNumber - minRow, - _columnNumber + sourceCell.Address.ColumnNumber - minColumn - ).CopyFromInternal(sourceCell as XLCell, - XLCellCopyOptions.All & ~XLCellCopyOptions.ConditionalFormats); //Conditional formats are copied separately - } - - var rangesToMerge = asRange.Worksheet.Internals.MergedRanges - .Where(mr => asRange.Contains(mr)) - .Select(mr => - { - var firstRow = _rowNumber + (mr.RangeAddress.FirstAddress.RowNumber - asRange.RangeAddress.FirstAddress.RowNumber); - var firstColumn = _columnNumber + (mr.RangeAddress.FirstAddress.ColumnNumber - asRange.RangeAddress.FirstAddress.ColumnNumber); - return (IXLRange)Worksheet.Range - ( - firstRow, - firstColumn, - firstRow + mr.RowCount() - 1, - firstColumn + mr.ColumnCount() - 1 - ); - }) - .ToList(); + var firstRow = _rowNumber + (mr.RangeAddress.FirstAddress.RowNumber - asRange.RangeAddress.FirstAddress.RowNumber); + var firstColumn = _columnNumber + (mr.RangeAddress.FirstAddress.ColumnNumber - asRange.RangeAddress.FirstAddress.ColumnNumber); + return (IXLRange)Worksheet.Range + ( + firstRow, + firstColumn, + firstRow + mr.RowCount() - 1, + firstColumn + mr.ColumnCount() - 1 + ); + }) + .ToList(); - rangesToMerge.ForEach(r => r.Merge(false)); + rangesToMerge.ForEach(r => r.Merge(false)); - var dataValidations = asRange.Worksheet.DataValidations - .GetAllInRange(asRange.RangeAddress) - .ToList(); + var dataValidations = asRange.Worksheet.DataValidations + .GetAllInRange(asRange.RangeAddress) + .ToList(); - foreach (var dataValidation in dataValidations) + foreach (var dataValidation in dataValidations) + { + XLDataValidation newDataValidation = null; + foreach (var dvRange in dataValidation.Ranges.Where(r => r.Intersects(asRange))) { - XLDataValidation newDataValidation = null; - foreach (var dvRange in dataValidation.Ranges.Where(r => r.Intersects(asRange))) + var dvTargetAddress = dvRange.RangeAddress.Relative(asRange.RangeAddress, targetRange.RangeAddress); + var dvTargetRange = Worksheet.Range(dvTargetAddress); + if (newDataValidation == null) { - var dvTargetAddress = dvRange.RangeAddress.Relative(asRange.RangeAddress, targetRange.RangeAddress); - var dvTargetRange = Worksheet.Range(dvTargetAddress); - if (newDataValidation == null) - { - newDataValidation = dvTargetRange.CreateDataValidation() as XLDataValidation; - newDataValidation.CopyFrom(dataValidation); - } - else - newDataValidation.AddRange(dvTargetRange); + newDataValidation = dvTargetRange.CreateDataValidation() as XLDataValidation; + newDataValidation.CopyFrom(dataValidation); } + else + newDataValidation.AddRange(dvTargetRange); } - - CopyConditionalFormatsFrom(asRange); - - return true; } - return false; + CopyConditionalFormatsFrom(asRange); + return this; } private void CopyConditionalFormatsFrom(XLCell otherCell) @@ -2106,286 +1328,31 @@ private void ClearMerged() mergeToDelete.ForEach(m => Worksheet.Internals.MergedRanges.Remove(m)); } - private void SetValue(object value) - { - if (value == null) - { - this.Clear(XLClearOptions.Contents); - return; - } - - FormulaA1 = String.Empty; - _richText = null; - - var style = GetStyleForRead(); - Boolean parsed = false; - string parsedValue = string.Empty; - - //// - // Try easy parsing first. If that doesn't work, we'll have to ToString it and parse it slowly - - // When number format starts with @, we treat any value as text - no parsing required - // This doesn't happen in the SetValue() version - if (style.NumberFormat.Format == "@") - { - parsedValue = value.ObjectToInvariantString(); - - _dataType = XLDataType.Text; - if (parsedValue.Contains(Environment.NewLine) && !style.Alignment.WrapText) - Style.Alignment.WrapText = true; - - parsed = true; - } - // Don't accept strings, because we're going to try to parse them later - else if (value is not string _) - { - parsed = TrySetInternallySupportedType(ConvertOtherSupportedTypes(value), style, out parsedValue); - } - - //// - if (!parsed) - { - // We'll have to parse it slowly :-( - parsedValue = DeduceCellValueByParsing(value.ToString(), style); - } - - if (SetTableHeaderValue(parsedValue)) return; - if (SetTableTotalsRowLabel(parsedValue)) return; - - SetInternalCellValueString(parsedValue, validate: true, parseToCachedValue: false); - CachedValue = null; - } - - private void SetDateTimeFormat(XLStyleValue style, Boolean onlyDatePart) - { - _dataType = XLDataType.DateTime; - - if (style.NumberFormat.Format.Length == 0 && style.NumberFormat.NumberFormatId == 0) - Style.NumberFormat.NumberFormatId = onlyDatePart ? 14 : 22; - } - - private void SetTimeSpanFormat(XLStyleValue style) - { - _dataType = XLDataType.TimeSpan; - - if (style.NumberFormat.Format.Length == 0 && style.NumberFormat.NumberFormatId == 0) - Style.NumberFormat.NumberFormatId = 46; - } - internal string GetFormulaR1C1(string value) { - return GetFormula(value, FormulaConversionType.A1ToR1C1, 0, 0); + return XLCellFormula.GetFormula(value, FormulaConversionType.A1ToR1C1, new XLSheetPoint(_rowNumber, _columnNumber)); } internal string GetFormulaA1(string value) { - return GetFormula(value, FormulaConversionType.R1C1ToA1, 0, 0); - } - - private string GetFormula(string strValue, FormulaConversionType conversionType, int rowsToShift, - int columnsToShift) - { - if (String.IsNullOrWhiteSpace(strValue)) - return String.Empty; - - var value = ">" + strValue + "<"; - - var regex = conversionType == FormulaConversionType.A1ToR1C1 ? A1Regex : R1C1Regex; - - var sb = new StringBuilder(); - var lastIndex = 0; - - foreach (var match in regex.Matches(value).Cast()) - { - var matchString = match.Value; - var matchIndex = match.Index; - if (value.Substring(0, matchIndex).CharCount('"') % 2 == 0 - && value.Substring(0, matchIndex).CharCount('\'') % 2 == 0) - { - // Check if the match is in between quotes - sb.Append(value.Substring(lastIndex, matchIndex - lastIndex)); - sb.Append(conversionType == FormulaConversionType.A1ToR1C1 - ? GetR1C1Address(matchString, rowsToShift, columnsToShift) - : GetA1Address(matchString, rowsToShift, columnsToShift)); - } - else - sb.Append(value.Substring(lastIndex, matchIndex - lastIndex + matchString.Length)); - lastIndex = matchIndex + matchString.Length; - } - - if (lastIndex < value.Length) - sb.Append(value.Substring(lastIndex)); - - var retVal = sb.ToString(); - return retVal.Substring(1, retVal.Length - 2); - } - - private string GetA1Address(string r1C1Address, int rowsToShift, int columnsToShift) - { - var addressToUse = r1C1Address.ToUpper(); - - if (addressToUse.Contains(':')) - { - var parts = addressToUse.Split(':'); - var p1 = parts[0]; - var p2 = parts[1]; - string leftPart; - string rightPart; - if (p1.StartsWith("R")) - { - leftPart = GetA1Row(p1, rowsToShift); - rightPart = GetA1Row(p2, rowsToShift); - } - else - { - leftPart = GetA1Column(p1, columnsToShift); - rightPart = GetA1Column(p2, columnsToShift); - } - - return leftPart + ":" + rightPart; - } - - try - { - var rowPart = addressToUse.Substring(0, addressToUse.IndexOf("C")); - var rowToReturn = GetA1Row(rowPart, rowsToShift); - - var columnPart = addressToUse.Substring(addressToUse.IndexOf("C")); - var columnToReturn = GetA1Column(columnPart, columnsToShift); - - var retAddress = columnToReturn + rowToReturn; - return retAddress; - } - catch (ArgumentOutOfRangeException) - { - return "#REF!"; - } - } - - private string GetA1Column(string columnPart, int columnsToShift) - { - string columnToReturn; - if (columnPart == "C") - columnToReturn = XLHelper.GetColumnLetterFromNumber(_columnNumber + columnsToShift); - else - { - var bIndex = columnPart.IndexOf("["); - var mIndex = columnPart.IndexOf("-"); - if (bIndex >= 0) - { - columnToReturn = XLHelper.GetColumnLetterFromNumber( - _columnNumber + - Int32.Parse(columnPart.Substring(bIndex + 1, columnPart.Length - bIndex - 2)) + columnsToShift - ); - } - else if (mIndex >= 0) - { - columnToReturn = XLHelper.GetColumnLetterFromNumber( - _columnNumber + Int32.Parse(columnPart.Substring(mIndex)) + columnsToShift - ); - } - else - { - columnToReturn = "$" + - XLHelper.GetColumnLetterFromNumber(Int32.Parse(columnPart.Substring(1)) + - columnsToShift); - } - } - - return columnToReturn; - } - - private string GetA1Row(string rowPart, int rowsToShift) - { - string rowToReturn; - if (rowPart == "R") - rowToReturn = (_rowNumber + rowsToShift).ToString(); - else - { - var bIndex = rowPart.IndexOf("["); - if (bIndex >= 0) - { - rowToReturn = - (_rowNumber + Int32.Parse(rowPart.Substring(bIndex + 1, rowPart.Length - bIndex - 2)) + - rowsToShift).ToString(); - } - else - rowToReturn = "$" + (Int32.Parse(rowPart.Substring(1)) + rowsToShift); - } - - return rowToReturn; - } - - private string GetR1C1Address(string a1Address, int rowsToShift, int columnsToShift) - { - if (a1Address.Contains(':')) - { - var parts = a1Address.Split(':'); - var p1 = parts[0]; - var p2 = parts[1]; - if (Int32.TryParse(p1.Replace("$", string.Empty), out Int32 row1)) - { - var row2 = Int32.Parse(p2.Replace("$", string.Empty)); - var leftPart = GetR1C1Row(row1, p1.Contains('$'), rowsToShift); - var rightPart = GetR1C1Row(row2, p2.Contains('$'), rowsToShift); - return leftPart + ":" + rightPart; - } - else - { - var column1 = XLHelper.GetColumnNumberFromLetter(p1.Replace("$", string.Empty)); - var column2 = XLHelper.GetColumnNumberFromLetter(p2.Replace("$", string.Empty)); - var leftPart = GetR1C1Column(column1, p1.Contains('$'), columnsToShift); - var rightPart = GetR1C1Column(column2, p2.Contains('$'), columnsToShift); - return leftPart + ":" + rightPart; - } - } - - var address = XLAddress.Create(Worksheet, a1Address); - - var rowPart = GetR1C1Row(address.RowNumber, address.FixedRow, rowsToShift); - var columnPart = GetR1C1Column(address.ColumnNumber, address.FixedColumn, columnsToShift); - - return rowPart + columnPart; - } - - private string GetR1C1Row(int rowNumber, bool fixedRow, int rowsToShift) - { - string rowPart; - rowNumber += rowsToShift; - var rowDiff = rowNumber - _rowNumber; - if (rowDiff != 0 || fixedRow) - rowPart = fixedRow ? "R" + rowNumber : "R[" + rowDiff + "]"; - else - rowPart = "R"; - - return rowPart; + return XLCellFormula.GetFormula(value, FormulaConversionType.R1C1ToA1, new XLSheetPoint(_rowNumber, _columnNumber)); } - private string GetR1C1Column(int columnNumber, bool fixedColumn, int columnsToShift) + internal void CopyValuesFrom(XLCell source) { - string columnPart; - columnNumber += columnsToShift; - var columnDiff = columnNumber - _columnNumber; - if (columnDiff != 0 || fixedColumn) - columnPart = fixedColumn ? "C" + columnNumber : "C[" + columnDiff + "]"; + // Rich text is basically a super set of a value. Setting a value would override rich text and vice versa. + var sourceRichText = source.SliceRichText; + if (sourceRichText is null) + SliceCellValue = source.SliceCellValue; else - columnPart = "C"; - - return columnPart; - } + SliceRichText = sourceRichText; - internal void CopyValuesFrom(XLCell source) - { - _cellValue = source._cellValue; - _dataType = source._dataType; FormulaR1C1 = source.FormulaR1C1; - _richText = source._richText == null ? null : new XLRichText(source._richText, source.Style.Font); - _comment = source._comment == null ? null : new XLComment(this, source._comment, source.Style.Font, source._comment.Style); - if (source._hyperlink != null) + SliceComment = source.SliceComment == null ? null : new XLComment(this, source.SliceComment, source.Style.Font, source.SliceComment.Style); + + if (source.Worksheet.Hyperlinks.TryGet(source.SheetPoint, out var sourceHyperlink)) { - SettingHyperlink = true; - SetHyperlink(new XLHyperlink(source.GetHyperlink())); - SettingHyperlink = false; + SetCellHyperlink(new XLHyperlink(sourceHyperlink)); } } @@ -2429,7 +1396,7 @@ private void CopySparklineFrom(XLCell otherCell) var shiftedRangeAddress = GetFormulaA1(otherCell.GetFormulaR1C1(sourceDataAddress)); var sourceDataWorksheet = otherCell.Worksheet == otherCell.Sparkline.SourceData.Worksheet ? Worksheet - : otherCell.Sparkline.SourceData.Worksheet; + : (XLWorksheet)otherCell.Sparkline.SourceData.Worksheet; var sourceData = sourceDataWorksheet.Range(shiftedRangeAddress); IXLSparklineGroup group; @@ -2465,15 +1432,12 @@ public IXLCell CopyFrom(IXLCell otherCell, XLCellCopyOptions options) private void CopyDataValidationFrom(XLCell otherCell) { - var eventTracking = Worksheet.EventTrackingEnabled; - Worksheet.EventTrackingEnabled = false; if (otherCell.HasDataValidation) CopyDataValidation(otherCell, otherCell.GetDataValidation()); else if (HasDataValidation) { Worksheet.DataValidations.Delete(AsRange()); } - Worksheet.EventTrackingEnabled = eventTracking; } internal void CopyDataValidation(XLCell otherCell, IXLDataValidation otherDv) @@ -2802,16 +1766,6 @@ private XLCell CellShift(Int32 rowsToShift, Int32 columnsToShift) return Worksheet.Cell(_rowNumber + rowsToShift, _columnNumber + columnsToShift); } - #region Nested type: FormulaConversionType - - private enum FormulaConversionType - { - A1ToR1C1, - R1C1ToA1 - }; - - #endregion Nested type: FormulaConversionType - #region XLCell Above IXLCell IXLCell.CellAbove() @@ -2908,39 +1862,123 @@ public XLCell CellRight(Int32 step) #endregion XLCell Right - public Boolean HasFormula - { get { return !String.IsNullOrWhiteSpace(FormulaA1); } } - - public Boolean HasArrayFormula - { get { return FormulaA1.StartsWith("{"); } } + public Boolean HasFormula => Formula is not null; - public IXLRangeAddress FormulaReference { get; set; } + public Boolean HasArrayFormula => Formula?.Type == FormulaType.Array; - public IXLRange CurrentRegion + public IXLRangeAddress FormulaReference { get { - return this.Worksheet.Range(FindCurrentRegion(this.AsRange())); + if (Formula is null) + return null; + + var range = Formula.Range; + if (range == default) + return null; + + return XLRangeAddress.FromSheetRange(Worksheet, range); + } + set + { + if (Formula is null) + throw new ArgumentException("Cell doesn't contain a formula."); + + if (value is null) + { + Formula.Range = default; + return; + } + + if (value.Worksheet is not null && Worksheet != value.Worksheet) + throw new ArgumentException("The reference worksheet must be same as worksheet of the cell or null."); + + Formula.Range = XLSheetRange.FromRangeAddress(value); } } - internal IXLRangeAddress FindCurrentRegion(IXLRangeBase range) + public IXLRange CurrentRegion => Worksheet.Range(FindCurrentRegion()); + + private IXLRangeAddress FindCurrentRegion() { - var rangeAddress = range.RangeAddress; + var sheet = Worksheet; - var filledCells = range - .SurroundingCells(c => !(c as XLCell).IsEmpty(XLCellsUsedOptions.AllContents)) - .Concat(this.Worksheet.Range(rangeAddress).Cells()); + var minRow = _rowNumber; + var minCol = _columnNumber; + var maxRow = _rowNumber; + var maxCol = _columnNumber; - var grownRangeAddress = new XLRangeAddress( - new XLAddress(this.Worksheet, filledCells.Min(c => c.Address.RowNumber), filledCells.Min(c => c.Address.ColumnNumber), false, false), - new XLAddress(this.Worksheet, filledCells.Max(c => c.Address.RowNumber), filledCells.Max(c => c.Address.ColumnNumber), false, false) - ); + bool hasRegionExpanded; - if (rangeAddress.Equals(grownRangeAddress)) - return this.Worksheet.Range(grownRangeAddress).RangeAddress; - else - return FindCurrentRegion(this.Worksheet.Range(grownRangeAddress)); + do + { + hasRegionExpanded = false; + + var borderMinRow = Math.Max(minRow - 1, XLHelper.MinRowNumber); + var borderMaxRow = Math.Min(maxRow + 1, XLHelper.MaxRowNumber); + var borderMinColumn = Math.Max(minCol - 1, XLHelper.MinColumnNumber); + var borderMaxColumn = Math.Min(maxCol + 1, XLHelper.MaxColumnNumber); + + if (minCol > XLHelper.MinColumnNumber && + !IsVerticalBorderBlank(sheet, borderMinColumn, borderMinRow, borderMaxRow)) + { + hasRegionExpanded = true; + minCol = borderMinColumn; + } + + if (maxCol < XLHelper.MaxColumnNumber && + !IsVerticalBorderBlank(sheet, borderMaxColumn, borderMinRow, borderMaxRow)) + { + hasRegionExpanded = true; + maxCol = borderMaxColumn; + } + + if (minRow > XLHelper.MinRowNumber && + !IsHorizontalBorderBlank(sheet, borderMinRow, borderMinColumn, borderMaxColumn)) + { + hasRegionExpanded = true; + minRow = borderMinRow; + } + + if (maxRow < XLHelper.MaxRowNumber && + !IsHorizontalBorderBlank(sheet, borderMaxRow, borderMinColumn, borderMaxColumn)) + { + hasRegionExpanded = true; + maxRow = borderMaxRow; + } + } while (hasRegionExpanded); + + return new XLRangeAddress( + new XLAddress(sheet, minRow, minCol, false, false), + new XLAddress(sheet, maxRow, maxCol, false, false)); + + static bool IsVerticalBorderBlank(XLWorksheet sheet, int borderColumn, int borderMinRow, int borderMaxRow) + { + for (var row = borderMinRow; row <= borderMaxRow; row++) + { + var verticalBorderCell = sheet.Cell(row, borderColumn); + if (!verticalBorderCell.IsEmpty(XLCellsUsedOptions.AllContents)) + { + return false; + } + } + + return true; + } + + static bool IsHorizontalBorderBlank(XLWorksheet sheet, int borderRow, int borderMinColumn, int borderMaxColumn) + { + for (var col = borderMinColumn; col <= borderMaxColumn; col++) + { + var horizontalBorderCell = sheet.Cell(borderRow, col); + if (!horizontalBorderCell.IsEmpty(XLCellsUsedOptions.AllContents)) + { + return false; + } + } + + return true; + } } internal bool IsInferiorMergedCell() @@ -2952,5 +1990,88 @@ internal bool IsSuperiorMergedCell() { return this.IsMerged() && this.Address.Equals(this.MergedRange().RangeAddress.FirstAddress); } + + /// + /// Get glyph bounding boxes for each grapheme in the text. Box size is determined according to + /// the font of a grapheme. New lines are represented as default (all dimensions zero) box. + /// A line without any text (i.e. contains only new line) should be represented by a box + /// with zero advance width, but with a line height of corresponding font. + /// + /// Engine used to determine box size. + /// DPI used to determine size of glyphs. + /// List where items are added. + internal void GetGlyphBoxes(IXLGraphicEngine engine, Dpi dpi, List output) + { + var richText = SliceRichText; + if (richText is not null) + { + foreach (var richTextRun in richText.Runs) + { + var text = richText.GetRunText(richTextRun); + var font = new XLFont(richTextRun.Font.Key); + AddGlyphs(text, font, engine, dpi, output); + } + } + else + { + var text = GetFormattedString(); + AddGlyphs(text, Style.Font, engine, dpi, output); + } + + static void AddGlyphs(string text, IXLFontBase font, IXLGraphicEngine engine, Dpi dpi, List output) + { + Span zeroWidthJoiner = stackalloc int[1] { 0x200D }; + var prevWasNewLine = false; + var graphemeStarts = StringInfo.ParseCombiningCharacters(text); + var textSpan = text.AsSpan(); + + // If we have more than 1 code unit per grapheme, the code units can + // be distributed through multiple grapheme. In the worst case, all extra + // code units are in exactly one grapheme -> allocate buffer of that size. + Span codePointsBuffer = stackalloc int[1 + text.Length - graphemeStarts.Length]; + for (var i = 0; i < graphemeStarts.Length; ++i) + { + var startIdx = graphemeStarts[i]; + var slice = textSpan.Slice(startIdx); + if (slice.TrySliceNewLine(out var eolLen)) + { + i += eolLen - 1; + if (prevWasNewLine) + { + // If there are consecutive new lines, we need height of new the lines between them + var box = engine.GetGlyphBox(zeroWidthJoiner, font, dpi); + output.Add(box); + } + + output.Add(GlyphBox.LineBreak); + prevWasNewLine = true; + } + else + { + var codeUnits = i + 1 < graphemeStarts.Length + ? textSpan.Slice(startIdx, graphemeStarts[i + 1] - startIdx) + : textSpan.Slice(startIdx); + var count = codeUnits.ToCodePoints(codePointsBuffer); + ReadOnlySpan grapheme = codePointsBuffer.Slice(0, count); + var box = engine.GetGlyphBox(grapheme, font, dpi); + output.Add(box); + prevWasNewLine = false; + } + } + } + } + + public override int GetHashCode() + { + unchecked + { + return (SheetPoint.GetHashCode() * 397) ^ Worksheet.GetHashCode(); + } + } + + public override bool Equals(object obj) + { + return obj is XLCell cell && cell.Worksheet == Worksheet && cell.SheetPoint == SheetPoint; + } } } diff --git a/ClosedXML/Excel/Cells/XLCellCopyOptions.cs b/ClosedXML/Excel/Cells/XLCellCopyOptions.cs index 6c8e59e5b..f91ad3dbb 100644 --- a/ClosedXML/Excel/Cells/XLCellCopyOptions.cs +++ b/ClosedXML/Excel/Cells/XLCellCopyOptions.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Cells/XLCellFormula.cs b/ClosedXML/Excel/Cells/XLCellFormula.cs new file mode 100644 index 000000000..d0ae8a74e --- /dev/null +++ b/ClosedXML/Excel/Cells/XLCellFormula.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using ClosedXML.Excel.CalcEngine; +using ClosedXML.Excel.CalcEngine.Visitors; +using ClosedXML.Parser; + +namespace ClosedXML.Excel +{ + internal enum FormulaType : byte + { + Normal, + Array, + DataTable, + Shared // Not used + } + + internal enum FormulaConversionType + { + A1ToR1C1, + R1C1ToA1 + }; + + /// + /// A representation of a cell formula, not the formula itself (i.e. the tree). + /// + [DebuggerDisplay("Cell:{Range} - Type: {Type} - Formula: {A1}")] + internal sealed class XLCellFormula + { + /// + /// This is only a placeholder, so the data table formula looks like array formula for saving code. + /// First argument is replaced by value from current row, second is replaced by value from current column. + /// + private const string DataTableFormulaFormat = "{{TABLE({0},{1}}}"; + + private XLSheetPoint _input1; + private XLSheetPoint _input2; + private FormulaFlags _flags; + + /// + /// Is this formula dirty, i.e. is it potentially out of date due to changes + /// to precedent cells? + /// + internal bool IsDirty { get; set; } + + /// + /// Formula in A1 notation. Doesn't start with = sign. + /// + internal string A1 { get; private set; } + + internal FormulaType Type { get; private init; } + + /// + /// Range for array and data table formulas, otherwise default value. + /// + /// Doesn't contain sheet, so it doesn't have to deal with + /// sheet renames and moving formula around. + internal XLSheetRange Range { get; set; } + + /// + /// True, if 1D data table formula is the row (the displayed formula in Excel is missing the second argument {=TABLE(A1;)}). + /// False the 1D data table is a column. (the displayed formula in Excel is missing the first argument {=TABLE(;A1)}) + /// This property is meaningless, if called for non-data-table formula. + /// + /// + /// + /// If data table is in row (i.e. the value returns true) that means it calculates values in a row, + /// it takes formula from a cell from a column one less than its range and replaces the input cell with value + /// at the intersection of current cell column and the top row of the range. When data table is a column, it works + /// pretty much same, except axis are reversed. + /// + /// + /// Just because data table is 1D doesn't mean its range has to be. It can be rectangular even for 1D + /// data table. It just means that data table is applied separately to each row/column (depending on whether + /// the data table is row or column). + /// + /// + internal Boolean IsRowDataTable => _flags.HasFlag(FormulaFlags.Is1DRow); + + /// + /// True, if data table is 2D and uses both inputs. Input1 is replaced by + /// value from current row, input2 is replaced by a value from current column. + /// This property is meaningless, if called for non-data-table formula. + /// + internal Boolean Is2DDataTable => _flags.HasFlag(FormulaFlags.Is2D); + + /// + /// Returns a cell that data table formula uses as a variable to replace with values + /// for the actual table. Used for 1D data table formula as a single input (row or column) + /// and as row for 2D data table. Must be present, even if input marked as deleted. + /// This property is meaningless, if called for non-data-table formula. + /// + internal XLSheetPoint Input1 => _input1; + + /// + /// Returns a cell that 2D data table formula uses as a variable to replace with values + /// for the actual table. The value is taken from the top of range of the current column. + /// Must be present for 2D, even if input marked as deleted. + /// This property is meaningless, if called for non-data-table formula. + /// + internal XLSheetPoint Input2 => _input2; + + /// + /// Returns true, if data table formula has its input1 deleted. + /// This property is meaningless, if called for non-data-table formula. + /// + internal Boolean Input1Deleted => _flags.HasFlag(FormulaFlags.Input1Deleted); + + /// + /// Returns true, if data table formula has its input1 deleted. + /// This property is meaningless, if called for non-data-table formula. + /// + internal Boolean Input2Deleted => _flags.HasFlag(FormulaFlags.Input2Deleted); + + private XLCellFormula(string a1) + { + A1 = a1; + } + + /// + /// Get stored formula in R1C1 notation. Returned formula doesn't contain equal sign. + /// + public string GetFormulaR1C1(XLSheetPoint cellAddress) + { + return GetFormula(A1, FormulaConversionType.A1ToR1C1, cellAddress); + } + + internal static string GetFormula(string strValue, FormulaConversionType conversionType, XLSheetPoint cellAddress) + { + if (String.IsNullOrWhiteSpace(strValue)) + return String.Empty; + + // Users and some producers might prefix formula with '=', but that is not a valid + // formula, so strip and re-add if present. + var formula = strValue.Trim(); + if (formula.StartsWith('=')) + formula = formula[1..]; + + var converted = conversionType switch + { + FormulaConversionType.A1ToR1C1 => FormulaConverter.ToR1C1(formula, cellAddress.Row, cellAddress.Column), + FormulaConversionType.R1C1ToA1 => FormulaConverter.ToA1(formula, cellAddress.Row, cellAddress.Column), + _ => throw new NotSupportedException() + }; + + if (formula.Length != strValue.Length) + converted = strValue[..^formula.Length] + converted; + + return converted; + } + + /// + /// A factory method to create a normal A1 formula. Doesn't affect recalculation version. + /// + /// Formula in A1 form. Shouldn't start with =. + internal static XLCellFormula NormalA1(string formulaA1) + { + return new XLCellFormula(formulaA1) + { + Type = FormulaType.Normal, + _flags = FormulaFlags.None + }; + } + + /// + /// A factory method to create an array formula. Doesn't affect recalculation version. + /// + /// Isn't wrapped in {} and doesn't start with =. + /// A range of cells that are calculated through the array formula. + /// A flag for always calculate array. + internal static XLCellFormula Array(string arrayFormulaA1, XLSheetRange range, bool aca) + { + return new XLCellFormula(arrayFormulaA1) + { + Type = FormulaType.Array, + _flags = aca ? FormulaFlags.AlwaysCalculateArray : FormulaFlags.None, + Range = range + }; + } + + /// + /// A factory method to create a cell formula for 1D data table formula. Doesn't affect recalculation version. + /// + /// Range of the data table formula. Even 1D table can have rectangular range. + /// Address of the input cell that will be replaced in the data table. If input deleted, ignored and value can be anything. + /// Was the original address deleted? + /// Is data table in row (true) or columns (false)? + internal static XLCellFormula DataTable1D( + XLSheetRange range, + XLSheetPoint input1Address, + bool input1Deleted, + bool isRowDataTable) + { + String rowInput; + String colInput; + if (isRowDataTable) + { + colInput = string.Empty; + rowInput = input1Deleted ? "#REF!" : input1Address.ToString(); + } + else + { + colInput = input1Deleted ? "#REF!" : input1Address.ToString(); + rowInput = string.Empty; + } + + var formula = string.Format(DataTableFormulaFormat, rowInput, colInput); + return new XLCellFormula(formula) + { + Range = range, + Type = FormulaType.DataTable, + _input1 = input1Address, + _flags = + (isRowDataTable ? FormulaFlags.Is1DRow : FormulaFlags.None) | + (input1Deleted ? FormulaFlags.Input1Deleted : FormulaFlags.None) + }; + } + + /// + /// A factory method to create a 2D data table formula. Doesn't affect recalculation version. + /// + /// Range of the formula. + /// Address of the input cell that will be replaced in the data table. If input deleted, ignored and value can be anything. + /// Was the original address deleted? + /// Address of the input cell that will be replaced in the data table. If input deleted, ignored and value can be anything. + /// Was the original address deleted? + internal static XLCellFormula DataTable2D( + XLSheetRange range, + XLSheetPoint input1Address, + bool input1Deleted, + XLSheetPoint input2Address, + bool input2Deleted) + { + var colInput = input1Deleted ? "#REF!" : input1Address.ToString(); + var rowInput = input2Deleted ? "#REF!" : input2Address.ToString(); + var formula = string.Format(DataTableFormulaFormat, rowInput, colInput); + return new XLCellFormula(formula) + { + Range = range, + Type = FormulaType.DataTable, + _input1 = input1Address, + _input2 = input2Address, + _flags = FormulaFlags.Is2D | + (input1Deleted ? FormulaFlags.Input1Deleted : FormulaFlags.None) | + (input2Deleted ? FormulaFlags.Input2Deleted : FormulaFlags.None) + }; + } + + /// + /// An enum to efficiently store various flags for formulas (bool takes up 1-4 bytes due to alignment). + /// Note that each type of formula uses different flags. + /// + [Flags] + private enum FormulaFlags : byte + { + None = 0, + + /// + /// For Array formula. Not fully clear from documentation, but seems to be some kind of dirty flag. + /// Current excel just writes ca="1" to each cell of array formula for cases described in the DOC. + /// + AlwaysCalculateArray = 1, + + /// + /// For data table formula. Flag whether the data table is 2D and has two inputs. + /// + Is2D = 2, + + /// + /// For data table formula. If the set, the data table is in row, not column. It uses input1 in both case, but the position + /// is interpreted differently. + /// + Is1DRow = 4, + + /// + /// For data table formula. When the input 1 cell has been deleted (not content, but the row or a column where cell was), + /// this flag is set. + /// + Input1Deleted = 8, + + /// + /// For data table formula. When the input 2 cell has been deleted (not content, but the row or a column where cell was), + /// this flag is set. + /// + Input2Deleted = 16, + } + + /// + /// Get a lazy initialized AST for the formula. + /// + /// Engine to parse the formula into AST, if necessary. + public Formula GetAst(XLCalcEngine engine) + { + var ast = engine.Parse(A1); + return ast; + } + + public override string ToString() + { + return A1; + } + + public void RenameSheet(XLSheetPoint origin, string oldSheetName, string newSheetName) + { + var a1 = A1; + var res = FormulaConverter.ModifyA1(a1, newSheetName, origin.Row, origin.Column, new RenameRefModVisitor + { + Sheets = new Dictionary { { oldSheetName, newSheetName } } + }); + + if (res != a1) + { + A1 = res; + IsDirty = true; + } + } + + internal XLCellFormula GetMovedTo(XLSheetPoint origin, XLSheetPoint destination) + { + // I could in theory swap 1x1 array or dataTable, but not worth it in this path. + if (Type != FormulaType.Normal) + throw new InvalidOperationException("Can only swap normal formulas."); + + var originR1C1 = FormulaConverter.ToR1C1(A1, origin.Row, origin.Column); + var targetA1 = FormulaConverter.ToA1(originR1C1, destination.Row, destination.Column); + var targetFormula = NormalA1(targetA1); + targetFormula.IsDirty = true; + return targetFormula; + } + } +} diff --git a/ClosedXML/Excel/Cells/XLCellValueComparer.cs b/ClosedXML/Excel/Cells/XLCellValueComparer.cs new file mode 100644 index 000000000..1721a5741 --- /dev/null +++ b/ClosedXML/Excel/Cells/XLCellValueComparer.cs @@ -0,0 +1,55 @@ +#nullable disable + +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel.Cells +{ + internal class XLCellValueComparer : IEqualityComparer + { + private readonly StringComparer _textComparer; + + internal static readonly XLCellValueComparer OrdinalIgnoreCase = new(StringComparer.OrdinalIgnoreCase); + + private XLCellValueComparer(StringComparer textComparer) + { + _textComparer = textComparer; + } + + public bool Equals(XLCellValue x, XLCellValue y) + { + if (x.Type != y.Type) + return false; + + return x.Type switch + { + XLDataType.Blank => true, + XLDataType.Boolean => x.GetBoolean() == y.GetBoolean(), + XLDataType.Number => x.GetNumber().Equals(y.GetNumber()), + XLDataType.Text => _textComparer.Equals(x.GetText(), y.GetText()), + XLDataType.Error => x.GetError() == y.GetError(), + XLDataType.DateTime => x.GetUnifiedNumber().Equals(y.GetUnifiedNumber()), + XLDataType.TimeSpan => x.GetUnifiedNumber().Equals(y.GetUnifiedNumber()), + _ => throw new NotSupportedException() + }; + } + + public int GetHashCode(XLCellValue obj) + { + unchecked + { + var hashCode = obj.Type.GetHashCode(); + var valueHashCode = obj.Type switch + { + XLDataType.Blank => 0, + XLDataType.Boolean => obj.GetBoolean().GetHashCode(), + XLDataType.Text => _textComparer.GetHashCode(obj.GetText()), + XLDataType.Error => obj.GetError().GetHashCode(), + _ => obj.GetUnifiedNumber().GetHashCode() + }; + hashCode = (hashCode * 397) ^ valueHashCode; + return hashCode; + } + } + } +} diff --git a/ClosedXML/Excel/Cells/XLCells.cs b/ClosedXML/Excel/Cells/XLCells.cs index e9ead7c57..e699c1ee9 100644 --- a/ClosedXML/Excel/Cells/XLCells.cs +++ b/ClosedXML/Excel/Cells/XLCells.cs @@ -20,7 +20,7 @@ internal class XLCells : XLStylizedBase, IXLCells, IXLStylized, IEnumerable predicate = null) + public XLCells(bool usedCellsOnly, XLCellsUsedOptions options, Func? predicate = null) : base(XLStyle.Default.Value) { _usedCellsOnly = usedCellsOnly; @@ -37,7 +37,7 @@ private IEnumerable GetAllCells() var groupedAddresses = _rangeAddresses.GroupBy(addr => addr.Worksheet); foreach (var worksheetGroup in groupedAddresses) { - var ws = worksheetGroup.Key; + var ws = worksheetGroup.Key!; var sheetPoints = worksheetGroup.SelectMany(addr => GetAllCellsInRange(addr)) .Distinct(); foreach (var sheetPoint in sheetPoints) @@ -71,10 +71,11 @@ private IEnumerable GetAllCellsInRange(IXLRangeAddress rangeAddres private IEnumerable GetUsedCells() { + var visitedCells = new HashSet(); var groupedAddresses = _rangeAddresses.GroupBy(addr => addr.Worksheet); foreach (var worksheetGroup in groupedAddresses) { - var ws = worksheetGroup.Key; + var ws = worksheetGroup.Key!; var usedCellsCandidates = GetUsedCellsCandidates(ws); @@ -82,14 +83,13 @@ private IEnumerable GetUsedCells() .OrderBy(cell => cell.Address.RowNumber) .ThenBy(cell => cell.Address.ColumnNumber); - var visitedCells = new HashSet(); + visitedCells.Clear(); foreach (var cell in cells) { - if (visitedCells.Contains(cell.Address)) continue; - - visitedCells.Add(cell.Address); - - yield return cell; + if (visitedCells.Add(cell.Address)) + { + yield return cell; + } } } } @@ -105,12 +105,11 @@ private IEnumerable GetUsedCellsInRange(XLRangeAddress rangeAddress, XLW var maxColumn = normalizedAddress.LastAddress.ColumnNumber; var cellRange = worksheet.Internals.CellsCollection - .GetCells(minRow, minColumn, maxRow, maxColumn, _predicate) - .Where(c => !c.IsEmpty(_options)); + .GetCells(minRow, minColumn, maxRow, maxColumn, _predicate); foreach (var cell in cellRange) { - if (_predicate(cell)) + if (!cell.IsEmpty(_options) && _predicate(cell)) yield return cell; } @@ -131,6 +130,11 @@ private IEnumerable GetUsedCellsCandidates(XLWorksheet worksheet) { var candidates = Enumerable.Empty(); + if (_options == XLCellsUsedOptions.AllContents) + { + return candidates; + } + if (_options.HasFlag(XLCellsUsedOptions.MergedRanges)) candidates = candidates.Union( worksheet.Internals.MergedRanges.SelectMany(r => GetAllCellsInRange(r.RangeAddress))); @@ -143,16 +147,21 @@ private IEnumerable GetUsedCellsCandidates(XLWorksheet worksheet) candidates = candidates.Union( worksheet.DataValidations.SelectMany(dv => dv.Ranges.SelectMany(r => GetAllCellsInRange(r.RangeAddress)))); + if (_options.HasFlag(XLCellsUsedOptions.Sparklines)) + candidates = candidates.Union( + worksheet.SparklineGroups.SelectMany(sg => sg).Select(sl => XLSheetPoint.FromAddress(sl.Location.Address))); + return candidates.Distinct(); } public IEnumerator GetEnumerator() { - var cells = (_usedCellsOnly) ? GetUsedCells() : GetAllCells(); - foreach (var cell in cells) - { - yield return cell; - } + return GetCells().GetEnumerator(); + } + + private IEnumerable GetCells() + { + return _usedCellsOnly ? GetUsedCells() : GetAllCells(); } #endregion IEnumerable Members @@ -161,8 +170,7 @@ public IEnumerator GetEnumerator() IEnumerator IEnumerable.GetEnumerator() { - foreach (XLCell cell in this) - yield return cell; + return GetCells().GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() @@ -170,22 +178,11 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - public Object Value + public XLCellValue Value { set { this.ForEach(c => c.Value = value); } } - public IXLCells SetDataType(XLDataType dataType) - { - this.ForEach(c => c.DataType = dataType); - return this; - } - - public XLDataType DataType - { - set { this.ForEach(c => c.DataType = value); } - } - public IXLCells Clear(XLClearOptions clearOptions = XLClearOptions.All) { this.ForEach(c => c.Clear(clearOptions)); @@ -216,16 +213,6 @@ public String FormulaR1C1 #region IXLStylized Members - public override IEnumerable Styles - { - get - { - yield return Style; - foreach (XLCell c in this) - yield return c.Style; - } - } - protected override IEnumerable Children { get diff --git a/ClosedXML/Excel/Cells/XLCellsCollection.cs b/ClosedXML/Excel/Cells/XLCellsCollection.cs index ef56b993a..acfc9bb4c 100644 --- a/ClosedXML/Excel/Cells/XLCellsCollection.cs +++ b/ClosedXML/Excel/Cells/XLCellsCollection.cs @@ -4,418 +4,461 @@ namespace ClosedXML.Excel { - internal class XLCellsCollection + internal class XLCellsCollection : IWorkbookListener { - internal Dictionary ColumnsUsed { get; } = new Dictionary(); - internal Dictionary> Deleted { get; } = new Dictionary>(); - internal Dictionary> RowsCollection { get; } = new Dictionary>(); + private readonly XLWorksheet _ws; + private readonly List _slices; - public Int32 MaxColumnUsed; - public Int32 MaxRowUsed; - public Dictionary RowsUsed = new Dictionary(); + public XLCellsCollection(XLWorksheet ws) + { + _ws = ws; + ValueSlice = new ValueSlice(ws.Workbook.SharedStringTable); + FormulaSlice = new FormulaSlice(ws); + _slices = new List { ValueSlice, FormulaSlice, StyleSlice, MiscSlice }; + } - public XLCellsCollection() + internal HashSet ColumnsUsedKeys { - Clear(); + get + { + var set = new HashSet(); + foreach (var slice in _slices) + set.UnionWith(slice.UsedColumns); + + return set; + } } - public Int32 Count { get; private set; } + internal bool IsEmpty => _slices.All(slice => slice.IsEmpty); - public void Add(XLSheetPoint sheetPoint, XLCell cell) + internal Int32 MaxColumnUsed { - Add(sheetPoint.Row, sheetPoint.Column, cell); + get + { + var max = int.MinValue; + foreach (var slice in _slices) + max = Math.Max(max, slice.MaxColumn); + + return Math.Max(1, max); + } } - public void Add(Int32 row, Int32 column, XLCell cell) + internal Int32 MaxRowUsed { - Count++; + get + { + var max = int.MinValue; + foreach (var slice in _slices) + max = Math.Max(max, slice.MaxRow); - IncrementUsage(RowsUsed, row); - IncrementUsage(ColumnsUsed, column); + return Math.Max(1, max); + } + } - if (!RowsCollection.TryGetValue(row, out Dictionary columnsCollection)) + internal HashSet RowsUsedKeys + { + get { - columnsCollection = new Dictionary(); - RowsCollection.Add(row, columnsCollection); - } - columnsCollection.Add(column, cell); - if (row > MaxRowUsed) MaxRowUsed = row; - if (column > MaxColumnUsed) MaxColumnUsed = column; + var set = new HashSet(); + foreach (var slice in _slices) + set.UnionWith(slice.UsedRows); - if (Deleted.TryGetValue(row, out HashSet delHash)) - delHash.Remove(column); + return set; + } } - private static void IncrementUsage(Dictionary dictionary, Int32 key) + internal ValueSlice ValueSlice { get; } + + internal FormulaSlice FormulaSlice { get; } + + internal Slice StyleSlice { get; } = new(); + + internal Slice MiscSlice { get; } = new(); + + internal XLWorksheet Worksheet => _ws; + + internal void Clear() { - if (dictionary.TryGetValue(key, out Int32 value)) - dictionary[key] = value + 1; - else - dictionary.Add(key, 1); + Clear(XLSheetRange.Full); } - /// - /// True if the number was lowered to zero so MaxColumnUsed or MaxRowUsed may require - /// recomputation. - private static bool DecrementUsage(Dictionary dictionary, Int32 key) + internal void Clear(XLSheetRange clearRange) { - if (!dictionary.TryGetValue(key, out Int32 count)) return false; - - if (count > 1) - { - dictionary[key] = count - 1; - return false; - } - else - { - dictionary.Remove(key); - return true; - } + foreach (var slice in _slices) + slice.Clear(clearRange); } - public void Clear() + internal void DeleteAreaAndShiftLeft(XLSheetRange rangeToDelete) { - Count = 0; - RowsUsed.Clear(); - ColumnsUsed.Clear(); + foreach (var slice in _slices) + slice.DeleteAreaAndShiftLeft(rangeToDelete); + } - RowsCollection.Clear(); - MaxRowUsed = 0; - MaxColumnUsed = 0; + internal void DeleteAreaAndShiftUp(XLSheetRange rangeToDelete) + { + foreach (var slice in _slices) + slice.DeleteAreaAndShiftUp(rangeToDelete); } - public void Remove(XLSheetPoint sheetPoint) + internal XLCell GetCell(XLSheetPoint address) { - Remove(sheetPoint.Row, sheetPoint.Column); + return new XLCell(_ws, address); } - public void Remove(Int32 row, Int32 column) + /// + /// Get all used cells in the worksheet. + /// + internal IEnumerable GetCells() { - Count--; - var rowRemoved = DecrementUsage(RowsUsed, row); - var columnRemoved = DecrementUsage(ColumnsUsed, column); + return GetCells(XLSheetRange.Full); + } - if (rowRemoved && row == MaxRowUsed) - { - MaxRowUsed = RowsUsed.Keys.Any() - ? RowsUsed.Keys.Max() - : 0; - } + /// + /// Get all used cells in the worksheet that satisfy the predicate. + /// + internal IEnumerable GetCells(Func predicate) + { + return GetCells(XLSheetRange.Full, predicate); + } - if (columnRemoved && column == MaxColumnUsed) - { - MaxColumnUsed = ColumnsUsed.Keys.Any() - ? ColumnsUsed.Keys.Max() - : 0; - } + /// + /// Get all used cells in the range that satisfy the predicate. + /// + internal IEnumerable GetCells(Int32 rowStart, Int32 columnStart, + Int32 rowEnd, Int32 columnEnd, + Func? predicate = null) + { + return GetCells(new XLSheetRange(rowStart, columnStart, rowEnd, columnEnd), predicate); + } - if (Deleted.TryGetValue(row, out HashSet delHash)) - { - if (!delHash.Contains(column)) - delHash.Add(column); - } - else - { - delHash = new HashSet(); - delHash.Add(column); - Deleted.Add(row, delHash); - } + /// + /// Get all used cells in the range that satisfy the predicate. + /// + internal IEnumerable GetCells(XLSheetRange range, Func? predicate = null) + { + var enumerator = new SlicesEnumerator(range, this); - if (RowsCollection.TryGetValue(row, out Dictionary columnsCollection)) + while (enumerator.MoveNext()) { - columnsCollection.Remove(column); - if (columnsCollection.Count == 0) - { - RowsCollection.Remove(row); - } + var cellAddress = enumerator.Current; + var cell = GetCell(cellAddress); + if (predicate == null || predicate(cell)) + yield return cell; } } - internal IEnumerable GetCells(Int32 rowStart, Int32 columnStart, - Int32 rowEnd, Int32 columnEnd, - Func predicate = null) + internal IEnumerable GetCellsInColumn(Int32 column) { - int finalRow = rowEnd > MaxRowUsed ? MaxRowUsed : rowEnd; - int finalColumn = columnEnd > MaxColumnUsed ? MaxColumnUsed : columnEnd; - for (int ro = rowStart; ro <= finalRow; ro++) - { - if (RowsCollection.TryGetValue(ro, out Dictionary columnsCollection)) - { - for (int co = columnStart; co <= finalColumn; co++) - { - if (columnsCollection.TryGetValue(co, out XLCell cell) - && (predicate == null || predicate(cell))) - yield return cell; - } - } - } + return GetCells(1, column, XLHelper.MaxRowNumber, column); } - public int FirstRowUsed(int rowStart, int columnStart, int rowEnd, int columnEnd, XLCellsUsedOptions options, - Func predicate = null) + internal IEnumerable GetCellsInRow(Int32 row) { - int finalRow = rowEnd > MaxRowUsed ? MaxRowUsed : rowEnd; - int finalColumn = columnEnd > MaxColumnUsed ? MaxColumnUsed : columnEnd; - for (int ro = rowStart; ro <= finalRow; ro++) - { - if (RowsCollection.TryGetValue(ro, out Dictionary columnsCollection)) - { - for (int co = columnStart; co <= finalColumn; co++) - { - if (columnsCollection.TryGetValue(co, out XLCell cell) - && !cell.IsEmpty(options) - && (predicate == null || predicate(cell))) + return GetCells(row, 1, row, XLHelper.MaxColumnNumber); + } - return ro; - } - } - } + /// + /// Get cell or null, if cell is not used. + /// + internal XLCell? GetUsedCell(XLSheetPoint address) + { + if (!IsUsed(address)) + return null; - return 0; + return GetCell(address); } - public int FirstColumnUsed(int rowStart, int columnStart, int rowEnd, int columnEnd, XLCellsUsedOptions options, - Func predicate = null) + internal int FirstColumnUsed(XLSheetRange searchRange, XLCellsUsedOptions options, Func? predicate = null) { - int finalRow = rowEnd > MaxRowUsed ? MaxRowUsed : rowEnd; - int finalColumn = columnEnd > MaxColumnUsed ? MaxColumnUsed : columnEnd; - int firstColumnUsed = finalColumn; - var found = false; - for (int ro = rowStart; ro <= finalRow; ro++) - { - if (RowsCollection.TryGetValue(ro, out Dictionary columnsCollection)) - { - for (int co = columnStart; co <= firstColumnUsed; co++) - { - if (columnsCollection.TryGetValue(co, out XLCell cell) - && !cell.IsEmpty(options) - && (predicate == null || predicate(cell)) - && co <= firstColumnUsed) - { - firstColumnUsed = co; - found = true; - break; - } - } - } - } - - return found ? firstColumnUsed : 0; + return FindUsedColumn(searchRange, options, predicate, false); } - public int LastRowUsed(int rowStart, int columnStart, int rowEnd, int columnEnd, XLCellsUsedOptions options, - Func predicate = null) + internal int FirstRowUsed(XLSheetRange searchRange, XLCellsUsedOptions options, Func? predicate = null) { - int finalRow = rowEnd > MaxRowUsed ? MaxRowUsed : rowEnd; - int finalColumn = columnEnd > MaxColumnUsed ? MaxColumnUsed : columnEnd; - for (int ro = finalRow; ro >= rowStart; ro--) - { - if (RowsCollection.TryGetValue(ro, out Dictionary columnsCollection)) - { - for (int co = finalColumn; co >= columnStart; co--) - { - if (columnsCollection.TryGetValue(co, out XLCell cell) - && !cell.IsEmpty(options) - && (predicate == null || predicate(cell))) + return FindUsedRow(searchRange, options, predicate, false); + } - return ro; - } - } - } - return 0; + internal void InsertAreaAndShiftDown(XLSheetRange insertedRange) + { + foreach (var slice in _slices) + slice.InsertAreaAndShiftDown(insertedRange); } - public int LastColumnUsed(int rowStart, int columnStart, int rowEnd, int columnEnd, XLCellsUsedOptions options, - Func predicate = null) + internal void InsertAreaAndShiftRight(XLSheetRange insertedRange) { - int maxCo = 0; - int finalRow = rowEnd > MaxRowUsed ? MaxRowUsed : rowEnd; - int finalColumn = columnEnd > MaxColumnUsed ? MaxColumnUsed : columnEnd; - for (int ro = finalRow; ro >= rowStart; ro--) - { - if (RowsCollection.TryGetValue(ro, out Dictionary columnsCollection)) - { - for (int co = finalColumn; co >= columnStart && co > maxCo; co--) - { - if (columnsCollection.TryGetValue(co, out XLCell cell) - && !cell.IsEmpty(options) - && (predicate == null || predicate(cell))) + foreach (var slice in _slices) + slice.InsertAreaAndShiftRight(insertedRange); + } - maxCo = co; - } - } - } - return maxCo; + internal int LastColumnUsed(XLSheetRange searchRange, XLCellsUsedOptions options, Func? predicate = null) + { + return FindUsedColumn(searchRange, options, predicate, true); } - public void RemoveAll(Int32 rowStart, Int32 columnStart, - Int32 rowEnd, Int32 columnEnd) + internal int LastRowUsed(XLSheetRange searchRange, XLCellsUsedOptions options, Func? predicate = null) { - int finalRow = rowEnd > MaxRowUsed ? MaxRowUsed : rowEnd; - int finalColumn = columnEnd > MaxColumnUsed ? MaxColumnUsed : columnEnd; - for (int ro = rowStart; ro <= finalRow; ro++) - { - if (RowsCollection.TryGetValue(ro, out Dictionary columnsCollection)) - { - for (int co = columnStart; co <= finalColumn; co++) - { - if (columnsCollection.ContainsKey(co)) - Remove(ro, co); - } - } - } + return FindUsedRow(searchRange, options, predicate, true); } - public IEnumerable GetSheetPoints(Int32 rowStart, Int32 columnStart, - Int32 rowEnd, Int32 columnEnd) + /// + /// Remap rows of a range. + /// + /// A sorted map of rows. The values must be resorted row numbers from . + /// Sheet that should have its rows rearranged. + internal void RemapRows(IList map, XLSheetRange sheetRange) { - int finalRow = rowEnd > MaxRowUsed ? MaxRowUsed : rowEnd; - int finalColumn = columnEnd > MaxColumnUsed ? MaxColumnUsed : columnEnd; - for (int ro = rowStart; ro <= finalRow; ro++) + RemapRanges(map, sheetRange.TopRow, SwapRows); + + void SwapRows(int prevRowNumber, int currentRowNumber) { - if (RowsCollection.TryGetValue(ro, out Dictionary columnsCollection)) - { - for (int co = columnStart; co <= finalColumn; co++) - { - if (columnsCollection.ContainsKey(co)) - yield return new XLSheetPoint(ro, co); - } - } + var prevRowRange = new XLSheetRange( + new XLSheetPoint(prevRowNumber, sheetRange.LeftColumn), + new XLSheetPoint(prevRowNumber, sheetRange.RightColumn)); + var currentRowRange = new XLSheetRange( + new XLSheetPoint(currentRowNumber, sheetRange.LeftColumn), + new XLSheetPoint(currentRowNumber, sheetRange.RightColumn)); + SwapRanges(prevRowRange, currentRowRange); } } - public XLCell GetCell(Int32 row, Int32 column) + /// + /// Remap columns of a range. + /// + /// A sorted map of columns. The values must be resorted columns numbers from . + /// Sheet that should have its columns rearranged. + internal void RemapColumns(IList map, XLSheetRange sheetRange) { - if (row > MaxRowUsed || column > MaxColumnUsed) - return null; + RemapRanges(map, sheetRange.LeftColumn, SwapColumns); - if (RowsCollection.TryGetValue(row, out Dictionary columnsCollection)) + void SwapColumns(int prevColNumber, int currentColNumber) { - return columnsCollection.TryGetValue(column, out XLCell cell) ? cell : null; + var prevRowRange = new XLSheetRange( + new XLSheetPoint(sheetRange.TopRow, prevColNumber), + new XLSheetPoint(sheetRange.BottomRow, prevColNumber)); + var currentRowRange = new XLSheetRange( + new XLSheetPoint(sheetRange.TopRow, currentColNumber), + new XLSheetPoint(sheetRange.BottomRow, currentColNumber)); + SwapRanges(prevRowRange, currentRowRange); } - return null; } - public XLCell GetCell(XLSheetPoint sp) + private static void RemapRanges(IList map, int indexOffset, Action swapData) { - return GetCell(sp.Row, sp.Column); + for (var i = 0; i < map.Count; ++i) + { + var axisNumber = i + indexOffset; + var dataAxisNumber = map[i]; + if (axisNumber == dataAxisNumber) + continue; + + // Current row doesn't contain data it should, so it is a part of a permutation + // loop. Go over each item in a loop and + // We need to replace + var prevNumber = axisNumber; + var currentNumber = dataAxisNumber; + var startLoopNumber = prevNumber; + do + { + // Current row number contains data that should be on the previous row number, + // so swap them. That will fix another link in a loop (the previous one), but + // will keep current inconsistent, but that will be fixed when loop completes. + swapData(prevNumber, currentNumber); + + // Because previous row number is already fixed and will no longer be touched + // during loop fix, mark it as a row that contains correct data. + map[prevNumber - indexOffset] = prevNumber; + + prevNumber = currentNumber; + currentNumber = map[currentNumber - indexOffset]; + } while (currentNumber != startLoopNumber); + + // Although we don't have to swap the last one (N count loop needs only N-1 swaps), + // we have to mark the last row mapping for the last link (the one before start). + map[prevNumber - indexOffset] = prevNumber; + } } - internal void SwapRanges(XLSheetRange sheetRange1, XLSheetRange sheetRange2, XLWorksheet worksheet) + private void SwapRanges(XLSheetRange sheetRange1, XLSheetRange sheetRange2) { - Int32 rowCount = sheetRange1.LastPoint.Row - sheetRange1.FirstPoint.Row + 1; - Int32 columnCount = sheetRange1.LastPoint.Column - sheetRange1.FirstPoint.Column + 1; - for (int row = 0; row < rowCount; row++) + var rowCount = sheetRange1.LastPoint.Row - sheetRange1.FirstPoint.Row + 1; + var columnCount = sheetRange1.LastPoint.Column - sheetRange1.FirstPoint.Column + 1; + for (var row = 0; row < rowCount; row++) { - for (int column = 0; column < columnCount; column++) + for (var column = 0; column < columnCount; column++) { var sp1 = new XLSheetPoint(sheetRange1.FirstPoint.Row + row, sheetRange1.FirstPoint.Column + column); var sp2 = new XLSheetPoint(sheetRange2.FirstPoint.Row + row, sheetRange2.FirstPoint.Column + column); - var cell1 = GetCell(sp1); - var cell2 = GetCell(sp2); - - if (cell1 == null) cell1 = worksheet.Cell(sp1.Row, sp1.Column); - if (cell2 == null) cell2 = worksheet.Cell(sp2.Row, sp2.Column); - - //if (cell1 != null) - //{ - cell1.Address = new XLAddress(cell1.Worksheet, sp2.Row, sp2.Column, false, false); - Remove(sp1); - //if (cell2 != null) - Add(sp1, cell2); - //} - - //if (cell2 == null) continue; - - cell2.Address = new XLAddress(cell2.Worksheet, sp1.Row, sp1.Column, false, false); - Remove(sp2); - //if (cell1 != null) - Add(sp2, cell1); + + SwapCellsContent(sp1, sp2); } } } - internal IEnumerable GetCells() - { - return GetCells(1, 1, MaxRowUsed, MaxColumnUsed); - } - - internal IEnumerable GetCells(Func predicate) + private int FindUsedColumn(XLSheetRange range, XLCellsUsedOptions options, Func? predicate, bool descending) { - for (int ro = 1; ro <= MaxRowUsed; ro++) + var usedColumns = Enumerable.Empty(); + foreach (var slice in _slices) + usedColumns = usedColumns.Concat(slice.UsedColumns); + + usedColumns = usedColumns + .Where(c => c >= range.FirstPoint.Column && c <= range.LastPoint.Column) + .Distinct(); + usedColumns = descending + ? usedColumns.OrderByDescending(x => x) + : usedColumns.OrderBy(x => x); + + foreach (var columnNumber in usedColumns) { - if (RowsCollection.TryGetValue(ro, out Dictionary columnsCollection)) + var enumerator = new SlicesEnumerator(new XLSheetRange(range.FirstPoint.Row, columnNumber, range.LastPoint.Row, columnNumber), this); + while (enumerator.MoveNext()) { - for (int co = 1; co <= MaxColumnUsed; co++) + var cell = new XLCell(_ws, enumerator.Current); + if (!cell.IsEmpty(options) && + (predicate == null || predicate(cell))) { - if (columnsCollection.TryGetValue(co, out XLCell cell) - && (predicate == null || predicate(cell))) - yield return cell; + return enumerator.Current.Column; } } } - } - public Boolean Contains(Int32 row, Int32 column) - { - return RowsCollection.TryGetValue(row, out Dictionary columnsCollection) - && columnsCollection.ContainsKey(column); + return 0; } - public Int32 MinRowInColumn(Int32 column) + private int FindUsedRow(XLSheetRange searchRange, XLCellsUsedOptions options, Func? predicate, bool reverse) { - for (int row = 1; row <= MaxRowUsed; row++) - { - if (RowsCollection.TryGetValue(row, out Dictionary columnsCollection) - && columnsCollection.ContainsKey(column)) + var enumerator = new SlicesEnumerator(searchRange, this, reverse); - return row; + while (enumerator.MoveNext()) + { + var cellAddress = enumerator.Current; + var cell = GetCell(cellAddress); + if (!cell.IsEmpty(options) + && (predicate == null || predicate(cell))) + return cellAddress.Row; } return 0; } - public Int32 MaxRowInColumn(Int32 column) + private bool IsUsed(XLSheetPoint address) { - for (int row = MaxRowUsed; row >= 1; row--) + // This is different from XLCellUsedOptions, which uses a business logic (e.g. empty string is considered not-used). + // Here, we ask whether any slice contains a used elements which might differ from cell used logic. + foreach (var slice in _slices) { - if (RowsCollection.TryGetValue(row, out Dictionary columnsCollection) - && columnsCollection.ContainsKey(column)) - - return row; + if (slice.IsUsed(address)) + return true; } - return 0; + return false; } - public Int32 MinColumnInRow(Int32 row) + internal void SwapCellsContent(XLSheetPoint sp1, XLSheetPoint sp2) { - if (RowsCollection.TryGetValue(row, out Dictionary columnsCollection) - && columnsCollection.Any()) - - return columnsCollection.Keys.Min(); + ValueSlice.Swap(sp1, sp2); + FormulaSlice.Swap(sp1, sp2); + StyleSlice.Swap(sp1, sp2); + MiscSlice.Swap(sp1, sp2); + } - return 0; + /// + /// Gets used points in the range. + /// + internal SlicesEnumerator ForValuesAndFormulas(XLSheetRange range) + { + var valueEnumerator = ValueSlice.GetEnumerator(range); + var formulaEnumerator = FormulaSlice.GetEnumerator(range); + return new SlicesEnumerator(false, valueEnumerator, formulaEnumerator); } - public Int32 MaxColumnInRow(Int32 row) + /// + /// Enumerator that combines several other slice enumerators and enumerates + /// in any of them. + /// + internal struct SlicesEnumerator { - if (RowsCollection.TryGetValue(row, out Dictionary columnsCollection) - && columnsCollection.Any()) + private readonly List> _enumerators; + private readonly bool _reverse; + + public SlicesEnumerator(XLSheetRange range, XLCellsCollection cellsCollection, bool reverse = false) + : this( + reverse, + cellsCollection.ValueSlice.GetEnumerator(range, reverse), + cellsCollection.FormulaSlice.GetEnumerator(range, reverse), + cellsCollection.StyleSlice.GetEnumerator(range, reverse), + cellsCollection.MiscSlice.GetEnumerator(range, reverse)) + { + } - return columnsCollection.Keys.Max(); + public SlicesEnumerator(bool reverse, params IEnumerator[] enumerators) + { + Current = new XLSheetPoint(1, 1); + _reverse = reverse; + _enumerators = new(); + foreach (var enumerator in enumerators) + { + if (enumerator.MoveNext()) + _enumerators.Add(enumerator); + } + } - return 0; - } + public XLSheetPoint Current { get; private set; } - public IEnumerable GetCellsInColumn(Int32 column) - { - return GetCells(1, column, MaxRowUsed, column); + public bool MoveNext() + { + XLSheetPoint? current = null; + for (var i = 0; i < _enumerators.Count; ++i) + { + var enumerator = _enumerators[i]; + if (current is null || ( + _reverse + ? enumerator.Current.CompareTo(current.Value) > 0 + : enumerator.Current.CompareTo(current.Value) < 0 + )) + current = enumerator.Current; + } + + if (current == null) + return false; + + Current = current.Value; + + for (var i = _enumerators.Count - 1; i >= 0; --i) + { + var enumerator = _enumerators[i]; + if (enumerator.Current == current) + { + var isDone = !enumerator.MoveNext(); + if (isDone) + { + _enumerators.RemoveAt(i); + } + } + } + + return true; + } } - public IEnumerable GetCellsInRow(Int32 row) + void IWorkbookListener.OnSheetRenamed(string oldSheetName, string newSheetName) { - return GetCells(row, 1, row, MaxColumnUsed); + using var enumerator = FormulaSlice.GetForwardEnumerator(XLSheetRange.Full); + while (enumerator.MoveNext()) + { + ref readonly XLCellFormula cellFormula = ref enumerator.Current; + var currentPoint = enumerator.Point; + if (cellFormula.Type != FormulaType.Normal) + { + // Array or data formula. Only change name once, on master cell. + var isMasterCell = cellFormula.Range.FirstPoint == currentPoint; + if (!isMasterCell) + { + continue; + } + } + + cellFormula.RenameSheet(currentPoint, oldSheetName, newSheetName); + } } } } diff --git a/ClosedXML/Excel/Cells/XLMiscSliceContent.cs b/ClosedXML/Excel/Cells/XLMiscSliceContent.cs new file mode 100644 index 000000000..d0c9167c5 --- /dev/null +++ b/ClosedXML/Excel/Cells/XLMiscSliceContent.cs @@ -0,0 +1,13 @@ +namespace ClosedXML.Excel +{ + internal struct XLMiscSliceContent + { + internal XLComment? Comment { get; set; } + + internal uint? CellMetaIndex { get; set; } + + internal uint? ValueMetaIndex { get; set; } + + internal bool HasPhonetic { get; set; } + } +} diff --git a/ClosedXML/Excel/Charts/IXLChart.cs b/ClosedXML/Excel/Charts/IXLChart.cs index 7cfc8139f..f0de4cbee 100644 --- a/ClosedXML/Excel/Charts/IXLChart.cs +++ b/ClosedXML/Excel/Charts/IXLChart.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Charts/IXLCharts.cs b/ClosedXML/Excel/Charts/IXLCharts.cs index 70c1478da..d5b96974e 100644 --- a/ClosedXML/Excel/Charts/IXLCharts.cs +++ b/ClosedXML/Excel/Charts/IXLCharts.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Collections.Generic; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Charts/XLChart.cs b/ClosedXML/Excel/Charts/XLChart.cs index d928e6ccc..591450ad2 100644 --- a/ClosedXML/Excel/Charts/XLChart.cs +++ b/ClosedXML/Excel/Charts/XLChart.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/ClosedXML/Excel/Charts/XLCharts.cs b/ClosedXML/Excel/Charts/XLCharts.cs index 112a6fc9d..489684136 100644 --- a/ClosedXML/Excel/Charts/XLCharts.cs +++ b/ClosedXML/Excel/Charts/XLCharts.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Collections.Generic; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Columns/IXLColumn.cs b/ClosedXML/Excel/Columns/IXLColumn.cs index 874874148..8a2656fb5 100644 --- a/ClosedXML/Excel/Columns/IXLColumn.cs +++ b/ClosedXML/Excel/Columns/IXLColumn.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -5,11 +7,12 @@ namespace ClosedXML.Excel public interface IXLColumn : IXLRangeBase { /// - /// Gets or sets the width of this column. + /// Gets or sets the width of this column in number of characters (NoC). /// - /// - /// The width of the column as multiple of maximum digit width (MDW). MDW is a maximum width of a 0-9 digit character. - /// + /// + /// NoC are a non-linear units displayed as a column width in Excel, next to pixels. NoC combined with default font + /// of the workbook can express width of the column in pixels and other units. + /// Double Width { get; set; } /// @@ -84,6 +87,13 @@ public interface IXLColumn : IXLRangeBase IXLColumn AdjustToContents(Int32 startRow, Double minWidth, Double maxWidth); + /// + /// Adjust width of the column according to the content of the cells. + /// + /// Number of a first row whose content is considered. + /// Number of a last row whose content is considered. + /// Minimum width of adjusted column, in NoC. + /// Maximum width of adjusted column, in NoC. IXLColumn AdjustToContents(Int32 startRow, Int32 endRow, Double minWidth, Double maxWidth); /// @@ -174,8 +184,6 @@ public interface IXLColumn : IXLRangeBase /// IXLColumn AddVerticalPageBreak(); - IXLColumn SetDataType(XLDataType dataType); - IXLColumn ColumnLeft(); IXLColumn ColumnLeft(Int32 step); @@ -190,9 +198,6 @@ public interface IXLColumn : IXLRangeBase /// Specify what you want to clear. new IXLColumn Clear(XLClearOptions clearOptions = XLClearOptions.All); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeColumn ColumnUsed(Boolean includeFormats); - IXLRangeColumn ColumnUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents); } } diff --git a/ClosedXML/Excel/Columns/IXLColumns.cs b/ClosedXML/Excel/Columns/IXLColumns.cs index 56a8edf1c..c24298b48 100644 --- a/ClosedXML/Excel/Columns/IXLColumns.cs +++ b/ClosedXML/Excel/Columns/IXLColumns.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; @@ -116,8 +118,6 @@ public interface IXLColumns : IEnumerable /// IXLColumns AddVerticalPageBreaks(); - IXLColumns SetDataType(XLDataType dataType); - /// /// Clears the contents of these columns. /// diff --git a/ClosedXML/Excel/Columns/XLColumn.cs b/ClosedXML/Excel/Columns/XLColumn.cs index 0af3da906..055ec8880 100644 --- a/ClosedXML/Excel/Columns/XLColumn.cs +++ b/ClosedXML/Excel/Columns/XLColumn.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using ClosedXML.Extensions; +using ClosedXML.Graphics; namespace ClosedXML.Excel { @@ -33,19 +33,6 @@ public override XLRangeType RangeType get { return XLRangeType.Column; } } - public override IEnumerable Styles - { - get - { - yield return Style; - - int column = ColumnNumber(); - - foreach (XLCell cell in Worksheet.Internals.CellsCollection.GetCellsInColumn(column)) - yield return cell.Style; - } - } - protected override IEnumerable Children { get @@ -62,6 +49,10 @@ protected override IEnumerable Children public Double Width { get; set; } + IXLCells IXLColumn.Cells(String cellsInColumn) => Cells(cellsInColumn); + + IXLCells IXLColumn.Cells(Int32 firstRow, Int32 lastRow) => Cells(firstRow, lastRow); + public void Delete() { int columnNumber = ColumnNumber(); @@ -80,7 +71,7 @@ public IXLCell Cell(Int32 rowNumber) return Cell(rowNumber, 1); } - public override IXLCells Cells(String cellsInColumn) + public override XLCells Cells(String cellsInColumn) { var retVal = new XLCells(false, XLCellsUsedOptions.All); var rangePairs = cellsInColumn.Split(','); @@ -94,7 +85,7 @@ public override IXLCells Cells() return Cells(true, XLCellsUsedOptions.All); } - public override IXLCells Cells(Boolean usedCellsOnly) + public override XLCells Cells(Boolean usedCellsOnly) { if (usedCellsOnly) return Cells(true, XLCellsUsedOptions.AllContents); @@ -102,7 +93,7 @@ public override IXLCells Cells(Boolean usedCellsOnly) return Cells(FirstCellUsed().Address.RowNumber, LastCellUsed().Address.RowNumber); } - public IXLCells Cells(Int32 firstRow, Int32 lastRow) + public XLCells Cells(Int32 firstRow, Int32 lastRow) { return Cells(firstRow + ":" + lastRow); } @@ -169,185 +160,142 @@ public IXLColumn AdjustToContents(Int32 startRow, Double minWidth, Double maxWid return AdjustToContents(startRow, XLHelper.MaxRowNumber, minWidth, maxWidth); } - public IXLColumn AdjustToContents(Int32 startRow, Int32 endRow, Double minWidth, Double maxWidth) + public IXLColumn AdjustToContents(Int32 startRow, Int32 endRow, Double minWidthNoC, Double maxWidthNoC) { - Double colMaxWidth = minWidth; + var engine = Worksheet.Workbook.GraphicEngine; + var dpi = new Dpi(Worksheet.Workbook.DpiX, Worksheet.Workbook.DpiY); + var columnWidthPx = CalculateMinColumnWidth(startRow, endRow, engine, dpi); + + // Maximum digit width, rounded to pixels, so Calibri at 11 pts returns 7 pixels MDW (the correct value) + var mdw = (int)Math.Round(engine.GetMaxDigitWidth(Worksheet.Workbook.Style.Font, dpi.X)); + + var minWidthInPx = Math.Ceiling(XLHelper.NoCToPixels(minWidthNoC, mdw)); + if (columnWidthPx < minWidthInPx) + columnWidthPx = (int)minWidthInPx; + + var maxWidthInPx = Math.Ceiling(XLHelper.NoCToPixels(maxWidthNoC, mdw)); + if (columnWidthPx > maxWidthInPx) + columnWidthPx = (int)maxWidthInPx; + + var colMaxWidth = XLHelper.PixelToNoC(columnWidthPx, mdw); + + // If there is nothing in the column, use worksheet column width. + if (colMaxWidth <= 0) + colMaxWidth = Worksheet.ColumnWidth; - List autoFilterRows = new List(); - if (this.Worksheet.AutoFilter != null && this.Worksheet.AutoFilter.Range != null) + Width = colMaxWidth; + + return this; + } + + /// + /// Calculate column width in pixels according to the content of cells. + /// + /// First row number whose content is used for determination. + /// Last row number whose content is used for determination. + /// Engine to determine size of glyphs. + /// DPI of the worksheet. + private int CalculateMinColumnWidth(int startRow, int endRow, IXLGraphicEngine engine, Dpi dpi) + { + var autoFilterRows = new List(); + if (this.Worksheet.AutoFilter != null && Worksheet.AutoFilter.Range != null) autoFilterRows.Add(this.Worksheet.AutoFilter.Range.FirstRow().RowNumber()); - autoFilterRows.AddRange(Worksheet.Tables.Where(t => + autoFilterRows.AddRange(Worksheet.Tables.Where(t => t.AutoFilter != null && t.AutoFilter.Range != null && !autoFilterRows.Contains(t.AutoFilter.Range.FirstRow().RowNumber())) .Select(t => t.AutoFilter.Range.FirstRow().RowNumber())); - XLStyle cellStyle = null; - foreach (var c in Column(startRow, endRow).CellsUsed().Cast()) + // Reusable buffer + var glyphs = new List(); + XLStyle? cellStyle = null; + var columnWidthPx = 0; + foreach (var cell in Column(startRow, endRow).CellsUsed()) { - if (c.IsMerged()) continue; - if (cellStyle == null || cellStyle.Value != c.StyleValue) - cellStyle = c.Style as XLStyle; + // Clear maintains capacity -> reduce need for GC + glyphs.Clear(); - Double thisWidthMax = 0; - Int32 textRotation = cellStyle.Alignment.TextRotation; - if (c.HasRichText || textRotation != 0 || c.InnerText.Contains(Environment.NewLine)) - { - var kpList = new List>(); + if (cell.IsMerged()) + continue; - #region if (c.HasRichText) + // Reuse styles if possible to reduce memory consumption + if (cellStyle is null || cellStyle.Value != cell.StyleValue) + cellStyle = (XLStyle)cell.Style; - if (c.HasRichText) - { - foreach (IXLRichString rt in c.GetRichText()) - { - String formattedString = rt.Text; - var arr = formattedString.Split(new[] { Environment.NewLine }, StringSplitOptions.None); - Int32 arrCount = arr.Count(); - for (Int32 i = 0; i < arrCount; i++) - { - String s = arr[i]; - if (i < arrCount - 1) - s += Environment.NewLine; - kpList.Add(new KeyValuePair(rt, s)); - } - } - } - else - { - String formattedString = c.GetFormattedString(); - var arr = formattedString.Split(new[] { Environment.NewLine }, StringSplitOptions.None); - Int32 arrCount = arr.Count(); - for (Int32 i = 0; i < arrCount; i++) - { - String s = arr[i]; - if (i < arrCount - 1) - s += Environment.NewLine; - kpList.Add(new KeyValuePair(cellStyle.Font, s)); - } - } + cell.GetGlyphBoxes(engine, dpi, glyphs); + var textWidthPx = (int)Math.Ceiling(GetContentWidth(cellStyle.Alignment.TextRotation, glyphs)); - #endregion if (c.HasRichText) + var scaledMdw = engine.GetMaxDigitWidth(cellStyle.Font, dpi.X); + scaledMdw = Math.Round(scaledMdw, MidpointRounding.AwayFromZero); - #region foreach (var kp in kpList) + // Not sure about rounding, but larger is probably better, so use ceiling. + // Due to mismatched rendering, add 3% instead of 1.75%, to have additional space. + var oneSidePadding = (int)Math.Ceiling(textWidthPx * 0.03 + scaledMdw / 4); - Double runningWidth = 0; - Boolean rotated = false; - Double maxLineWidth = 0; - Int32 lineCount = 1; - foreach (KeyValuePair kp in kpList) - { - var f = kp.Key; - String formattedString = kp.Value; - - Int32 newLinePosition = formattedString.IndexOf(Environment.NewLine); - if (textRotation == 0) - { - #region if (newLinePosition >= 0) - - if (newLinePosition >= 0) - { - if (newLinePosition > 0) - runningWidth += GetWidthInCharacters(formattedString.Substring(0, newLinePosition), f); - - if (runningWidth > thisWidthMax) - thisWidthMax = runningWidth; - - runningWidth = newLinePosition < formattedString.Length - 2 - ? GetWidthInCharacters(formattedString.Substring(newLinePosition + 2), f) - : 0; - } - else - runningWidth += GetWidthInCharacters(formattedString, f); - - #endregion if (newLinePosition >= 0) - } - else - { - #region if (textRotation == 255) - - if (textRotation == 255) - { - if (runningWidth <= 0) - runningWidth = GetWidthInCharacters("X", f); - - if (newLinePosition >= 0) - runningWidth += GetWidthInCharacters("X", f); - } - else - { - rotated = true; - Double vWidth = GetWidthInCharacters("X", f); - if (vWidth > maxLineWidth) - maxLineWidth = vWidth; - - if (newLinePosition >= 0) - { - lineCount++; - - if (newLinePosition > 0) - runningWidth += GetWidthInCharacters(formattedString.Substring(0, newLinePosition), f); - - if (runningWidth > thisWidthMax) - thisWidthMax = runningWidth; - - runningWidth = newLinePosition < formattedString.Length - 2 - ? GetWidthInCharacters(formattedString.Substring(newLinePosition + 2), f) - : 0; - } - else - runningWidth += GetWidthInCharacters(formattedString, f); - } - - #endregion if (textRotation == 255) - } - } + // Cell width if calculated as content width + padding on each side of a content. + // The one side padding is roughly 1.75% of content + MDW/4. + // The additional pixel is there for lines between cells. + var cellWidthPx = textWidthPx + 2 * oneSidePadding + 1; - #endregion foreach (var kp in kpList) + if (autoFilterRows.Contains(cell.Address.RowNumber)) + { + // Autofilter arrow is 16px at 96dpi, scaling through DPI, e.g. 20px at 120dpi + cellWidthPx += (int)Math.Round(16d * dpi.X / 96d, MidpointRounding.AwayFromZero); + } - if (runningWidth > thisWidthMax) - thisWidthMax = runningWidth; + columnWidthPx = Math.Max(cellWidthPx, columnWidthPx); + } - #region if (rotated) + return columnWidthPx; + } - if (rotated) + private static double GetContentWidth(int textRotationDeg, List glyphs) + { + if (textRotationDeg == 0) + { + var maxTextWidth = 0d; + var lineTextWidth = 0d; + foreach (var glyph in glyphs) + { + if (!glyph.IsLineBreak) { - Int32 rotation; - if (textRotation == 90 || textRotation == 180 || textRotation == 255) - rotation = 90; - else - rotation = textRotation % 90; - - Double r = DegreeToRadian(rotation); - - thisWidthMax = (thisWidthMax * Math.Cos(r)) + (maxLineWidth * lineCount); + lineTextWidth += glyph.AdvanceWidth; + maxTextWidth = Math.Max(lineTextWidth, maxTextWidth); } - - #endregion if (rotated) + else + lineTextWidth = 0; } - else - thisWidthMax = GetWidthInCharacters(c.GetFormattedString(), cellStyle.Font); - if (autoFilterRows.Contains(c.Address.RowNumber)) - thisWidthMax += 2.7148; // Allow room for arrow icon in autofilter + return maxTextWidth; + } + if (textRotationDeg == 255) + { + // Glyphs are arranged vertically, top to bottom. + var maxGlyphWidth = 0d; + foreach (var grapheme in glyphs) + maxGlyphWidth = Math.Max(grapheme.AdvanceWidth, maxGlyphWidth); + + return maxGlyphWidth; + } + else + { + // Glyphs are rotated + if (textRotationDeg > 90) + textRotationDeg = 90 - textRotationDeg; - if (thisWidthMax >= maxWidth) + var totalWidth = 0d; + var maxHeight = 0d; + foreach (var glyph in glyphs) { - colMaxWidth = maxWidth; - break; + totalWidth += glyph.AdvanceWidth; + maxHeight = Math.Max(maxHeight, glyph.LineHeight); } - if (thisWidthMax > colMaxWidth) - colMaxWidth = thisWidthMax + 1; + var projectedHeight = maxHeight * Math.Cos(XLHelper.DegToRad(90 - textRotationDeg)); + var projectedWidth = totalWidth * Math.Cos(XLHelper.DegToRad(textRotationDeg)); + return projectedWidth + projectedHeight; } - - if (colMaxWidth <= 0) - colMaxWidth = Worksheet.ColumnWidth; - - Width = colMaxWidth; - - return this; } public IXLColumn Hide() @@ -445,6 +393,8 @@ public IXLColumn Sort(XLSortOrder sortOrder = XLSortOrder.Ascending, Boolean mat return this; } + IXLRangeColumn IXLColumn.Column(Int32 start, Int32 end) => Column(start, end); + IXLRangeColumn IXLColumn.CopyTo(IXLCell target) { var copy = AsRange().CopyTo(target); @@ -470,7 +420,7 @@ public IXLColumn CopyTo(IXLColumn column) return newColumn; } - public IXLRangeColumn Column(Int32 start, Int32 end) + public XLRangeColumn Column(Int32 start, Int32 end) { return Range(start, 1, end, 1).Column(1); } @@ -498,20 +448,6 @@ public IXLColumn AddVerticalPageBreak() return this; } - public IXLColumn SetDataType(XLDataType dataType) - { - DataType = dataType; - return this; - } - - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLRangeColumn ColumnUsed(Boolean includeFormats) - { - return ColumnUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - public IXLRangeColumn ColumnUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents) { return Column((this as IXLRangeBase).FirstCellUsed(options), @@ -575,24 +511,6 @@ public IXLRangeColumn Range(int firstRow, int lastRow) return Range(firstRow, 1, lastRow, 1).Column(1); } - private static double DegreeToRadian(double angle) - { - return Math.PI * angle / 180.0; - } - - private double GetWidthInCharacters(string text, IXLFontBase font) - { - if (string.IsNullOrWhiteSpace(text)) - return 0; - var wb = Worksheet.Workbook; - var engine = wb.GraphicEngine; - var widthInPx = engine.GetTextWidth(text, font, wb.DpiX); - - var width = (widthInPx / 7d * 256 - 128 / 7) / 256; - width = Math.Round(width + 0.2, 2); - return width; - } - private XLColumn ColumnShift(Int32 columnsToShift) { return Worksheet.Column(ColumnNumber() + columnsToShift); diff --git a/ClosedXML/Excel/Columns/XLColumnCollection.cs b/ClosedXML/Excel/Columns/XLColumnCollection.cs index 4c33ee9ee..defdfc6b4 100644 --- a/ClosedXML/Excel/Columns/XLColumnCollection.cs +++ b/ClosedXML/Excel/Columns/XLColumnCollection.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -6,6 +8,8 @@ namespace ClosedXML.Excel { internal class XLColumnsCollection : IDictionary { + private readonly Dictionary _dictionary = new(); + public void ShiftColumnsRight(Int32 startingColumn, Int32 columnsToShift) { foreach (var co in _dictionary.Keys.Where(k => k >= startingColumn).OrderByDescending(k => k)) @@ -21,48 +25,31 @@ public void ShiftColumnsRight(Int32 startingColumn, Int32 columnsToShift) } } - private readonly Dictionary _dictionary = new Dictionary(); - public void Add(int key, XLColumn value) { _dictionary.Add(key, value); } - public bool ContainsKey(int key) - { - return _dictionary.ContainsKey(key); - } + public bool ContainsKey(int key) => _dictionary.ContainsKey(key); - public ICollection Keys - { - get { return _dictionary.Keys; } - } + public ICollection Keys => _dictionary.Keys; public bool Remove(int key) { return _dictionary.Remove(key); } - + public bool TryGetValue(int key, out XLColumn value) { return _dictionary.TryGetValue(key, out value); } - public ICollection Values - { - get { return _dictionary.Values; } - } + public ICollection Values => _dictionary.Values; public XLColumn this[int key] { - get - { - return _dictionary[key]; - } - set - { - _dictionary[key] = value; - } + get => _dictionary[key]; + set => _dictionary[key] = value; } public void Add(KeyValuePair item) @@ -85,30 +72,18 @@ public void CopyTo(KeyValuePair[] array, int arrayIndex) throw new NotImplementedException(); } - public int Count - { - get { return _dictionary.Count; } - } + public int Count => _dictionary.Count; - public bool IsReadOnly - { - get { return false; } - } + public bool IsReadOnly => false; public bool Remove(KeyValuePair item) { return _dictionary.Remove(item.Key); } - public IEnumerator> GetEnumerator() - { - return _dictionary.GetEnumerator(); - } + public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() - { - return _dictionary.GetEnumerator(); - } + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => _dictionary.GetEnumerator(); public void RemoveAll(Func predicate) { diff --git a/ClosedXML/Excel/Columns/XLColumns.cs b/ClosedXML/Excel/Columns/XLColumns.cs index 8eda992cc..16b154fd3 100644 --- a/ClosedXML/Excel/Columns/XLColumns.cs +++ b/ClosedXML/Excel/Columns/XLColumns.cs @@ -9,10 +9,10 @@ namespace ClosedXML.Excel internal class XLColumns : XLStylizedBase, IXLColumns, IXLStylized { private readonly List _columnsCollection = new List(); - private readonly XLWorksheet _worksheet; + private readonly XLWorksheet? _worksheet; private bool IsMaterialized => _lazyEnumerable == null; - private IEnumerable _lazyEnumerable; + private IEnumerable? _lazyEnumerable; private IEnumerable Columns => _lazyEnumerable ?? _columnsCollection.AsEnumerable(); /// @@ -22,7 +22,7 @@ internal class XLColumns : XLStylizedBase, IXLColumns, IXLStylized /// all columns on a worksheet so changing its width will affect all columns. /// Default style to use when initializing child entries. /// A predefined enumerator of to support lazy initialization. - public XLColumns(XLWorksheet worksheet, XLStyleValue defaultStyle = null, IEnumerable lazyEnumerable = null) + public XLColumns(XLWorksheet? worksheet, XLStyleValue? defaultStyle = null, IEnumerable? lazyEnumerable = null) : base(defaultStyle) { _worksheet = worksheet; @@ -193,7 +193,7 @@ public IXLCells CellsUsed(Boolean includeFormats) } public IXLCells CellsUsed(XLCellsUsedOptions options) - { + { var cells = new XLCells(true, options); foreach (XLColumn container in Columns) cells.Add(container.RangeAddress); @@ -210,33 +210,10 @@ public IXLColumns AddVerticalPageBreaks() return this; } - public IXLColumns SetDataType(XLDataType dataType) - { - Columns.ForEach(c => c.DataType = dataType); - return this; - } - #endregion IXLColumns Members #region IXLStylized Members - public override IEnumerable Styles - { - get - { - yield return Style; - if (_worksheet != null) - yield return _worksheet.Style; - else - { - foreach (IXLStyle s in Columns.SelectMany(col => col.Styles)) - { - yield return s; - } - } - } - } - protected override IEnumerable Children { get diff --git a/ClosedXML/Excel/Comments/IXLComment.cs b/ClosedXML/Excel/Comments/IXLComment.cs index 9b3b30e9a..cd34f6672 100644 --- a/ClosedXML/Excel/Comments/IXLComment.cs +++ b/ClosedXML/Excel/Comments/IXLComment.cs @@ -1,5 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Comments/XLComment.cs b/ClosedXML/Excel/Comments/XLComment.cs index 9144c40a6..1be15a52c 100644 --- a/ClosedXML/Excel/Comments/XLComment.cs +++ b/ClosedXML/Excel/Comments/XLComment.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMax.cs b/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMax.cs index 40217cd8e..d4258f98e 100644 --- a/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMax.cs +++ b/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMax.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMid.cs b/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMid.cs index 95e514538..de447594e 100644 --- a/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMid.cs +++ b/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMid.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMin.cs b/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMin.cs index 7abc9ea3b..33a89ba4a 100644 --- a/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMin.cs +++ b/ClosedXML/Excel/ConditionalFormats/IXLCFColorScaleMin.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/ConditionalFormats/IXLCFDataBarMax.cs b/ClosedXML/Excel/ConditionalFormats/IXLCFDataBarMax.cs index 05df66a4c..8198f142b 100644 --- a/ClosedXML/Excel/ConditionalFormats/IXLCFDataBarMax.cs +++ b/ClosedXML/Excel/ConditionalFormats/IXLCFDataBarMax.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/ConditionalFormats/IXLCFDataBarMin.cs b/ClosedXML/Excel/ConditionalFormats/IXLCFDataBarMin.cs index cd1236fc7..d9f94931e 100644 --- a/ClosedXML/Excel/ConditionalFormats/IXLCFDataBarMin.cs +++ b/ClosedXML/Excel/ConditionalFormats/IXLCFDataBarMin.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/ConditionalFormats/IXLCFIconSet.cs b/ClosedXML/Excel/ConditionalFormats/IXLCFIconSet.cs index e9f80737c..afdd2a553 100644 --- a/ClosedXML/Excel/ConditionalFormats/IXLCFIconSet.cs +++ b/ClosedXML/Excel/ConditionalFormats/IXLCFIconSet.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/ConditionalFormats/IXLConditionalFormat.cs b/ClosedXML/Excel/ConditionalFormats/IXLConditionalFormat.cs index ba18bf4fe..4bf38cb2d 100644 --- a/ClosedXML/Excel/ConditionalFormats/IXLConditionalFormat.cs +++ b/ClosedXML/Excel/ConditionalFormats/IXLConditionalFormat.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/ConditionalFormats/IXLConditionalFormats.cs b/ClosedXML/Excel/ConditionalFormats/IXLConditionalFormats.cs index cae5f8c40..44ef858be 100644 --- a/ClosedXML/Excel/ConditionalFormats/IXLConditionalFormats.cs +++ b/ClosedXML/Excel/ConditionalFormats/IXLConditionalFormats.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/IXLCFConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/IXLCFConverter.cs index bfa177858..4eb231419 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/IXLCFConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/IXLCFConverter.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using DocumentFormat.OpenXml.Spreadsheet; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/ConditionalFormats/Save/IXLCFConverterExtension.cs b/ClosedXML/Excel/ConditionalFormats/Save/IXLCFConverterExtension.cs index 82b11a0f3..63808509d 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/IXLCFConverterExtension.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/IXLCFConverterExtension.cs @@ -1,7 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +#nullable disable + using DocumentFormat.OpenXml.Office2010.Excel; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFBaseConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFBaseConverter.cs index 546140672..5224628c9 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFBaseConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFBaseConverter.cs @@ -1,4 +1,6 @@ -using ClosedXML.Utils; +#nullable disable + +using ClosedXML.Utils; using DocumentFormat.OpenXml.Spreadsheet; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFCellIsConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFCellIsConverter.cs index 256eaef10..75b2e3337 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFCellIsConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFCellIsConverter.cs @@ -1,6 +1,5 @@ using DocumentFormat.OpenXml.Spreadsheet; using System; -using System.Globalization; namespace ClosedXML.Excel { @@ -11,7 +10,7 @@ public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, String val = GetQuoted(cf.Values[1]); var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; @@ -35,7 +34,7 @@ private String GetQuoted(XLFormula formula) if (formula.IsFormula || value.StartsWith("\"") && value.EndsWith("\"") || - Double.TryParse(value, XLHelper.NumberStyle, XLHelper.ParseCulture, out double num)) + Double.TryParse(value, XLHelper.NumberStyle, XLHelper.ParseCulture, out _)) { return value; } diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFColorScaleConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFColorScaleConverter.cs index b47906e69..a85304311 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFColorScaleConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFColorScaleConverter.cs @@ -1,3 +1,5 @@ +#nullable disable + using DocumentFormat.OpenXml.Spreadsheet; using System; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFContainsConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFContainsConverter.cs index d4a43af62..06428df61 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFContainsConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFContainsConverter.cs @@ -9,7 +9,7 @@ public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, { String val = cf.Values[1].Value; var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFConverters.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFConverters.cs index c451c7c3f..3c12fa3d1 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFConverters.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFConverters.cs @@ -1,3 +1,5 @@ +#nullable disable + using DocumentFormat.OpenXml.Spreadsheet; using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFConvertersExtension.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFConvertersExtension.cs index 7b859511f..3b95d33ee 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFConvertersExtension.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFConvertersExtension.cs @@ -1,5 +1,6 @@ -using DocumentFormat.OpenXml.Office2010.Excel; -using System; +#nullable disable + +using DocumentFormat.OpenXml.Office2010.Excel; using System.Collections.Generic; namespace ClosedXML.Excel @@ -25,4 +26,4 @@ public static ConditionalFormattingRule Convert(IXLConditionalFormat conditional return XLCFConvertersExtension.Converters[conditionalFormat.ConditionalFormatType].Convert(conditionalFormat, context); } } -} \ No newline at end of file +} diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverter.cs index 91919a961..5703d9961 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverter.cs @@ -1,5 +1,4 @@ using ClosedXML.Extensions; -using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Spreadsheet; using System; @@ -15,7 +14,7 @@ public ConditionalFormattingRule Convert(IXLConditionalFormat cf, Int32 priority var conditionalFormatValueObject1 = GetConditionalFormatValueObjectByIndex(cf, 1, ConditionalFormatValueObjectValues.Min); var conditionalFormatValueObject2 = GetConditionalFormatValueObjectByIndex(cf, 2, ConditionalFormatValueObjectValues.Max); - + var color = new Color(); switch (cf.Colors[1].ColorType) { @@ -51,7 +50,7 @@ private ConditionalFormattingRuleExtension BuildRuleExtension(IXLConditionalForm conditionalFormattingRuleExtension.AddNamespaceDeclaration("x14", "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); var id = new DocumentFormat.OpenXml.Office2010.Excel.Id { - Text = (cf as XLConditionalFormat).Id.WrapInBraces() + Text = ((XLConditionalFormat) cf).Id.WrapInBraces() }; conditionalFormattingRuleExtension.Append(id); diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverterExtension.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverterExtension.cs index 8c24428f4..0ea59f6fe 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverterExtension.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverterExtension.cs @@ -1,23 +1,30 @@ -using ClosedXML.Extensions; +using ClosedXML.Extensions; using DocumentFormat.OpenXml.Office.Excel; using DocumentFormat.OpenXml.Office2010.Excel; -using System; +using System.Collections.Generic; using System.Linq; namespace ClosedXML.Excel { internal class XLCFDataBarConverterExtension : IXLCFConverterExtension { - public XLCFDataBarConverterExtension() - { - } + private static readonly IReadOnlyDictionary CFValueToTypeMap = + new Dictionary + { + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Max, ConditionalFormattingValueObjectTypeValues.AutoMax }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Min, ConditionalFormattingValueObjectTypeValues.AutoMin }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Number, ConditionalFormattingValueObjectTypeValues.Numeric }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Percent, ConditionalFormattingValueObjectTypeValues.Percent }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Percentile, ConditionalFormattingValueObjectTypeValues.Percentile }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Formula, ConditionalFormattingValueObjectTypeValues.Formula }, + }; public ConditionalFormattingRule Convert(IXLConditionalFormat cf, XLWorkbook.SaveContext context) { ConditionalFormattingRule conditionalFormattingRule = new ConditionalFormattingRule() { Type = DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValues.DataBar, - Id = (cf as XLConditionalFormat).Id.WrapInBraces() + Id = ((XLConditionalFormat)cf).Id.WrapInBraces() }; DataBar dataBar = new DataBar() @@ -29,7 +36,7 @@ public ConditionalFormattingRule Convert(IXLConditionalFormat cf, XLWorkbook.Sav }; var cfMinType = cf.ContentTypes.TryGetValue(1, out var contentType1) - ? Convert(contentType1.ToOpenXml()) + ? GetCFType(contentType1.ToOpenXml()) : ConditionalFormattingValueObjectTypeValues.AutoMin; var cfMin = new ConditionalFormattingValueObject { Type = cfMinType }; if (cf.Values.Any() && cf.Values[1]?.Value != null) @@ -39,7 +46,7 @@ public ConditionalFormattingRule Convert(IXLConditionalFormat cf, XLWorkbook.Sav } var cfMaxType = cf.ContentTypes.TryGetValue(2, out var contentType2) - ? Convert(contentType2.ToOpenXml()) + ? GetCFType(contentType2.ToOpenXml()) : ConditionalFormattingValueObjectTypeValues.AutoMax; var cfMax = new ConditionalFormattingValueObject { Type = cfMaxType }; if (cf.Values.Count >= 2 && cf.Values[2]?.Value != null) @@ -67,25 +74,9 @@ public ConditionalFormattingRule Convert(IXLConditionalFormat cf, XLWorkbook.Sav return conditionalFormattingRule; } - private ConditionalFormattingValueObjectTypeValues Convert(DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues obj) + private static ConditionalFormattingValueObjectTypeValues GetCFType(DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues value) { - switch (obj) - { - case DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Max: - return ConditionalFormattingValueObjectTypeValues.AutoMax; - case DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Min: - return ConditionalFormattingValueObjectTypeValues.AutoMin; - case DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Number: - return ConditionalFormattingValueObjectTypeValues.Numeric; - case DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Percent: - return ConditionalFormattingValueObjectTypeValues.Percent; - case DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Percentile: - return ConditionalFormattingValueObjectTypeValues.Percentile; - case DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Formula: - return ConditionalFormattingValueObjectTypeValues.Formula; - default: - throw new NotImplementedException(); - } + return CFValueToTypeMap[value]; } } } diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFDatesOccuringConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFDatesOccuringConverter.cs index e71d3c400..13c017593 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFDatesOccuringConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFDatesOccuringConverter.cs @@ -1,4 +1,4 @@ -using DocumentFormat.OpenXml.Spreadsheet; +using DocumentFormat.OpenXml.Spreadsheet; using System; using System.Collections.Generic; @@ -23,7 +23,7 @@ internal class XLCFDatesOccurringConverter : IXLCFConverter public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, XLWorkbook.SaveContext context) { var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFEndsWithConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFEndsWithConverter.cs index df10a3164..5c6c53e45 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFEndsWithConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFEndsWithConverter.cs @@ -9,7 +9,7 @@ public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, { String val = cf.Values[1].Value; var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFIconSetConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFIconSetConverter.cs index 722465d31..4dd3ed7f0 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFIconSetConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFIconSetConverter.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using DocumentFormat.OpenXml.Spreadsheet; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFIsBlankConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFIsBlankConverter.cs index 8571b7f94..7d6fe5086 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFIsBlankConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFIsBlankConverter.cs @@ -8,7 +8,7 @@ internal class XLCFIsBlankConverter : IXLCFConverter public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, XLWorkbook.SaveContext context) { var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFIsErrorConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFIsErrorConverter.cs index b98be0520..acd560033 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFIsErrorConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFIsErrorConverter.cs @@ -8,7 +8,7 @@ internal class XLCFIsErrorConverter : IXLCFConverter public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, XLWorkbook.SaveContext context) { var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotBlankConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotBlankConverter.cs index d6b60416c..b66d8b345 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotBlankConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotBlankConverter.cs @@ -8,7 +8,7 @@ internal class XLCFNotBlankConverter : IXLCFConverter public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, XLWorkbook.SaveContext context) { var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotContainsConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotContainsConverter.cs index 94d310a05..5ba14ef63 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotContainsConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotContainsConverter.cs @@ -9,7 +9,7 @@ public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, { String val = cf.Values[1].Value; var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotErrorConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotErrorConverter.cs index e09299d78..6e78d2d06 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotErrorConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFNotErrorConverter.cs @@ -8,7 +8,7 @@ internal class XLCFNotErrorConverter : IXLCFConverter public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, XLWorkbook.SaveContext context) { var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFStartsWithConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFStartsWithConverter.cs index 1b5d124e4..fb5424305 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFStartsWithConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFStartsWithConverter.cs @@ -7,9 +7,9 @@ internal class XLCFStartsWithConverter : IXLCFConverter { public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, XLWorkbook.SaveContext context) { - String val = cf.Values[1].Value; + String? val = cf.Values[1].Value; var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFTopConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFTopConverter.cs index 4870315a5..f375652a1 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFTopConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFTopConverter.cs @@ -9,7 +9,7 @@ public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, { UInt32 val = UInt32.Parse(cf.Values[1].Value); var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFUniqueConverter.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFUniqueConverter.cs index fddc065b0..8452d3a01 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFUniqueConverter.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFUniqueConverter.cs @@ -8,7 +8,7 @@ internal class XLCFUniqueConverter : IXLCFConverter public ConditionalFormattingRule Convert(IXLConditionalFormat cf, int priority, XLWorkbook.SaveContext context) { var conditionalFormattingRule = XLCFBaseConverter.Convert(cf, priority); - var cfStyle = (cf.Style as XLStyle).Value; + var cfStyle = ((XLStyle)cf.Style).Value; if (!cfStyle.Equals(XLWorkbook.DefaultStyleValue)) conditionalFormattingRule.FormatId = (UInt32)context.DifferentialFormats[cfStyle]; diff --git a/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMax.cs b/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMax.cs index 8d4cd00a3..626623fdf 100644 --- a/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMax.cs +++ b/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMax.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMid.cs b/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMid.cs index 222649eec..67d26300a 100644 --- a/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMid.cs +++ b/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMid.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMin.cs b/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMin.cs index 35c9a78db..fd418e49e 100644 --- a/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMin.cs +++ b/ClosedXML/Excel/ConditionalFormats/XLCFColorScaleMin.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/ConditionalFormats/XLCFDataBarMax.cs b/ClosedXML/Excel/ConditionalFormats/XLCFDataBarMax.cs index 50658acfd..cac7282d4 100644 --- a/ClosedXML/Excel/ConditionalFormats/XLCFDataBarMax.cs +++ b/ClosedXML/Excel/ConditionalFormats/XLCFDataBarMax.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/ConditionalFormats/XLCFDataBarMin.cs b/ClosedXML/Excel/ConditionalFormats/XLCFDataBarMin.cs index b6803dc91..d904edfe0 100644 --- a/ClosedXML/Excel/ConditionalFormats/XLCFDataBarMin.cs +++ b/ClosedXML/Excel/ConditionalFormats/XLCFDataBarMin.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/ConditionalFormats/XLCFIconSet.cs b/ClosedXML/Excel/ConditionalFormats/XLCFIconSet.cs index 2c88ea59b..c8a8785dd 100644 --- a/ClosedXML/Excel/ConditionalFormats/XLCFIconSet.cs +++ b/ClosedXML/Excel/ConditionalFormats/XLCFIconSet.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/ConditionalFormats/XLConditionalFormat.cs b/ClosedXML/Excel/ConditionalFormats/XLConditionalFormat.cs index 7012825e4..489f627b0 100644 --- a/ClosedXML/Excel/ConditionalFormats/XLConditionalFormat.cs +++ b/ClosedXML/Excel/ConditionalFormats/XLConditionalFormat.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -59,7 +61,7 @@ public bool Equals(IXLConditionalFormat x, IXLConditionalFormat y) public int GetHashCode(IXLConditionalFormat obj) { var xx = (XLConditionalFormat)obj; - var xStyle = (obj.Style as XLStyle).Value; + var xStyle = ((XLStyle)obj.Style).Value; var xValues = xx.Values.Values.Where(v => !v.IsFormula).Select(v => v.Value); if (obj.Ranges.Count > 0) xValues = xValues @@ -161,18 +163,14 @@ public XLConditionalFormat(XLConditionalFormat conditionalFormat, IEnumerable + /// Priority of formatting rule. Lower values have higher priority than higher values. + /// Minimum value is 1. It is basically used for ordering of CF during saving. + /// + internal Int32 Priority { get; set; } public Boolean CopyDefaultModify { get; set; } - public override IEnumerable Styles - { - get - { - yield return Style; - } - } - protected override IEnumerable Children { get { yield break; } diff --git a/ClosedXML/Excel/ConditionalFormats/XLConditionalFormats.cs b/ClosedXML/Excel/ConditionalFormats/XLConditionalFormats.cs index 5a000f64b..0b4d0a50f 100644 --- a/ClosedXML/Excel/ConditionalFormats/XLConditionalFormats.cs +++ b/ClosedXML/Excel/ConditionalFormats/XLConditionalFormats.cs @@ -4,11 +4,16 @@ namespace ClosedXML.Excel { + /// + /// A container for conditional formatting of a . It contains + /// a collection of . Doesn't contain pivot table formats, + /// they are in pivot table , + /// internal class XLConditionalFormats : IXLConditionalFormats { - private readonly List _conditionalFormats = new List(); + private readonly List _conditionalFormats = new(); - private static readonly List _conditionalFormatTypesExcludedFromConsolidation = new List() + private static readonly List CFTypesExcludedFromConsolidation = new() { XLConditionalFormatType.DataBar, XLConditionalFormatType.ColorScale, @@ -53,7 +58,7 @@ internal void Consolidate() { var item = formats.First(); - if (!_conditionalFormatTypesExcludedFromConsolidation.Contains(item.ConditionalFormatType)) + if (!CFTypesExcludedFromConsolidation.Contains(item.ConditionalFormatType)) { var rangesToJoin = new XLRanges(); item.Ranges.ForEach(r => rangesToJoin.Add(r)); @@ -110,7 +115,7 @@ internal void Consolidate() consRanges.ForEach(r => item.Ranges.Add(r)); var targetCell = item.Ranges.First().FirstCell() as XLCell; - (item as XLConditionalFormat).AdjustFormulas(baseCell, targetCell); + ((XLConditionalFormat)item).AdjustFormulas(baseCell, targetCell); similarFormats.ForEach(cf => formats.Remove(cf)); } @@ -130,7 +135,7 @@ public void RemoveAll() /// public void ReorderAccordingToOriginalPriority() { - var reorderedFormats = _conditionalFormats.OrderBy(cf => (cf as XLConditionalFormat).OriginalPriority).ToList(); + var reorderedFormats = _conditionalFormats.OrderBy(cf => ((XLConditionalFormat)cf).Priority).ToList(); _conditionalFormats.Clear(); _conditionalFormats.AddRange(reorderedFormats); } diff --git a/ClosedXML/Excel/ContentManagers/XLBaseContentManager.cs b/ClosedXML/Excel/ContentManagers/XLBaseContentManager.cs index 6d031b5d8..7319c877f 100644 --- a/ClosedXML/Excel/ContentManagers/XLBaseContentManager.cs +++ b/ClosedXML/Excel/ContentManagers/XLBaseContentManager.cs @@ -1,36 +1,32 @@ -using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml; using System; using System.Collections.Generic; using System.Linq; namespace ClosedXML.Excel.ContentManagers { - internal abstract class XLBaseContentManager - { - - } - - internal abstract class XLBaseContentManager : XLBaseContentManager - where T : struct, IConvertible + internal abstract class XLBaseContentManager + where T : struct, Enum { - protected readonly IDictionary contents = new Dictionary(); + protected readonly Dictionary contents = new(); - public OpenXmlElement GetPreviousElementFor(T content) + public OpenXmlElement? GetPreviousElementFor(T content) { - var i = content.CastTo(); + // JIT will recognize the conversion for identity and removes it (for int enums). + var i = (int)(ValueType)content; - var previousElements = contents.Keys - .Where(key => key.CastTo() < i && contents[key] != null) - .OrderBy(key => key.CastTo()); + var previousElements = contents + .Where(kv => (int)(ValueType)kv.Key < i && kv.Value is not null); - if (previousElements.Any()) - return contents[previousElements.Last()]; - else - return null; + // If there is no previous element, return null. + var previousElement = previousElements + .DefaultIfEmpty(new KeyValuePair(default, null)) + .MaxBy(kv => kv.Key).Value; + return previousElement; } - public void SetElement(T content, OpenXmlElement element) + public void SetElement(T content, OpenXmlElement? element) { contents[content] = element; } diff --git a/ClosedXML/Excel/ContentManagers/XLSheetViewContentManager.cs b/ClosedXML/Excel/ContentManagers/XLSheetViewContentManager.cs index 12c74adaf..a7771c26e 100644 --- a/ClosedXML/Excel/ContentManagers/XLSheetViewContentManager.cs +++ b/ClosedXML/Excel/ContentManagers/XLSheetViewContentManager.cs @@ -1,4 +1,6 @@ -using DocumentFormat.OpenXml.Spreadsheet; +#nullable disable + +using DocumentFormat.OpenXml.Spreadsheet; using System.Linq; namespace ClosedXML.Excel.ContentManagers diff --git a/ClosedXML/Excel/ContentManagers/XLWorksheetContentManager.cs b/ClosedXML/Excel/ContentManagers/XLWorksheetContentManager.cs index d3f90c039..9b88f7d39 100644 --- a/ClosedXML/Excel/ContentManagers/XLWorksheetContentManager.cs +++ b/ClosedXML/Excel/ContentManagers/XLWorksheetContentManager.cs @@ -1,3 +1,5 @@ +#nullable disable + using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Spreadsheet; using System.Linq; diff --git a/ClosedXML/Excel/Coordinates/AbsLengthUnit.cs b/ClosedXML/Excel/Coordinates/AbsLengthUnit.cs new file mode 100644 index 000000000..a5b527ed0 --- /dev/null +++ b/ClosedXML/Excel/Coordinates/AbsLengthUnit.cs @@ -0,0 +1,29 @@ +namespace ClosedXML.Excel; + +/// +/// Absolute units of physical length. +/// +/// +/// Pixels are relative units to the size of screen. +/// +internal enum AbsLengthUnit +{ + Inch, + Centimeter, + Millimeter, + + /// + /// 1 pt = 1/72 inch + /// + Point, + + /// + /// 1 pc = 12pt. + /// + Pica, + + /// + /// English metric unit. + /// + Emu +} diff --git a/ClosedXML/Excel/Coordinates/Emu.cs b/ClosedXML/Excel/Coordinates/Emu.cs new file mode 100644 index 000000000..5d11b9255 --- /dev/null +++ b/ClosedXML/Excel/Coordinates/Emu.cs @@ -0,0 +1,86 @@ +using System; +using System.Diagnostics; +using System.Globalization; + +namespace ClosedXML.Excel; + +/// +/// English metric unit. +/// +internal readonly record struct Emu +{ + private const int PerInch = 914400; + private const int PerCm = 360000; + private const int PerMm = PerCm / 10; + private const int PerPt = PerInch / 72; + private const int PerPc = PerInch / 6; + + private readonly AbsLengthUnit _preferredUnit; + + internal static readonly Emu ZeroPt = new(0, AbsLengthUnit.Point); + + private Emu(int emus, AbsLengthUnit preferredUnit) + { + _preferredUnit = preferredUnit; + Value = emus; + } + + /// + /// Length in EMU. + /// + internal int Value { get; } + + internal static Emu? From(double value, AbsLengthUnit srcUnit) + { + var coef = GetUnitCoefficient(srcUnit); + var emus = Math.Round(value * coef, MidpointRounding.AwayFromZero); + if (emus is < int.MinValue or > int.MaxValue) + return null; + + return new Emu((int)emus, srcUnit); + } + + private static int GetUnitCoefficient(AbsLengthUnit srcUnit) + { + return srcUnit switch + { + AbsLengthUnit.Inch => PerInch, + AbsLengthUnit.Centimeter => PerCm, + AbsLengthUnit.Millimeter => PerMm, + AbsLengthUnit.Point => PerPt, + AbsLengthUnit.Pica => PerPc, + AbsLengthUnit.Emu => 1, + _ => throw new ArgumentOutOfRangeException(), + }; + } + + /// + /// Return length in specified unit. + /// + internal double To(AbsLengthUnit unit) + { + var coef = GetUnitCoefficient(unit); + return Value / (double)coef; + } + + public override string ToString() + { + return ToString(_preferredUnit); + } + + public string ToString(AbsLengthUnit unit) + { + var lengthInUnit = To(unit); + var unitSuffix = unit switch + { + AbsLengthUnit.Inch => "in", + AbsLengthUnit.Centimeter => "cm", + AbsLengthUnit.Millimeter => "mm", + AbsLengthUnit.Point => "pt", + AbsLengthUnit.Pica => "pc", + AbsLengthUnit.Emu => "emu", + _ => throw new UnreachableException(), + }; + return lengthInUnit.ToString(CultureInfo.InvariantCulture) + unitSuffix; + } +} diff --git a/ClosedXML/Excel/Coordinates/IXLAddress.cs b/ClosedXML/Excel/Coordinates/IXLAddress.cs index 3bbd25c55..3dadbf10f 100644 --- a/ClosedXML/Excel/Coordinates/IXLAddress.cs +++ b/ClosedXML/Excel/Coordinates/IXLAddress.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/Coordinates/XLAddress.cs b/ClosedXML/Excel/Coordinates/XLAddress.cs index 4d00442d5..d82132bbf 100644 --- a/ClosedXML/Excel/Coordinates/XLAddress.cs +++ b/ClosedXML/Excel/Coordinates/XLAddress.cs @@ -1,4 +1,5 @@ -using ClosedXML.Extensions; +#nullable disable + using System; using System.Diagnostics; @@ -11,7 +12,6 @@ internal struct XLAddress : IXLAddress, IEquatable /// Create address without worksheet. For calculation only! /// /// - /// public static XLAddress Create(string cellAddressString) { return Create(null, cellAddressString); @@ -298,11 +298,7 @@ public string GetTrimmedAddress() public static Boolean operator ==(XLAddress left, XLAddress right) { - if (ReferenceEquals(left, right)) - { - return true; - } - return !ReferenceEquals(left, null) && left.Equals(right); + return left.Equals(right); } public static Boolean operator !=(XLAddress left, XLAddress right) diff --git a/ClosedXML/Excel/Coordinates/XLBookArea.cs b/ClosedXML/Excel/Coordinates/XLBookArea.cs new file mode 100644 index 000000000..8d9388e39 --- /dev/null +++ b/ClosedXML/Excel/Coordinates/XLBookArea.cs @@ -0,0 +1,81 @@ +using System; + +namespace ClosedXML.Excel +{ + /// + /// A specification of an area (rectangular range) of a sheet. + /// + internal readonly struct XLBookArea : IEquatable + { + /// + /// Name of the sheet. Sheet may exist or not (e.g. deleted). Never null. + /// + public readonly string Name; + + /// + /// An area in the sheet. + /// + public readonly XLSheetRange Area; + + public XLBookArea(String name, XLSheetRange area) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException(nameof(name)); + + Name = name; + Area = area; + } + + public static bool operator ==(XLBookArea lhs, XLBookArea rhs) => lhs.Equals(rhs); + + public static bool operator !=(XLBookArea lhs, XLBookArea rhs) => !(lhs == rhs); + + internal static XLBookArea From(IXLRange range) + { + if (range.Worksheet is null) + throw new ArgumentException("Range doesn't contain sheet.", nameof(range)); + + return new XLBookArea(range.Worksheet.Name, XLSheetRange.FromRangeAddress(range.RangeAddress)); + } + + public bool Equals(XLBookArea other) + { + return Area == other.Area && XLHelper.SheetComparer.Equals(Name, other.Name); + } + + public override bool Equals(object? obj) + { + return obj is XLBookArea other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + return (XLHelper.SheetComparer.GetHashCode(Name) * 397) ^ Area.GetHashCode(); + } + } + + /// + /// Perform an intersection. + /// + /// The area that is being intersected with this one. + /// The intersection (=same sheet and has non-empty intersection) or null if intersection isn't possible. + public XLBookArea? Intersect(XLBookArea other) + { + if (!XLHelper.SheetComparer.Equals(Name, other.Name)) + return null; + + var intersectionRange = Area.Intersect(other.Area); + if (intersectionRange is null) + return null; + + return new XLBookArea(Name, intersectionRange.Value); + } + + public override string ToString() + { + return $"{Name}!{Area}"; + } + } +} diff --git a/ClosedXML/Excel/Coordinates/XLBookPoint.cs b/ClosedXML/Excel/Coordinates/XLBookPoint.cs new file mode 100644 index 000000000..c9ab47da9 --- /dev/null +++ b/ClosedXML/Excel/Coordinates/XLBookPoint.cs @@ -0,0 +1,69 @@ +using System; + +namespace ClosedXML.Excel +{ + /// + /// A single point in a workbook. The book point might point to a deleted + /// worksheet, so it might be invalid. Make sure it is checked when + /// determining the properties of the actual data of the point. + /// + internal readonly struct XLBookPoint : IEquatable + { + internal XLBookPoint(XLWorksheet sheet, XLSheetPoint point) + : this(sheet.SheetId, point) + { + } + + internal XLBookPoint(uint sheetId, XLSheetPoint point) + { + SheetId = sheetId; + Point = point; + } + + /// TODO: SheetId doesn't work nicely with renames, but will in the future. + /// + /// A sheet id of a point. Id of a sheet never changes during workbook + /// lifecycle (), but the sheet may be + /// deleted, making the sheetId and thus book point invalid. + /// + public uint SheetId { get; } + + /// + public int Row => Point.Column; + + /// + public int Column => Point.Column; + + /// + /// A point in the sheet. + /// + public XLSheetPoint Point { get; } + + public static bool operator ==(XLBookPoint lhs, XLBookPoint rhs) => lhs.Equals(rhs); + + public static bool operator !=(XLBookPoint lhs, XLBookPoint rhs) => !(lhs == rhs); + + public bool Equals(XLBookPoint other) + { + return SheetId == other.SheetId && Point.Equals(other.Point); + } + + public override bool Equals(object? obj) + { + return obj is XLBookPoint other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + return ((int)SheetId * 397) ^ Point.GetHashCode(); + } + } + + public override string ToString() + { + return $"[{SheetId}]{Point}"; + } + } +} diff --git a/ClosedXML/Excel/Coordinates/XLName.cs b/ClosedXML/Excel/Coordinates/XLName.cs new file mode 100644 index 000000000..a43069c94 --- /dev/null +++ b/ClosedXML/Excel/Coordinates/XLName.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; + +namespace ClosedXML.Excel +{ + /// + /// A name in a worksheet. Unlike , this is basically only a reference. + /// The actual + /// + internal readonly struct XLName : IEquatable + { + /// + /// Name of a sheet. If null, the scope is a workbook. The sheet might not exist, e.g. it + /// is only in a formula. The name of a sheet is not escaped. + /// + public string? SheetName { get; } + + /// + /// The defined name in the scope. Case insensitive during comparisons. + /// + public string Name { get; } + + public XLName(string sheetName, string name) + { + if (string.IsNullOrEmpty(sheetName)) + throw new ArgumentException(nameof(sheetName)); + + if (name.Any(char.IsWhiteSpace)) + throw new ArgumentException("Name can't contain whitespace."); + + SheetName = sheetName; + Name = name; + } + + public XLName(string name) + { + if (name.Any(char.IsWhiteSpace)) + throw new ArgumentException("Name can't contain whitespace."); + + SheetName = null; + Name = name; + } + + public bool Equals(XLName other) + { + var differentScope = SheetName is null ^ other.SheetName is null; + if (differentScope) + return false; + + var bothWorkbookScope = SheetName is null && other.SheetName is null; + if (bothWorkbookScope) + return XLHelper.NameComparer.Equals(Name, other.Name); + + return XLHelper.NameComparer.Equals(Name, other.Name) && + XLHelper.SheetComparer.Equals(SheetName, other.SheetName); + } + + public override bool Equals(object? obj) + { + return obj is XLName other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = (SheetName is not null ? XLHelper.SheetComparer.GetHashCode(SheetName) : 0) * 397; + hashCode ^= XLHelper.NameComparer.GetHashCode(Name); + return hashCode; + } + } + + public override string ToString() + { + var isWorkbookScoped = SheetName is null; + return isWorkbookScoped ? Name : $"{SheetName}!{Name}"; + } + } +} diff --git a/ClosedXML/Excel/Coordinates/XLReference.cs b/ClosedXML/Excel/Coordinates/XLReference.cs new file mode 100644 index 000000000..ff9eb9975 --- /dev/null +++ b/ClosedXML/Excel/Coordinates/XLReference.cs @@ -0,0 +1,54 @@ +using ClosedXML.Extensions; +using ClosedXML.Parser; + +namespace ClosedXML.Excel; + +/// +/// A reference without a sheet. Can represent single cell (A1), area +/// (B$4:$D$10), row span (4:10) and col span (G:H). +/// +/// +/// This is an actual representation of a reference, while the is for +/// an absolute are of a sheet and is only for a cell reference and +/// only for area reference. +/// +internal readonly record struct XLReference +{ + private readonly ReferenceArea _reference; + + internal XLReference(ReferenceArea reference) + { + _reference = reference; + } + + internal string GetA1() + { + return _reference.GetDisplayStringA1(); + } + + internal XLRangeAddress ToRangeAddress(XLWorksheet? sheet, XLSheetPoint anchor) + { + var area = _reference.ToSheetRange(anchor); + var firstColAbs = _reference.First.ColumnType == ReferenceAxisType.Absolute; + var firstRowAbs = _reference.First.RowType == ReferenceAxisType.Absolute; + var secondColAbs = _reference.Second.ColumnType == ReferenceAxisType.Absolute; + var secondRowAbs = _reference.Second.RowType == ReferenceAxisType.Absolute; + if (_reference.First.IsColumn) + { + // Column span + firstRowAbs = true; + secondRowAbs = true; + } + + if (_reference.First.IsRow) + { + // Row span + firstColAbs = true; + secondColAbs = true; + } + + return new XLRangeAddress( + new XLAddress(sheet, area.TopRow, area.LeftColumn, firstRowAbs, firstColAbs), + new XLAddress(sheet, area.BottomRow, area.RightColumn, secondRowAbs, secondColAbs)); + } +} diff --git a/ClosedXML/Excel/Coordinates/XLSheetOffset.cs b/ClosedXML/Excel/Coordinates/XLSheetOffset.cs new file mode 100644 index 000000000..7c77a128d --- /dev/null +++ b/ClosedXML/Excel/Coordinates/XLSheetOffset.cs @@ -0,0 +1,20 @@ +using System; + +namespace ClosedXML.Excel; + +/// +/// An offset of a cell in a sheet. +/// +/// The row offset in number of rows from the original point. +/// The column offset in number of columns from the original point +internal readonly record struct XLSheetOffset(int RowOfs, int ColOfs) : IComparable +{ + public int CompareTo(XLSheetOffset other) + { + var rowComparison = RowOfs.CompareTo(other.RowOfs); + if (rowComparison != 0) + return rowComparison; + + return ColOfs.CompareTo(other.ColOfs); + } +} diff --git a/ClosedXML/Excel/Coordinates/XLSheetPoint.cs b/ClosedXML/Excel/Coordinates/XLSheetPoint.cs index f1c3cea23..d8f66813d 100644 --- a/ClosedXML/Excel/Coordinates/XLSheetPoint.cs +++ b/ClosedXML/Excel/Coordinates/XLSheetPoint.cs @@ -1,21 +1,39 @@ using System; +using System.Diagnostics; namespace ClosedXML.Excel { - internal struct XLSheetPoint:IEquatable + /// + /// An point (address) in a worksheet, an equivalent of ST_CellRef. + /// + /// Unlike the XLAddress, sheet can never be invalid. + [DebuggerDisplay("{XLHelper.GetColumnLetterFromNumber(Column)+Row}")] + internal readonly struct XLSheetPoint : IEquatable, IComparable { - public XLSheetPoint(Int32 row, Int32 column) + public XLSheetPoint(Int32 row, Int32 column) { Row = row; Column = column; } + /// + /// 1-based row number in a sheet. + /// public readonly Int32 Row; + + /// + /// 1-based column number in a sheet. + /// public readonly Int32 Column; - public override bool Equals(object obj) + public static implicit operator XLSheetRange(XLSheetPoint point) + { + return new XLSheetRange(point); + } + + public override bool Equals(object? obj) { - return Equals((XLSheetPoint)obj); + return obj is XLSheetPoint point && Equals(point); } public bool Equals(XLSheetPoint other) @@ -28,7 +46,7 @@ public override int GetHashCode() return (Row * -1) ^ Column; } - public static bool operator==(XLSheetPoint a, XLSheetPoint b) + public static bool operator ==(XLSheetPoint a, XLSheetPoint b) { return a.Row == b.Row && a.Column == b.Column; } @@ -37,5 +55,186 @@ public override int GetHashCode() { return a.Row != b.Row || a.Column != b.Column; } + + /// + /// Get offset that must be added to so we can get . + /// + public static XLSheetOffset operator -(XLSheetPoint target, XLSheetPoint origin) + { + return new XLSheetOffset(target.Row - origin.Row, target.Column - origin.Column); + } + + /// + public static XLSheetPoint Parse(String text) => Parse(text.AsSpan()); + + /// + /// Parse point per type ST_CellRef from + /// 2.1.1108 Part 4 Section 3.18.8, ST_CellRef (Cell Reference) + /// + /// Input text + /// If the input doesn't match expected grammar. + public static XLSheetPoint Parse(ReadOnlySpan input) + { + if (!TryParse(input, out var point)) + throw new FormatException($"Sheet point doesn't have correct format: '{input.ToString()}'."); + + return point; + } + + /// + /// Try to parse sheet point. Doesn't accept any extra whitespace anywhere in the input. + /// Letters must be upper case. + /// + public static bool TryParse(ReadOnlySpan input, out XLSheetPoint point) + { + point = default; + + // Don't reuse inefficient logic from XLAddress + if (input.Length < 2) + return false; + + var i = 0; + var c = input[i++]; + if (!IsLetter(c)) + return false; + + var columnIndex = c - 'A' + 1; + while (i < input.Length && IsLetter(c = input[i])) + { + columnIndex = columnIndex * 26 + c - 'A' + 1; + i++; + } + + if (i > 3) + return false; + + if (i == input.Length) + return false; + + // Everything else must be digits + c = input[i++]; + + // First letter can't be 0 + if (c is < '1' or > '9') + return false; + + var rowIndex = c - '0'; + while (i < input.Length && IsDigit(c = input[i])) + { + rowIndex = rowIndex * 10 + c - '0'; + i++; + } + + if (i != input.Length) + return false; + + if (rowIndex > XLHelper.MaxRowNumber || columnIndex > XLHelper.MaxColumnNumber) + return false; + + point = new XLSheetPoint(rowIndex, columnIndex); + return true; + + static bool IsLetter(char c) => c is >= 'A' and <= 'Z'; + static bool IsDigit(char c) => c is >= '0' and <= '9'; + } + + /// + /// Write the sheet point as a reference to the span (e.g. A1). + /// + /// Must be at least 10 chars long + /// Number of chars + public int Format(Span output) + { + var columnLetters = XLHelper.GetColumnLetterFromNumber(Column); + for (var i = 0; i < columnLetters.Length; ++i) + output[i] = columnLetters[i]; + + var digitCount = GetDigitCount(Row); + var rowRemainder = Row; + var formattedLength = digitCount + columnLetters.Length; + for (var i = formattedLength - 1; i >= columnLetters.Length; --i) + { + var digit = rowRemainder % 10; + rowRemainder /= 10; + output[i] = (char)(digit + '0'); + } + + return formattedLength; + } + + public override String ToString() + { + Span text = stackalloc char[10]; + var len = Format(text); + return text.Slice(0, len).ToString(); + } + + private static int GetDigitCount(int n) + { + if (n < 10L) return 1; + if (n < 100L) return 2; + if (n < 1000L) return 3; + if (n < 10000L) return 4; + if (n < 100000L) return 5; + if (n < 1000000L) return 6; + return 7; // Row can't have more digits + } + + /// + /// Create a sheet point from the address. Workbook is ignored. + /// + public static XLSheetPoint FromAddress(IXLAddress address) + => new(address.RowNumber, address.ColumnNumber); + + public int CompareTo(XLSheetPoint other) + { + var rowComparison = Row.CompareTo(other.Row); + if (rowComparison != 0) + return rowComparison; + + return Column.CompareTo(other.Column); + } + + /// + /// Is the point within the range or below the range? + /// + internal bool InRangeOrBelow(in XLSheetRange range) + { + return Row >= range.FirstPoint.Row && + Column >= range.FirstPoint.Column && + Column <= range.LastPoint.Column; + } + + /// + /// Is the point within the range or to the left of the range? + /// + internal bool InRangeOrToLeft(in XLSheetRange range) + { + return Column >= range.FirstPoint.Column && + Row >= range.FirstPoint.Row && + Row <= range.LastPoint.Row; + } + + /// + /// Return a new point that has its row coordinate shifted by . + /// + /// How many rows will new point be shifted. Positive - new point + /// is downwards, negative - new point is upwards relative to the current point. + /// Shifted point. + internal XLSheetPoint ShiftRow(int rowShift) + { + return new XLSheetPoint(Row + rowShift, Column); + } + + /// + /// Return a new point that has its column coordinate shifted by . + /// + /// How many columns will new point be shifted. Positive - new + /// point is to the right, negative - new point is to the left. + /// Shifted point. + internal XLSheetPoint ShiftColumn(int columnShift) + { + return new XLSheetPoint(Row, Column + columnShift); + } } } diff --git a/ClosedXML/Excel/Coordinates/XLSheetRange.cs b/ClosedXML/Excel/Coordinates/XLSheetRange.cs index 9fb23a223..83422a592 100644 --- a/ClosedXML/Excel/Coordinates/XLSheetRange.cs +++ b/ClosedXML/Excel/Coordinates/XLSheetRange.cs @@ -1,18 +1,79 @@ -using System; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; namespace ClosedXML.Excel { - internal struct XLSheetRange:IEquatable + /// + /// A representation of a ST_Ref, i.e. an area in a sheet (no reference to the sheet). + /// + internal readonly struct XLSheetRange : IEquatable, IEnumerable { - public XLSheetRange(XLSheetPoint firstPoint, XLSheetPoint lastPoint) + internal XLSheetRange(XLSheetPoint point) + : this(point, point) + { + } + + internal XLSheetRange(XLSheetPoint firstPoint, XLSheetPoint lastPoint) { FirstPoint = firstPoint; LastPoint = lastPoint; } + public XLSheetRange(Int32 rowStart, Int32 columnStart, Int32 rowEnd, Int32 columnEnd) + : this(new XLSheetPoint(rowStart, columnStart), new XLSheetPoint(rowEnd, columnEnd)) + { + } + + /// + /// A range that covers whole worksheet. + /// + public static readonly XLSheetRange Full = new( + new XLSheetPoint(XLHelper.MinRowNumber, XLHelper.MinColumnNumber), + new XLSheetPoint(XLHelper.MaxRowNumber, XLHelper.MaxColumnNumber)); + + /// + /// Top-left point of the sheet range. + /// public readonly XLSheetPoint FirstPoint; + + /// + /// Bottom-right point of the sheet range. + /// public readonly XLSheetPoint LastPoint; + public int Width => LastPoint.Column - FirstPoint.Column + 1; + + public int Height => LastPoint.Row - FirstPoint.Row + 1; + + /// + /// The left column number of the range. From 1 to . + /// + public int LeftColumn => FirstPoint.Column; + + /// + /// The right column number of the range. From 1 to . + /// Greater or equal to . + /// + public int RightColumn => LastPoint.Column; + + /// + /// The top row number of the range. From 1 to . + /// + public int TopRow => FirstPoint.Row; + + /// + /// The bottom row number of the range. From 1 to . + /// Greater or equal to . + /// + public int BottomRow => LastPoint.Row; + + public override bool Equals(object? obj) + { + return obj is XLSheetRange range && Equals(range); + } + public bool Equals(XLSheetRange other) { return FirstPoint.Equals(other.FirstPoint) && LastPoint.Equals(other.LastPoint); @@ -22,5 +83,567 @@ public override int GetHashCode() { return FirstPoint.GetHashCode() ^ LastPoint.GetHashCode(); } + + public static bool operator ==(XLSheetRange left, XLSheetRange right) => left.Equals(right); + + public static bool operator !=(XLSheetRange left, XLSheetRange right) => !(left == right); + + + /// + public static XLSheetRange Parse(String input) => Parse(input.AsSpan()); + + /// + /// Parse point per type ST_Ref from + /// 2.1.1119 Part 4 Section 3.18.64, ST_Ref (Cell Range Reference) + /// + /// Can be one cell reference (A1) or two separated by a colon (A1:B2). First reference is always in top left corner + /// Input text + /// If the input doesn't match expected grammar. + public static XLSheetRange Parse(ReadOnlySpan input) + { + if (!TryParse(input, out var area)) + throw new FormatException($"Area reference doesn't have correct format: '{input.ToString()}'."); + + return area; + } + + /// + /// Try to parse area. Doesn't accept any extra whitespace anywhere in the input. Letters + /// must be upper case. Area can specify one corner (A1) or both corners (A1:B3). + /// + public static bool TryParse(ReadOnlySpan input, out XLSheetRange area) + { + var separatorIndex = input.IndexOf(':'); + if (separatorIndex == -1) + { + if (!XLSheetPoint.TryParse(input, out var sheetPoint)) + { + area = default; + return false; + } + + area = new XLSheetRange(sheetPoint, sheetPoint); + return true; + } + + if (!XLSheetPoint.TryParse(input[..separatorIndex], out var first) || + !XLSheetPoint.TryParse(input[(separatorIndex + 1)..], out var second) || + first.Column > second.Column || first.Row > second.Row) + { + area = default; + return false; + } + + area = new XLSheetRange(first, second); + return true; + } + + /// + /// Write the sheet range to the span. If range has only one cell, write only the cell. + /// + /// Must be at least 21 chars long. + /// Number of written characters. + public int Format(Span output) + { + if (FirstPoint == LastPoint) + return FirstPoint.Format(output); + + var firstPointLen = FirstPoint.Format(output); + output[firstPointLen] = ':'; + var lastPointLen = LastPoint.Format(output.Slice(firstPointLen + 1)); + return firstPointLen + 1 + lastPointLen; + } + + public override String ToString() + { + Span text = stackalloc char[21]; + var len = Format(text); + return text.Slice(0, len).ToString(); + } + + /// + /// Return a range that contains all cells below the current range. + /// + /// The range touches the bottom border of the sheet. + internal XLSheetRange BelowRange() + { + return BelowRange(XLHelper.MaxRowNumber); + } + + /// + /// Get a range below the current one rows. + /// If there isn't enough rows, use as many as possible. + /// + /// The range touches the bottom border of the sheet. + internal XLSheetRange BelowRange(int rows) + { + if (LastPoint.Row >= XLHelper.MaxRowNumber) + throw new InvalidOperationException("No cells below."); + + rows = Math.Min(rows, XLHelper.MaxRowNumber - LastPoint.Row); + return new XLSheetRange( + new XLSheetPoint(LastPoint.Row + 1, FirstPoint.Column), + new XLSheetPoint(LastPoint.Row + rows, LastPoint.Column)); + } + + /// + /// Return a range that contains all cells to the right of the range. + /// + /// The range touches the right border of the sheet. + internal XLSheetRange RightRange() + { + if (LastPoint.Column == XLHelper.MaxColumnNumber) + throw new InvalidOperationException("No cells to the left."); + + return new XLSheetRange( + new XLSheetPoint(FirstPoint.Row, LastPoint.Column + 1), + new XLSheetPoint(LastPoint.Row, XLHelper.MaxColumnNumber)); + } + + /// + /// Return a range that contains additional number of rows below. + /// + internal XLSheetRange ExtendBelow(int rows) + { + Debug.Assert(rows >= 0); + var row = Math.Min(LastPoint.Row + rows, XLHelper.MaxRowNumber); + return new XLSheetRange(FirstPoint, new XLSheetPoint(row, LastPoint.Column)); + } + + /// + /// Return a range that contains additional number of columns to the right. + /// + internal XLSheetRange ExtendRight(int columns) + { + Debug.Assert(columns >= 0); + var column = Math.Min(LastPoint.Column + columns, XLHelper.MaxColumnNumber); + return new XLSheetRange(FirstPoint, new XLSheetPoint(LastPoint.Row, column)); + } + + internal static XLSheetRange FromRangeAddress(T address) + where T : IXLRangeAddress + { + var firstPoint = XLSheetPoint.FromAddress(address.FirstAddress); + var lastPoint = XLSheetPoint.FromAddress(address.LastAddress); + if (firstPoint.Row > lastPoint.Row || firstPoint.Column > lastPoint.Column) + return new XLSheetRange(lastPoint, firstPoint); + + return new XLSheetRange(firstPoint, lastPoint); + } + + public bool Contains(XLSheetPoint point) + { + return + point.Row >= FirstPoint.Row && point.Row <= LastPoint.Row && + point.Column >= FirstPoint.Column && point.Column <= LastPoint.Column; + } + + /// + /// Create a new range from this one by taking a number of rows from the bottom row up. + /// + /// How many rows to take, must be at least one. + public XLSheetRange SliceFromBottom(int rows) + { + if (rows < 1) + throw new ArgumentOutOfRangeException(); + + return new XLSheetRange(new XLSheetPoint(BottomRow - rows + 1, FirstPoint.Column), LastPoint); + } + + /// + /// Create a new range from this one by taking a number of rows from the top row down. + /// + /// How many rows to take, must be at least one. + public XLSheetRange SliceFromTop(int rows) + { + if (rows < 1) + throw new ArgumentOutOfRangeException(); + + return new XLSheetRange(FirstPoint, new XLSheetPoint(TopRow + rows - 1, LastPoint.Column)); + } + + /// + /// Create a new range from this one by taking a number of rows from the left column to the right. + /// + /// How many columns to take, must be at least one. + public XLSheetRange SliceFromLeft(int columns) + { + if (columns < 1) + throw new ArgumentOutOfRangeException(); + + return new XLSheetRange(FirstPoint, new XLSheetPoint(FirstPoint.Row, LeftColumn + columns - 1)); + } + + /// + /// Create a new range from this one by taking a number of rows from the bottom row up. + /// + /// How many columns to take, must be at least one. + public XLSheetRange SliceFromRight(int columns) + { + if (columns < 1) + throw new ArgumentOutOfRangeException(); + + return new XLSheetRange(new XLSheetPoint(FirstPoint.Row, RightColumn - columns + 1), LastPoint); + } + + /// + /// Create a new sheet range that is a result of range operator (:) + /// of this sheet range and + /// + /// The other range. + /// A range that contains both this range and . + public XLSheetRange Range(XLSheetRange otherRange) + { + var topRow = Math.Min(TopRow, otherRange.TopRow); + var leftColumn = Math.Min(LeftColumn, otherRange.LeftColumn); + var bottomRow = Math.Max(BottomRow, otherRange.BottomRow); + var rightColumn = Math.Max(RightColumn, otherRange.RightColumn); + return new XLSheetRange(topRow, leftColumn, bottomRow, rightColumn); + } + + /// + /// Does this range intersects with . + /// + /// true if intersects, false otherwise. + internal bool Intersects(XLSheetRange other) + { + return Intersect(other) is not null; + } + + /// + /// Do an intersection between this range and other range. + /// + /// Other range. + /// The intersection range if it exists and is non-empty or null, if intersection doesn't exist. + internal XLSheetRange? Intersect(XLSheetRange other) + { + var leftColumn = Math.Max(LeftColumn, other.LeftColumn); + var rightColumn = Math.Min(RightColumn, other.RightColumn); + var topRow = Math.Max(TopRow, other.TopRow); + var bottomRow = Math.Min(BottomRow, other.BottomRow); + + if (bottomRow < topRow || rightColumn < leftColumn) + return null; + + return new XLSheetRange(topRow, leftColumn, bottomRow, rightColumn); + } + + /// + /// Does this range overlaps the ? + /// + internal bool Overlaps(XLSheetRange otherRange) + { + return TopRow <= otherRange.TopRow && + RightColumn >= otherRange.RightColumn && + BottomRow >= otherRange.BottomRow && + LeftColumn <= otherRange.LeftColumn; + } + + /// + /// Does range cover all rows, from top row to bottom row of a sheet. + /// + internal bool IsEntireColumn() + { + return TopRow == 1 && BottomRow == XLHelper.MaxRowNumber; + } + + /// + /// Does range cover all columns, from first to last column of a sheet. + /// + public bool IsEntireRow() + { + return LeftColumn == 1 && RightColumn == XLHelper.MaxColumnNumber; + } + + /// + /// Return a new range that has the same size as the current one, + /// + /// New top left coordinate of returned range. + /// New range. + internal XLSheetRange At(XLSheetPoint topLeftCorner) + { + var bottomRightCorner = topLeftCorner.ShiftColumn(Width - 1).ShiftRow(Height - 1); + return new XLSheetRange(topLeftCorner, bottomRightCorner); + } + + /// + /// Return a new range that has been shifted in vertical direction by . + /// + /// By how much to shift the range, positive - downwards, negative - upwards. + /// Newly created area. + internal XLSheetRange ShiftRows(int rowShift) + { + var topLeftCorner = FirstPoint.ShiftRow(rowShift); + var bottomRightCorner = LastPoint.ShiftRow(rowShift); + return new XLSheetRange(topLeftCorner, bottomRightCorner); + } + + /// + /// Return a new range that has been shifted in horizontal direction by . + /// + /// By how much to shift the range, positive - rightward, negative - leftward. + /// Newly created area. + internal XLSheetRange ShiftColumns(int columnShift) + { + var topLeftCorner = FirstPoint.ShiftColumn(columnShift); + var bottomRightCorner = LastPoint.ShiftColumn(columnShift); + return new XLSheetRange(topLeftCorner, bottomRightCorner); + } + + public IEnumerator GetEnumerator() + { + for (var row = TopRow; row <= BottomRow; ++row) + { + for (var col = LeftColumn; col <= RightColumn; ++col) + { + yield return new XLSheetPoint(row, col); + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Calculate size and position of the area when another area is inserted into a sheet. + /// + /// Inserted area. + /// The result, might be null as a valid result if area is pushed out. + /// true if results wasn't partially shifted. + internal bool TryInsertAreaAndShiftRight(XLSheetRange insertedArea, out XLSheetRange? result) + { + // Inserted fully upward, downward or to the right + if (insertedArea.BottomRow < TopRow || + insertedArea.TopRow > BottomRow || + insertedArea.LeftColumn > RightColumn) + { + result = this; + return true; + } + + var fullyOverlaps = insertedArea.TopRow <= TopRow && + insertedArea.BottomRow >= BottomRow; + if (!fullyOverlaps) + { + result = null; + return false; + } + + // Are is effectively inserted into a seam at the left column of the insertedArea + if (insertedArea.LeftColumn <= LeftColumn) + { + // Area is completely pushed out + if (LeftColumn + insertedArea.Width > XLHelper.MaxColumnNumber) + { + result = null; + return true; + } + + // Area is partially pushed out + if (RightColumn + insertedArea.Width > XLHelper.MaxColumnNumber) + { + var pushedOutColsCount = RightColumn + insertedArea.Width - XLHelper.MaxColumnNumber; + var keepCols = Width - pushedOutColsCount; + var resized = SliceFromLeft(keepCols); + result = resized.ShiftColumns(insertedArea.Width); + return true; + } + + // Not pushed out = only shift + result = ShiftColumns(insertedArea.Width); + return true; + } + + result = ExtendRight(insertedArea.Width); + return true; + } + + /// + /// Calculate size and position of the area when another area is inserted into a sheet. + /// + /// Inserted area. + /// The result, might be null as a valid result if area is pushed out. + /// true if results wasn't partially shifted. + internal bool TryInsertAreaAndShiftDown(XLSheetRange insertedArea, out XLSheetRange? result) + { + // Inserted fully to the left, to the right or below + if (insertedArea.RightColumn < LeftColumn || + insertedArea.LeftColumn > RightColumn || + insertedArea.TopRow > BottomRow) + { + result = this; + return true; + } + + var fullyOverlaps = insertedArea.LeftColumn <= LeftColumn && + insertedArea.RightColumn >= RightColumn; + if (!fullyOverlaps) + { + result = null; + return false; + } + + // Are is effectively inserted into a seam at the top row of the insertedArea + if (insertedArea.TopRow <= TopRow) + { + // Area is completely pushed out + if (TopRow + insertedArea.Height > XLHelper.MaxRowNumber) + { + result = null; + return true; + } + + // Area is partially pushed out + if (BottomRow + insertedArea.Height > XLHelper.MaxRowNumber) + { + var pushedOutRowsCount = BottomRow + insertedArea.Height - XLHelper.MaxRowNumber; + var keepRows = Height - pushedOutRowsCount; + var resized = SliceFromTop(keepRows); + result = resized.ShiftRows(insertedArea.Height); + return true; + } + + // Not pushed out = only shift + result = ShiftRows(insertedArea.Height); + return true; + } + + result = ExtendBelow(insertedArea.Height); + return true; + } + + /// + /// Take the area and reposition it as if the was removed + /// from sheet. If cells the left of the area are deleted, the area shifts to the left. + /// If is within the area, the width of the area decreases. + /// + /// + /// If the method returns false, there is a partial cover and it's up to you to + /// decide what to do. + /// + /// + /// The has a value null if the range was completely + /// removed by . + /// + internal bool TryDeleteAreaAndShiftLeft(XLSheetRange deletedArea, out XLSheetRange? result) + { + // Deleted area is fully upwards, downwards or to the right of this area. + if (deletedArea.BottomRow < TopRow || + deletedArea.TopRow > BottomRow || + deletedArea.LeftColumn > RightColumn) + { + result = this; + return true; + } + + var doesntOverlapHeight = deletedArea.TopRow > TopRow || + deletedArea.BottomRow < BottomRow; + var deletesColumnsToLeft = deletedArea.LeftColumn < LeftColumn; + var deletesColumnsOfArea = deletedArea.LeftColumn <= RightColumn && + deletedArea.RightColumn >= LeftColumn; + if (doesntOverlapHeight && (deletesColumnsToLeft || deletesColumnsOfArea)) + { + result = null; + return false; + } + + var repositioned = this; + if (deletesColumnsOfArea) + { + // Decrease width of repositioned area + var left = Math.Max(deletedArea.LeftColumn, repositioned.LeftColumn); + var right = Math.Min(deletedArea.RightColumn, repositioned.RightColumn); + + var columnsToDelete = right - left + 1; + var newWidth = repositioned.Width - columnsToDelete; + if (newWidth == 0) + { + result = null; + return true; + } + + repositioned = repositioned.SliceFromLeft(newWidth); + } + + if (deletesColumnsToLeft) + { + // There are some deleted columns to the left of the area -> shift left + var deletedLastColumnsOutwards = Math.Min(repositioned.LeftColumn - 1, deletedArea.RightColumn); + + var shiftLeft = deletedLastColumnsOutwards - deletedArea.LeftColumn + 1; + repositioned = repositioned.ShiftColumns(-shiftLeft); + } + + result = repositioned; + return true; + } + + /// + /// Take the area and reposition it as if the was removed + /// from sheet. If cells upward of the area are deleted, the area shifts to the upward. + /// If is within the area, the height of the area decreases. + /// + /// + /// If the method returns false, there is a partial cover and it's up to you to + /// decide what to do. + /// + /// + /// The has a value null if the range was completely + /// removed by . + /// + internal bool TryDeleteAreaAndShiftUp(XLSheetRange deletedArea, out XLSheetRange? result) + { + // Deleted area is fully on left, right or bottom side of this area. + if (deletedArea.RightColumn < LeftColumn || + deletedArea.LeftColumn > RightColumn || + deletedArea.TopRow > BottomRow) + { + result = this; + return true; + } + + var doesntOverlapWidth = deletedArea.LeftColumn > LeftColumn || + deletedArea.RightColumn < RightColumn; + var deletesRowsAboveArea = deletedArea.TopRow < TopRow; + var deletesRowsOfArea = deletedArea.TopRow <= BottomRow && + deletedArea.BottomRow >= TopRow; + if (doesntOverlapWidth && (deletesRowsAboveArea || deletesRowsOfArea)) + { + result = null; + return false; + } + + var repositioned = this; + if (deletesRowsOfArea) + { + // Decrease height of repositioned area + var top = Math.Max(deletedArea.TopRow, repositioned.TopRow); + var bottom = Math.Min(deletedArea.BottomRow, repositioned.BottomRow); + + var rowsToDelete = bottom - top + 1; + var newHeight = repositioned.Height - rowsToDelete; + if (newHeight == 0) + { + result = null; + return true; + } + + repositioned = repositioned.SliceFromTop(newHeight); + } + + if (deletesRowsAboveArea) + { + // There are some deleted rows above the area -> shift up + var deletedLastRowAboveArea = Math.Min(repositioned.TopRow - 1, deletedArea.BottomRow); + + var shiftUp = deletedLastRowAboveArea - deletedArea.TopRow + 1; + repositioned = repositioned.ShiftRows(-shiftUp); + } + + result = repositioned; + return true; + } } } diff --git a/ClosedXML/Excel/Coordinates/XLSheetReference.cs b/ClosedXML/Excel/Coordinates/XLSheetReference.cs new file mode 100644 index 000000000..8ef9ff915 --- /dev/null +++ b/ClosedXML/Excel/Coordinates/XLSheetReference.cs @@ -0,0 +1,37 @@ +namespace ClosedXML.Excel; + +/// +/// that includes a sheet. It can represent cell +/// ('Sheet one'!A$1), area (Sheet1!A4:$G$5), row span ('Sales Q1'!4:10) +/// and col span (Sales!G:H). +/// +/// +/// Name of a sheet. Unescaped, so it doesn't include quotes. Note that sheet might not exist. +/// +/// +/// Referenced area in the sheet. Can be in A1 or R1C1. +/// +internal readonly record struct XLSheetReference(string Sheet, XLReference Reference) +{ + public bool Equals(XLSheetReference other) + { + return XLHelper.SheetComparer.Equals(Sheet, other.Sheet) && + Reference.Equals(other.Reference); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = 2122234362; + hashCode = hashCode * -1521134295 + XLHelper.SheetComparer.GetHashCode(Sheet); + hashCode = hashCode * -1521134295 + Reference.GetHashCode(); + return hashCode; + } + } + + internal string GetA1() + { + return Sheet.EscapeSheetName() + '!' + Reference.GetA1(); + } +} diff --git a/ClosedXML/Excel/CustomProperties/IXLCustomProperties.cs b/ClosedXML/Excel/CustomProperties/IXLCustomProperties.cs index 83e906a5c..b1332380f 100644 --- a/ClosedXML/Excel/CustomProperties/IXLCustomProperties.cs +++ b/ClosedXML/Excel/CustomProperties/IXLCustomProperties.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/CustomProperties/IXLCustomProperty.cs b/ClosedXML/Excel/CustomProperties/IXLCustomProperty.cs index cfed06ed9..5e008d9d0 100644 --- a/ClosedXML/Excel/CustomProperties/IXLCustomProperty.cs +++ b/ClosedXML/Excel/CustomProperties/IXLCustomProperty.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/CustomProperties/XLCustomProperties.cs b/ClosedXML/Excel/CustomProperties/XLCustomProperties.cs index 0322069c1..0824050c8 100644 --- a/ClosedXML/Excel/CustomProperties/XLCustomProperties.cs +++ b/ClosedXML/Excel/CustomProperties/XLCustomProperties.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/CustomProperties/XLCustomProperty.cs b/ClosedXML/Excel/CustomProperties/XLCustomProperty.cs index a1d4d19cd..58ea21a92 100644 --- a/ClosedXML/Excel/CustomProperties/XLCustomProperty.cs +++ b/ClosedXML/Excel/CustomProperties/XLCustomProperty.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Linq; @@ -37,13 +39,13 @@ public XLCustomPropertyType Type { if (Value is DateTime) return XLCustomPropertyType.Date; - + if (Value is Boolean) return XLCustomPropertyType.Boolean; - + if (Double.TryParse(Value.ToString(), out Double dTest)) return XLCustomPropertyType.Number; - + return XLCustomPropertyType.Text; } } diff --git a/ClosedXML/Excel/DataValidation/IXLDataValidation.cs b/ClosedXML/Excel/DataValidation/IXLDataValidation.cs index 05e176e02..fcb2551a6 100644 --- a/ClosedXML/Excel/DataValidation/IXLDataValidation.cs +++ b/ClosedXML/Excel/DataValidation/IXLDataValidation.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/DataValidation/IXLDataValidations.cs b/ClosedXML/Excel/DataValidation/IXLDataValidations.cs index 919d6883a..7aa76948e 100644 --- a/ClosedXML/Excel/DataValidation/IXLDataValidations.cs +++ b/ClosedXML/Excel/DataValidation/IXLDataValidations.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/DataValidation/IXLValidationCriteria.cs b/ClosedXML/Excel/DataValidation/IXLValidationCriteria.cs index e1328bd2a..b7a633886 100644 --- a/ClosedXML/Excel/DataValidation/IXLValidationCriteria.cs +++ b/ClosedXML/Excel/DataValidation/IXLValidationCriteria.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; @@ -7,58 +9,34 @@ public interface IXLValidationCriteria { void Between(String minValue, String maxValue); - [Obsolete("Use the overload accepting IXLCell")] - void Between(IXLRange minValue, IXLRange maxValue); - void Between(IXLCell minValue, IXLCell maxValue); void EqualOrGreaterThan(String value); - [Obsolete("Use the overload accepting IXLCell")] - void EqualOrGreaterThan(IXLRange range); - void EqualOrGreaterThan(IXLCell cell); void EqualOrLessThan(String value); - [Obsolete("Use the overload accepting IXLCell")] - void EqualOrLessThan(IXLRange range); - void EqualOrLessThan(IXLCell cell); void EqualTo(String value); - [Obsolete("Use the overload accepting IXLCell")] - void EqualTo(IXLRange range); - void EqualTo(IXLCell cell); void GreaterThan(String value); - [Obsolete("Use the overload accepting IXLCell")] - void GreaterThan(IXLRange range); - void GreaterThan(IXLCell cell); void LessThan(String value); - [Obsolete("Use the overload accepting IXLCell")] - void LessThan(IXLRange range); - void LessThan(IXLCell cell); void NotBetween(String minValue, String maxValue); - [Obsolete("Use the overload accepting IXLCell")] - void NotBetween(IXLRange minValue, IXLRange maxValue); - void NotBetween(IXLCell minValue, IXLCell maxValue); void NotEqualTo(String value); - [Obsolete("Use the overload accepting IXLCell")] - void NotEqualTo(IXLRange range); - void NotEqualTo(IXLCell cell); } } diff --git a/ClosedXML/Excel/DataValidation/XLDataValidation.cs b/ClosedXML/Excel/DataValidation/XLDataValidation.cs index 234164789..1996ab318 100644 --- a/ClosedXML/Excel/DataValidation/XLDataValidation.cs +++ b/ClosedXML/Excel/DataValidation/XLDataValidation.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/DataValidation/XLDataValidations.cs b/ClosedXML/Excel/DataValidation/XLDataValidations.cs index 14a92baef..a562be229 100644 --- a/ClosedXML/Excel/DataValidation/XLDataValidations.cs +++ b/ClosedXML/Excel/DataValidation/XLDataValidations.cs @@ -62,7 +62,7 @@ public void Delete(IXLDataValidation dataValidation) { if (!_dataValidations.Remove(dataValidation)) return; - var xlDataValidation = dataValidation as XLDataValidation; + var xlDataValidation = (XLDataValidation) dataValidation; xlDataValidation.RangeAdded -= OnRangeAdded; xlDataValidation.RangeRemoved -= OnRangeRemoved; @@ -116,7 +116,7 @@ IEnumerator IEnumerable.GetEnumerator() /// For example, if the rule is applied to ranges A1:A3,C1:C3 then this method will /// return True for ranges A1:A3, C1:C2, A2:A3, and False for ranges A1:C3, A1:C1, etc. /// True is the data validation rule was found, false otherwise. - public bool TryGet(IXLRangeAddress rangeAddress, out IXLDataValidation dataValidation) + public bool TryGet(IXLRangeAddress rangeAddress, out IXLDataValidation? dataValidation) { dataValidation = null; if (rangeAddress == null || !rangeAddress.IsValid) @@ -126,10 +126,11 @@ public bool TryGet(IXLRangeAddress rangeAddress, out IXLDataValidation dataValid .Where(c => c.RangeAddress.Contains(rangeAddress.FirstAddress) && c.RangeAddress.Contains(rangeAddress.LastAddress)); - if (!candidates.Any()) + var candidate = candidates.FirstOrDefault(); + if (candidate is null) return false; - dataValidation = candidates.First().DataValidation; + dataValidation = candidate.DataValidation; return true; } @@ -208,7 +209,7 @@ public void Consolidate() private void OnRangeAdded(object sender, RangeEventArgs e) { - ProcessRangeAdded(e.Range, sender as XLDataValidation, skipIntersectionCheck: false); + ProcessRangeAdded(e.Range, (XLDataValidation) sender, skipIntersectionCheck: false); } private void OnRangeRemoved(object sender, RangeEventArgs e) @@ -229,13 +230,9 @@ private void ProcessRangeAdded(IXLRange range, XLDataValidation dataValidation, private void ProcessRangeRemoved(IXLRange range) { - var entry = _dataValidationIndex.GetIntersectedRanges((XLRangeAddress)range.RangeAddress) - .SingleOrDefault(e => Equals(e.RangeAddress, range.RangeAddress)); - - if (entry != null) - { - _dataValidationIndex.Remove(entry.RangeAddress); - } + var entries = _dataValidationIndex.GetIntersectedRanges((XLRangeAddress)range.RangeAddress) + .Where(e => Equals(e.RangeAddress, range.RangeAddress)); + entries.ToArray().ForEach(entry => _dataValidationIndex.Remove(entry.RangeAddress)); } private void SplitExistingRanges(IXLRangeAddress rangeAddress) diff --git a/ClosedXML/Excel/DataValidation/XLDateCriteria.cs b/ClosedXML/Excel/DataValidation/XLDateCriteria.cs index d374e4fae..b7c7043a8 100644 --- a/ClosedXML/Excel/DataValidation/XLDateCriteria.cs +++ b/ClosedXML/Excel/DataValidation/XLDateCriteria.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; diff --git a/ClosedXML/Excel/DataValidation/XLDecimalCriteria.cs b/ClosedXML/Excel/DataValidation/XLDecimalCriteria.cs index f8ac16fa5..010b58d4f 100644 --- a/ClosedXML/Excel/DataValidation/XLDecimalCriteria.cs +++ b/ClosedXML/Excel/DataValidation/XLDecimalCriteria.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; diff --git a/ClosedXML/Excel/DataValidation/XLTextLengthCriteria.cs b/ClosedXML/Excel/DataValidation/XLTextLengthCriteria.cs index 6a0ce8a16..8d9dd3e20 100644 --- a/ClosedXML/Excel/DataValidation/XLTextLengthCriteria.cs +++ b/ClosedXML/Excel/DataValidation/XLTextLengthCriteria.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/DataValidation/XLTimeCriteria.cs b/ClosedXML/Excel/DataValidation/XLTimeCriteria.cs index 46031106f..fb9ef84da 100644 --- a/ClosedXML/Excel/DataValidation/XLTimeCriteria.cs +++ b/ClosedXML/Excel/DataValidation/XLTimeCriteria.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; diff --git a/ClosedXML/Excel/DataValidation/XLValidationCriteria.cs b/ClosedXML/Excel/DataValidation/XLValidationCriteria.cs index a18af3153..238c9dc8c 100644 --- a/ClosedXML/Excel/DataValidation/XLValidationCriteria.cs +++ b/ClosedXML/Excel/DataValidation/XLValidationCriteria.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; @@ -21,14 +23,6 @@ public void Between(String minValue, String maxValue) dataValidation.Operator = XLOperator.Between; } - [Obsolete("Use the overload accepting IXLCell")] - public void Between(IXLRange minValue, IXLRange maxValue) - { - dataValidation.MinValue = minValue.RangeAddress.ToStringFixed(); - dataValidation.MaxValue = maxValue.RangeAddress.ToStringFixed(); - dataValidation.Operator = XLOperator.Between; - } - public void Between(IXLCell minValue, IXLCell maxValue) { dataValidation.MinValue = minValue.Address.ToStringFixed(); @@ -42,13 +36,6 @@ public void EqualOrGreaterThan(String value) dataValidation.Operator = XLOperator.EqualOrGreaterThan; } - [Obsolete("Use the overload accepting IXLCell")] - public void EqualOrGreaterThan(IXLRange range) - { - dataValidation.Value = range.RangeAddress.ToStringFixed(); - dataValidation.Operator = XLOperator.EqualOrGreaterThan; - } - public void EqualOrGreaterThan(IXLCell cell) { dataValidation.Value = cell.Address.ToStringFixed(); @@ -61,13 +48,6 @@ public void EqualOrLessThan(String value) dataValidation.Operator = XLOperator.EqualOrLessThan; } - [Obsolete("Use the overload accepting IXLCell")] - public void EqualOrLessThan(IXLRange range) - { - dataValidation.Value = range.RangeAddress.ToStringFixed(); - dataValidation.Operator = XLOperator.EqualOrLessThan; - } - public void EqualOrLessThan(IXLCell cell) { dataValidation.Value = cell.Address.ToStringFixed(); @@ -80,13 +60,6 @@ public void EqualTo(String value) dataValidation.Operator = XLOperator.EqualTo; } - [Obsolete("Use the overload accepting IXLCell")] - public void EqualTo(IXLRange range) - { - dataValidation.Value = range.RangeAddress.ToStringFixed(); - dataValidation.Operator = XLOperator.EqualTo; - } - public void EqualTo(IXLCell cell) { dataValidation.Value = cell.Address.ToStringFixed(); @@ -99,13 +72,6 @@ public void GreaterThan(String value) dataValidation.Operator = XLOperator.GreaterThan; } - [Obsolete("Use the overload accepting IXLCell")] - public void GreaterThan(IXLRange range) - { - dataValidation.Value = range.RangeAddress.ToStringFixed(); - dataValidation.Operator = XLOperator.GreaterThan; - } - public void GreaterThan(IXLCell cell) { dataValidation.Value = cell.Address.ToStringFixed(); @@ -118,13 +84,6 @@ public void LessThan(String value) dataValidation.Operator = XLOperator.LessThan; } - [Obsolete("Use the overload accepting IXLCell")] - public void LessThan(IXLRange range) - { - dataValidation.Value = range.RangeAddress.ToStringFixed(); - dataValidation.Operator = XLOperator.LessThan; - } - public void LessThan(IXLCell cell) { dataValidation.Value = cell.Address.ToStringFixed(); @@ -138,14 +97,6 @@ public void NotBetween(String minValue, String maxValue) dataValidation.Operator = XLOperator.NotBetween; } - [Obsolete("Use the overload accepting IXLCell")] - public void NotBetween(IXLRange minValue, IXLRange maxValue) - { - dataValidation.MinValue = minValue.RangeAddress.ToStringFixed(); - dataValidation.MaxValue = maxValue.RangeAddress.ToStringFixed(); - dataValidation.Operator = XLOperator.NotBetween; - } - public void NotBetween(IXLCell minValue, IXLCell maxValue) { dataValidation.MinValue = minValue.Address.ToStringFixed(); @@ -159,13 +110,6 @@ public void NotEqualTo(String value) dataValidation.Operator = XLOperator.NotEqualTo; } - [Obsolete("Use the overload accepting IXLCell")] - public void NotEqualTo(IXLRange range) - { - dataValidation.Value = range.RangeAddress.ToStringFixed(); - dataValidation.Operator = XLOperator.NotEqualTo; - } - public void NotEqualTo(IXLCell cell) { dataValidation.Value = cell.Address.ToStringFixed(); diff --git a/ClosedXML/Excel/DataValidation/XLWholeNumberCriteria.cs b/ClosedXML/Excel/DataValidation/XLWholeNumberCriteria.cs index 2154f99e6..f49570d91 100644 --- a/ClosedXML/Excel/DataValidation/XLWholeNumberCriteria.cs +++ b/ClosedXML/Excel/DataValidation/XLWholeNumberCriteria.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/DataValidation/XLWholeNumberCriteriaBase.cs b/ClosedXML/Excel/DataValidation/XLWholeNumberCriteriaBase.cs index 1fa3458a4..ea2754013 100644 --- a/ClosedXML/Excel/DataValidation/XLWholeNumberCriteriaBase.cs +++ b/ClosedXML/Excel/DataValidation/XLWholeNumberCriteriaBase.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; diff --git a/ClosedXML/Excel/DefinedNames/IXLDefinedName.cs b/ClosedXML/Excel/DefinedNames/IXLDefinedName.cs new file mode 100644 index 000000000..2fb4962c7 --- /dev/null +++ b/ClosedXML/Excel/DefinedNames/IXLDefinedName.cs @@ -0,0 +1,106 @@ +using System; + +namespace ClosedXML.Excel +{ + /// + /// A scope of . It determines where can be defined name resolved. + /// + public enum XLNamedRangeScope + { + /// + /// Name is defined at the sheet level and is available only at the sheet + /// it is defined or collection or when referred + /// with sheet specifier (e.g. Sheet5!Name when name is scoped to Sheet5). + /// + Worksheet, + + /// + /// Name is defined at the workbook and is available everywhere. + /// + Workbook + } + + public interface IXLDefinedName + { + /// + /// Gets or sets the comment for this named range. + /// + /// + /// The comment for this named range. + /// + String? Comment { get; set; } + + /// + /// Checks if the named range contains invalid references (#REF!). + /// + /// Defined name with a formula SUM(#REF!A1, Sheet7!B4) would return + /// true, because #REF!A1 is an invalid reference. + /// + /// + bool IsValid { get; } + + /// + /// Gets or sets the name of the range. + /// + /// + /// The name of the range. + /// + /// Set value is not a valid name. + /// The name is colliding with a different name + /// that is already defined in the collection. + String Name { get; set; } + + /// + /// Gets the ranges associated with this named range. + /// Note: A named range can point to multiple ranges. + /// + IXLRanges Ranges { get; } + + /// + /// A formula of the named range. In most cases, name is just a range (e.g. + /// Sheet5!$A$4), but it can be a constant, lambda or other values. + /// The name formula can contain a bang reference (e.g. reference without + /// a sheet, but with exclamation mark !$A$5), but can't contain plain + /// local cell references (i.e. references without a sheet like A5). + /// + String RefersTo { get; set; } + + /// + /// Gets the scope of this named range. + /// + XLNamedRangeScope Scope { get; } + + /// + /// Gets or sets the visibility of this named range. + /// + /// + /// true if visible; otherwise, false. + /// + Boolean Visible { get; set; } + + /// + /// Copy sheet-scoped defined name to a different sheet. The references to the original + /// sheet are changed to refer to the : + /// + /// Cell ranges (Org!A1 will be New!A1). + /// Tables - if the target sheet contains a table of same size at same place as the original sheet. + /// Sheet-specified names (Org!Name will be New!Name, but the actual name won't be created). + /// + /// + /// Target sheet where to copy the defined name. + /// Defined name is workbook-scoped + /// Trying to copy defined name to the same sheet. + IXLDefinedName CopyTo(IXLWorksheet targetSheet); + + /// + /// Deletes this named range (not the cells). + /// + void Delete(); + + IXLDefinedName SetRefersTo(String formula); + + IXLDefinedName SetRefersTo(IXLRangeBase range); + + IXLDefinedName SetRefersTo(IXLRanges ranges); + } +} diff --git a/ClosedXML/Excel/DefinedNames/IXLDefinedNames.cs b/ClosedXML/Excel/DefinedNames/IXLDefinedNames.cs new file mode 100644 index 000000000..ab13b8a62 --- /dev/null +++ b/ClosedXML/Excel/DefinedNames/IXLDefinedNames.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace ClosedXML.Excel +{ + public interface IXLDefinedNames : IEnumerable + { + /// + [Obsolete($"Use {nameof(DefinedName)} instead.")] + IXLDefinedName NamedRange(String name); + + /// + /// Gets the specified defined name. + /// + /// Name identifier. + /// Name wasn't found. + IXLDefinedName DefinedName(String name); + + /// + /// Adds a new defined name. + /// + /// Name identifier to add. + /// The range address to add. + /// The name or address is invalid. + IXLDefinedName Add(String name, String rangeAddress); + + /// + /// Adds a new defined name. + /// + /// Name identifier to add. + /// The range to add. + /// The name is invalid. + IXLDefinedName Add(String name, IXLRange range); + + /// + /// Adds a new defined name. + /// + /// Name identifier to add. + /// The ranges to add. + /// The name is invalid. + IXLDefinedName Add(String name, IXLRanges ranges); + + /// + /// Adds a new defined name. + /// + /// Name identifier to add. + /// The range address to add. + /// The comment for the new named range. + /// The range name or address is invalid. + IXLDefinedName Add(String name, String rangeAddress, String? comment); + + /// + /// Adds a new defined name. + /// + /// Name identifier to add. + /// The range to add. + /// The comment for the new named range. + /// The range name is invalid. + IXLDefinedName Add(String name, IXLRange range, String? comment); + + /// + /// Adds a new defined name. + /// + /// Name identifier to add. + /// The ranges to add. + /// The comment for the new named range. + /// The range name is invalid. + IXLDefinedName Add(String name, IXLRanges ranges, String? comment); + + /// + /// Deletes the specified defined name. Deleting defined name doesn't delete referenced + /// cells. + /// + /// Name identifier to delete. + void Delete(String name); + + /// + /// Deletes the specified defined name's index. Deleting defined name doesn't delete + /// referenced cells. + /// + /// Index of the defined name to delete. + /// The index is outside of named ranges array. + void Delete(Int32 index); + + /// + /// Deletes all defined names of this collection, i.e. a workbook or a sheet. Deleting + /// defined name doesn't delete referenced cells. + /// + void DeleteAll(); + + Boolean TryGetValue(String name, [NotNullWhen(true)] out IXLDefinedName? range); + + Boolean Contains(String name); + + /// + /// Returns a subset of defined names that do not have invalid references. + /// + IEnumerable ValidNamedRanges(); + + /// + /// Returns a subset of defined names that do have invalid references. + /// + IEnumerable InvalidNamedRanges(); + } +} diff --git a/ClosedXML/Excel/DefinedNames/XLDefinedName.cs b/ClosedXML/Excel/DefinedNames/XLDefinedName.cs new file mode 100644 index 000000000..4065e4017 --- /dev/null +++ b/ClosedXML/Excel/DefinedNames/XLDefinedName.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using ClosedXML.Excel.CalcEngine.Visitors; +using ClosedXML.Parser; + +namespace ClosedXML.Excel; + +[DebuggerDisplay("{_name}:{_formula}")] +internal class XLDefinedName : IXLDefinedName, IWorkbookListener +{ + private readonly XLDefinedNames _container; + private String _name; + private String _formula = null!; + private FormulaReferences _references = null!; + + internal XLDefinedName(XLDefinedNames container, String name, Boolean validateName, String formula, String? comment) + { + // Excel accepts invalid names per grammar (e.g. `[Foo]Bar`) as a valid name and they can + // encountered in existing workbooks. We shouldn't throw exception on load. + if (validateName) + { + if (!XLHelper.ValidateName("named range", name, out var error)) + throw new ArgumentException(error, nameof(name)); + } + + _container = container; + _name = name; + RefersTo = formula; + Visible = true; + Comment = comment; + } + + public bool IsValid => !_references.ContainsRefError; + + public String Name + { + get => _name; + set + { + if (XLHelper.NameComparer.Equals(_name, value)) + return; + + if (!XLHelper.ValidateName("named range", value, out var error)) + throw new ArgumentException(error, nameof(value)); + + if (_container.Contains(value)) + throw new InvalidOperationException($"There is already a name '{value}'."); + + _container.Delete(_name); + _name = value; + _container.Add(_name, this); + } + } + + public IXLRanges Ranges => _references.GetExternalRanges(_container.Workbook, new XLSheetPoint(1, 1)); + + public String? Comment { get; set; } + + public Boolean Visible { get; set; } + + public XLNamedRangeScope Scope => _container.Scope; + + public String RefersTo + { + get => _formula; + set + { + if (value is null) + throw new ArgumentNullException(); + + var formula = value.TrimFormulaEqual(); + var references = FormulaReferences.ForFormula(formula); + if (references.References.Any()) + { + // `[MS-XLSX] 2.2.2.5: The formula MUST NOT use the local-cell-reference production + // rule.` Excel will refuse to load a workbook with such a defined name (e.g. `A1`). + // In theory, defined name should support bang references as a replacement for local + // references, but ClosedParser doesn't support it yet. + throw new ArgumentException($"Formula '{formula}' contains references without a sheet."); + } + + _references = references; + _formula = formula; + } + } + + IXLDefinedName IXLDefinedName.CopyTo(IXLWorksheet targetSheet) => CopyTo((XLWorksheet)targetSheet); + + void IXLDefinedName.Delete() => _container.Delete(Name); + + /// + /// Get sheet references found in the formula in A1. Doesn't return tables or name references, + /// only what has col/row coordinates. + /// + internal IReadOnlyList SheetReferencesList => _references.SheetReferences.Select(x => x.GetA1()).ToList(); + + internal XLDefinedName CopyTo(XLWorksheet targetSheet) + { + var sheet = _container.Worksheet; + if (targetSheet == sheet) + throw new InvalidOperationException("Cannot copy named range to the worksheet it already belongs to."); + + if (sheet is null) + throw new InvalidOperationException("Cannot copy workbook scoped defined name."); + + var targetTables = targetSheet.Tables.ToDictionary(x => x.SheetRange); + var tableRenames = new Dictionary(); + foreach (var table in sheet.Tables) + { + if (targetTables.TryGetValue(table.SheetRange, out var targetTable)) + { + tableRenames.Add(table.Name, targetTable.Name); + } + } + + var copiedFormula = FormulaConverter.ModifyA1(_formula, sheet.Name, 1, 1, new RenameRefModVisitor + { + Sheets = new Dictionary { { sheet.Name, targetSheet.Name } }, + Tables = tableRenames, + }); + var copiedName = new XLDefinedName(targetSheet.DefinedNames, Name, false, copiedFormula, Comment); + return targetSheet.DefinedNames.Add(Name, copiedName); + } + + public IXLDefinedName SetRefersTo(IXLRangeBase range) + { + return SetRefersTo(RangeToFixed(range)); + } + + public IXLDefinedName SetRefersTo(IXLRanges ranges) + { + var unionFormula = string.Join(",", ranges.Select(RangeToFixed)); + return SetRefersTo(unionFormula); + } + + public IXLDefinedName SetRefersTo(String formula) + { + RefersTo = formula; + return this; + } + + public override string ToString() + { + return _formula; + } + + internal void Add(String rangeAddress) + { + var byExclamation = rangeAddress.Split('!'); + var wsName = byExclamation[0].Replace("'", ""); + var rng = byExclamation[1]; + var rangeToAdd = _container.Workbook.WorksheetsInternal.Worksheet(wsName).Range(rng); + + var ranges = new XLRanges { rangeToAdd }; + RefersTo = _formula + "," + string.Join(",", ranges.Select(RangeToFixed)); + } + + void IWorkbookListener.OnSheetRenamed(string oldSheetName, string newSheetName) + { + RenameFormulaSheet(oldSheetName, newSheetName); + } + + internal void OnWorksheetDeleted(string worksheetName) + { + RenameFormulaSheet(worksheetName, null); + } + + private void RenameFormulaSheet(string oldSheetName, string? newSheetName) + { + if (!_references.ContainsSheet(oldSheetName)) + return; + + var modified = FormulaConverter.ModifyA1(_formula, newSheetName ?? string.Empty, 1, 1, new RenameRefModVisitor + { + Sheets = new Dictionary { { oldSheetName, newSheetName } } + }); + + RefersTo = modified; + } + + private static string RangeToFixed(IXLRangeBase range) + { + return range.RangeAddress.ToStringFixed(XLReferenceStyle.A1, true); + } +} diff --git a/ClosedXML/Excel/NamedRanges/XLNamedRanges.cs b/ClosedXML/Excel/DefinedNames/XLDefinedNames.cs similarity index 52% rename from ClosedXML/Excel/NamedRanges/XLNamedRanges.cs rename to ClosedXML/Excel/DefinedNames/XLDefinedNames.cs index 99b88f84d..8763399b1 100644 --- a/ClosedXML/Excel/NamedRanges/XLNamedRanges.cs +++ b/ClosedXML/Excel/DefinedNames/XLDefinedNames.cs @@ -1,27 +1,31 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; namespace ClosedXML.Excel { - internal class XLNamedRanges : IXLNamedRanges + /// + /// A collection of a named ranges, either for workbook or for worksheet. + /// + internal class XLDefinedNames : IXLDefinedNames, IEnumerable { - private readonly Dictionary _namedRanges = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _namedRanges = new(XLHelper.NameComparer); internal XLWorkbook Workbook { get; set; } - internal XLWorksheet Worksheet { get; set; } + internal XLWorksheet? Worksheet { get; set; } internal XLNamedRangeScope Scope { get; } - public XLNamedRanges(XLWorksheet worksheet) + public XLDefinedNames(XLWorksheet worksheet) : this(worksheet.Workbook) { Worksheet = worksheet; Scope = XLNamedRangeScope.Worksheet; } - public XLNamedRanges(XLWorkbook workbook) + public XLDefinedNames(XLWorkbook workbook) { Workbook = workbook; Scope = XLNamedRangeScope.Workbook; @@ -29,48 +33,52 @@ public XLNamedRanges(XLWorkbook workbook) #region IXLNamedRanges Members - public IXLNamedRange NamedRange(String rangeName) + [Obsolete] + IXLDefinedName IXLDefinedNames.NamedRange(String name) => DefinedName(name); + + IXLDefinedName IXLDefinedNames.DefinedName(String name) => DefinedName(name); + + internal XLDefinedName DefinedName(String name) { - if (_namedRanges.TryGetValue(rangeName, out IXLNamedRange range)) + if (_namedRanges.TryGetValue(name, out XLDefinedName range)) return range; - return null; + throw new KeyNotFoundException($"Name {name} not found."); } - public IXLNamedRange Add(String rangeName, String rangeAddress) + public IXLDefinedName Add(String name, String rangeAddress) { - return Add(rangeName, rangeAddress, null); + return Add(name, rangeAddress, null); } - public IXLNamedRange Add(String rangeName, IXLRange range) + public IXLDefinedName Add(String name, IXLRange range) { - return Add(rangeName, range, null); + return Add(name, range, null); } - public IXLNamedRange Add(String rangeName, IXLRanges ranges) + public IXLDefinedName Add(String name, IXLRanges ranges) { - return Add(rangeName, ranges, null); + return Add(name, ranges, null); } - public IXLNamedRange Add(String rangeName, String rangeAddress, String comment) + public IXLDefinedName Add(String name, String rangeAddress, String? comment) { - return Add(rangeName, rangeAddress, comment, validateName: true, validateRangeAddress: true); + return Add(name, rangeAddress, comment, validateName: true, validateRangeAddress: true); } /// /// Adds the specified range name. /// - /// Name of the range. + /// Name of the range. /// The range address. /// The comment. /// if set to true validates the name. /// if set to true range address will be checked for validity. - /// /// /// /// For named ranges in the workbook scope, specify the sheet name in the reference. /// - internal IXLNamedRange Add(String rangeName, String rangeAddress, String comment, Boolean validateName, Boolean validateRangeAddress) + internal IXLDefinedName Add(String name, String rangeAddress, String? comment, Boolean validateName, Boolean validateRangeAddress) { // When loading named ranges from an existing file, we do not validate the range address or name. if (validateRangeAddress) @@ -81,9 +89,9 @@ internal IXLNamedRange Add(String rangeName, String rangeAddress, String comment { if (XLHelper.IsValidRangeAddress(rangeAddress)) { - IXLRange range = null; + IXLRange? range; if (Scope == XLNamedRangeScope.Worksheet) - range = Worksheet.Range(rangeAddress); + range = Worksheet!.Range(rangeAddress); else if (Scope == XLNamedRangeScope.Workbook) range = Workbook.Range(rangeAddress); else @@ -92,38 +100,39 @@ internal IXLNamedRange Add(String rangeName, String rangeAddress, String comment if (range == null) throw new ArgumentException(string.Format( "The range address '{0}' for the named range '{1}' is not a valid range.", rangeAddress, - rangeName)); + name)); if (Scope == XLNamedRangeScope.Workbook || !XLHelper.NamedRangeReferenceRegex.Match(range.ToString()).Success) throw new ArgumentException( "For named ranges in the workbook scope, specify the sheet name in the reference."); - rangeAddress = Worksheet.Range(rangeAddress).ToString(); + rangeAddress = range.ToString(); } } } - var namedRange = new XLNamedRange(this, rangeName, validateName, rangeAddress, comment); - _namedRanges.Add(rangeName, namedRange); + var namedRange = new XLDefinedName(this, name, validateName, rangeAddress, comment); + _namedRanges.Add(name, namedRange); return namedRange; } - public IXLNamedRange Add(String rangeName, IXLRange range, String comment) + public IXLDefinedName Add(String name, IXLRange range, String? comment) { var ranges = new XLRanges { range }; - return Add(rangeName, ranges, comment); + return Add(name, ranges, comment); } - public IXLNamedRange Add(String rangeName, IXLRanges ranges, String comment) + public IXLDefinedName Add(String name, IXLRanges ranges, String? comment) { - var namedRange = new XLNamedRange(this, rangeName, ranges, comment); - _namedRanges.Add(rangeName, namedRange); + var formula = string.Join(",", ranges.Select(r => r.RangeAddress.ToStringFixed(XLReferenceStyle.A1, true))); + var namedRange = new XLDefinedName(this, name, true, formula, comment); + _namedRanges.Add(name, namedRange); return namedRange; } - public IXLNamedRange Add(String rangeName, IXLNamedRange namedRange) + internal XLDefinedName Add(String name, XLDefinedName namedRange) { - _namedRanges.Add(rangeName, namedRange); + _namedRanges.Add(name, namedRange); return namedRange; } @@ -145,30 +154,30 @@ public void DeleteAll() /// /// Returns a subset of named ranges that do not have invalid references. /// - public IEnumerable ValidNamedRanges() + public IEnumerable ValidNamedRanges() { - return this.Where(nr => nr.IsValid); + return _namedRanges.Values.Where(nr => nr.IsValid); } /// /// Returns a subset of named ranges that do have invalid references. /// - public IEnumerable InvalidNamedRanges() + public IEnumerable InvalidNamedRanges() { - return this.Where(nr => !nr.IsValid); + return _namedRanges.Values.Where(nr => !nr.IsValid); } #endregion IXLNamedRanges Members - #region IEnumerable Members + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - public IEnumerator GetEnumerator() + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public Dictionary.ValueCollection.Enumerator GetEnumerator() { return _namedRanges.Values.GetEnumerator(); } - #endregion IEnumerable Members - #region IEnumerable Members System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() @@ -178,14 +187,29 @@ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() #endregion IEnumerable Members - public Boolean TryGetValue(String name, out IXLNamedRange range) + public Boolean TryGetValue(String name, [NotNullWhen(true)] out IXLDefinedName? definedName) { - if (_namedRanges.TryGetValue(name, out range)) return true; + if (TryGetScopedValue(name, out var sheetDefinedName)) + { + definedName = sheetDefinedName; + return true; + } - if (Scope == XLNamedRangeScope.Workbook) - range = Workbook.NamedRange(name); + definedName = Scope == XLNamedRangeScope.Workbook + ? Workbook.DefinedName(name) + : null; + + return definedName is not null; + } + + internal Boolean TryGetScopedValue(String name, [NotNullWhen(true)] out XLDefinedName? definedName) + { + if (_namedRanges.TryGetValue(name, out definedName)) + { + return true; + } - return range != null; + return false; } public Boolean Contains(String name) @@ -193,7 +217,7 @@ public Boolean Contains(String name) if (_namedRanges.ContainsKey(name)) return true; if (Scope == XLNamedRangeScope.Workbook) - return Workbook.NamedRange(name) != null; + return Workbook.DefinedName(name) is not null; else return false; } @@ -201,7 +225,6 @@ public Boolean Contains(String name) internal void OnWorksheetDeleted(string worksheetName) { _namedRanges.Values - .Cast() .ForEach(nr => nr.OnWorksheetDeleted(worksheetName)); } } diff --git a/ClosedXML/Excel/Drawings/IXLDrawing.cs b/ClosedXML/Excel/Drawings/IXLDrawing.cs index c00631532..989d96749 100644 --- a/ClosedXML/Excel/Drawings/IXLDrawing.cs +++ b/ClosedXML/Excel/Drawings/IXLDrawing.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Drawings/IXLDrawingPosition.cs b/ClosedXML/Excel/Drawings/IXLDrawingPosition.cs index 2b3322918..1cd42ab24 100644 --- a/ClosedXML/Excel/Drawings/IXLDrawingPosition.cs +++ b/ClosedXML/Excel/Drawings/IXLDrawingPosition.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/IXLPicture.cs b/ClosedXML/Excel/Drawings/IXLPicture.cs index db2380bf3..d0b891a90 100644 --- a/ClosedXML/Excel/Drawings/IXLPicture.cs +++ b/ClosedXML/Excel/Drawings/IXLPicture.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Drawing; diff --git a/ClosedXML/Excel/Drawings/IXLPictures.cs b/ClosedXML/Excel/Drawings/IXLPictures.cs index 12ce16b6e..312b84182 100644 --- a/ClosedXML/Excel/Drawings/IXLPictures.cs +++ b/ClosedXML/Excel/Drawings/IXLPictures.cs @@ -1,6 +1,7 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; -using System.Drawing; using System.IO; namespace ClosedXML.Excel.Drawings diff --git a/ClosedXML/Excel/Drawings/PictureEnums.cs b/ClosedXML/Excel/Drawings/PictureEnums.cs index 8c2478309..49d580b1d 100644 --- a/ClosedXML/Excel/Drawings/PictureEnums.cs +++ b/ClosedXML/Excel/Drawings/PictureEnums.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace ClosedXML.Excel.Drawings { public enum XLMarkerPosition @@ -17,7 +19,8 @@ public enum XLPictureFormat Pcx, Jpeg, Emf, - Wmf + Wmf, + Webp } public enum XLPicturePlacement diff --git a/ClosedXML/Excel/Drawings/Style/IXLDrawingAlignment.cs b/ClosedXML/Excel/Drawings/Style/IXLDrawingAlignment.cs index eb68bbb2f..17a2f681e 100644 --- a/ClosedXML/Excel/Drawings/Style/IXLDrawingAlignment.cs +++ b/ClosedXML/Excel/Drawings/Style/IXLDrawingAlignment.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/IXLDrawingColorsAndLines.cs b/ClosedXML/Excel/Drawings/Style/IXLDrawingColorsAndLines.cs index 72a6fec3b..d718f40d7 100644 --- a/ClosedXML/Excel/Drawings/Style/IXLDrawingColorsAndLines.cs +++ b/ClosedXML/Excel/Drawings/Style/IXLDrawingColorsAndLines.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/IXLDrawingFont.cs b/ClosedXML/Excel/Drawings/Style/IXLDrawingFont.cs index 878f23b3c..539accc7e 100644 --- a/ClosedXML/Excel/Drawings/Style/IXLDrawingFont.cs +++ b/ClosedXML/Excel/Drawings/Style/IXLDrawingFont.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { @@ -18,5 +17,6 @@ public interface IXLDrawingFont : IXLFontBase IXLDrawingStyle SetFontName(String value); IXLDrawingStyle SetFontFamilyNumbering(XLFontFamilyNumberingValues value); IXLDrawingStyle SetFontCharSet(XLFontCharSet value); + IXLDrawingStyle SetFontScheme(XLFontScheme value); } } diff --git a/ClosedXML/Excel/Drawings/Style/IXLDrawingMargins.cs b/ClosedXML/Excel/Drawings/Style/IXLDrawingMargins.cs index 759cd44c3..4a5b77810 100644 --- a/ClosedXML/Excel/Drawings/Style/IXLDrawingMargins.cs +++ b/ClosedXML/Excel/Drawings/Style/IXLDrawingMargins.cs @@ -1,17 +1,34 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { public interface IXLDrawingMargins { Boolean Automatic { get; set; } + + /// + /// Left margin in inches. + /// Double Left { get; set; } + + /// + /// Right margin in inches. + /// Double Right { get; set; } + + /// + /// Top margin in inches. + /// Double Top { get; set; } + + /// + /// Bottom margin in inches. + /// Double Bottom { get; set; } + + /// + /// Set , , , margins at once. + /// Double All { set; } IXLDrawingStyle SetAutomatic(); IXLDrawingStyle SetAutomatic(Boolean value); diff --git a/ClosedXML/Excel/Drawings/Style/IXLDrawingProperties.cs b/ClosedXML/Excel/Drawings/Style/IXLDrawingProperties.cs index 615794646..c42c58d58 100644 --- a/ClosedXML/Excel/Drawings/Style/IXLDrawingProperties.cs +++ b/ClosedXML/Excel/Drawings/Style/IXLDrawingProperties.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +#nullable disable namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/IXLDrawingProtection.cs b/ClosedXML/Excel/Drawings/Style/IXLDrawingProtection.cs index 36f896b6a..03f923379 100644 --- a/ClosedXML/Excel/Drawings/Style/IXLDrawingProtection.cs +++ b/ClosedXML/Excel/Drawings/Style/IXLDrawingProtection.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/IXLDrawingSize.cs b/ClosedXML/Excel/Drawings/Style/IXLDrawingSize.cs index 5b83d320c..ae2265a81 100644 --- a/ClosedXML/Excel/Drawings/Style/IXLDrawingSize.cs +++ b/ClosedXML/Excel/Drawings/Style/IXLDrawingSize.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/IXLDrawingStyle.cs b/ClosedXML/Excel/Drawings/Style/IXLDrawingStyle.cs index 9eed6a8de..19b05aaf9 100644 --- a/ClosedXML/Excel/Drawings/Style/IXLDrawingStyle.cs +++ b/ClosedXML/Excel/Drawings/Style/IXLDrawingStyle.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +#nullable disable namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/IXLDrawingWeb.cs b/ClosedXML/Excel/Drawings/Style/IXLDrawingWeb.cs index 00324881a..69576a6dc 100644 --- a/ClosedXML/Excel/Drawings/Style/IXLDrawingWeb.cs +++ b/ClosedXML/Excel/Drawings/Style/IXLDrawingWeb.cs @@ -1,14 +1,10 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { public interface IXLDrawingWeb { - String AlternateText { get; set; } - IXLDrawingStyle SetAlternateText(String value); - + String? AlternateText { get; set; } + IXLDrawingStyle SetAlternateText(String? value); } } diff --git a/ClosedXML/Excel/Drawings/Style/XLDrawingAlignment.cs b/ClosedXML/Excel/Drawings/Style/XLDrawingAlignment.cs index 3da9f89fa..d693c7594 100644 --- a/ClosedXML/Excel/Drawings/Style/XLDrawingAlignment.cs +++ b/ClosedXML/Excel/Drawings/Style/XLDrawingAlignment.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/XLDrawingColorsAndLines.cs b/ClosedXML/Excel/Drawings/Style/XLDrawingColorsAndLines.cs index ec8e4dbe7..37b3a2b0d 100644 --- a/ClosedXML/Excel/Drawings/Style/XLDrawingColorsAndLines.cs +++ b/ClosedXML/Excel/Drawings/Style/XLDrawingColorsAndLines.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/XLDrawingFont.cs b/ClosedXML/Excel/Drawings/Style/XLDrawingFont.cs index 744d7817d..bf5448d2d 100644 --- a/ClosedXML/Excel/Drawings/Style/XLDrawingFont.cs +++ b/ClosedXML/Excel/Drawings/Style/XLDrawingFont.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -25,8 +27,8 @@ public XLDrawingFont(IXLDrawingStyle style) public XLColor FontColor { get; set; } public String FontName { get; set; } public XLFontFamilyNumberingValues FontFamilyNumbering { get; set; } - public XLFontCharSet FontCharSet { get; set; } + public XLFontScheme FontScheme { get; set; } public IXLDrawingStyle SetBold() { @@ -123,5 +125,11 @@ public IXLDrawingStyle SetFontCharSet(XLFontCharSet value) FontCharSet = value; return _style; } + + public IXLDrawingStyle SetFontScheme(XLFontScheme value) + { + FontScheme = value; + return _style; + } } } diff --git a/ClosedXML/Excel/Drawings/Style/XLDrawingMargins.cs b/ClosedXML/Excel/Drawings/Style/XLDrawingMargins.cs index 650f7a13c..af4f39ab4 100644 --- a/ClosedXML/Excel/Drawings/Style/XLDrawingMargins.cs +++ b/ClosedXML/Excel/Drawings/Style/XLDrawingMargins.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/XLDrawingProperties.cs b/ClosedXML/Excel/Drawings/Style/XLDrawingProperties.cs index a70276206..6e135f3be 100644 --- a/ClosedXML/Excel/Drawings/Style/XLDrawingProperties.cs +++ b/ClosedXML/Excel/Drawings/Style/XLDrawingProperties.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +#nullable disable namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/XLDrawingProtection.cs b/ClosedXML/Excel/Drawings/Style/XLDrawingProtection.cs index 8d0c57042..feaed3eee 100644 --- a/ClosedXML/Excel/Drawings/Style/XLDrawingProtection.cs +++ b/ClosedXML/Excel/Drawings/Style/XLDrawingProtection.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/XLDrawingSize.cs b/ClosedXML/Excel/Drawings/Style/XLDrawingSize.cs index 84f4f1b1d..b346c54ad 100644 --- a/ClosedXML/Excel/Drawings/Style/XLDrawingSize.cs +++ b/ClosedXML/Excel/Drawings/Style/XLDrawingSize.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/Style/XLDrawingStyle.cs b/ClosedXML/Excel/Drawings/Style/XLDrawingStyle.cs index 6fd952ac9..71f133f4c 100644 --- a/ClosedXML/Excel/Drawings/Style/XLDrawingStyle.cs +++ b/ClosedXML/Excel/Drawings/Style/XLDrawingStyle.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace ClosedXML.Excel { internal class XLDrawingStyle : IXLDrawingStyle diff --git a/ClosedXML/Excel/Drawings/Style/XLDrawingWeb.cs b/ClosedXML/Excel/Drawings/Style/XLDrawingWeb.cs index f47cb3bc8..721acb332 100644 --- a/ClosedXML/Excel/Drawings/Style/XLDrawingWeb.cs +++ b/ClosedXML/Excel/Drawings/Style/XLDrawingWeb.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { @@ -13,7 +10,9 @@ public XLDrawingWeb(IXLDrawingStyle style) { _style = style; } - public String AlternateText { get; set; } public IXLDrawingStyle SetAlternateText(String value) { AlternateText = value; return _style; } + public String? AlternateText { get; set; } + + public IXLDrawingStyle SetAlternateText(String? value) { AlternateText = value; return _style; } } } diff --git a/ClosedXML/Excel/Drawings/XLDrawing.cs b/ClosedXML/Excel/Drawings/XLDrawing.cs index c3e3a83d0..c073bfd94 100644 --- a/ClosedXML/Excel/Drawings/XLDrawing.cs +++ b/ClosedXML/Excel/Drawings/XLDrawing.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Drawings/XLDrawingPosition.cs b/ClosedXML/Excel/Drawings/XLDrawingPosition.cs index b7309f288..220ced8e0 100644 --- a/ClosedXML/Excel/Drawings/XLDrawingPosition.cs +++ b/ClosedXML/Excel/Drawings/XLDrawingPosition.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Drawings/XLMarker.cs b/ClosedXML/Excel/Drawings/XLMarker.cs index 57d496ab0..98dff28c6 100644 --- a/ClosedXML/Excel/Drawings/XLMarker.cs +++ b/ClosedXML/Excel/Drawings/XLMarker.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Diagnostics; diff --git a/ClosedXML/Excel/Drawings/XLPicture.cs b/ClosedXML/Excel/Drawings/XLPicture.cs index 27d101920..b851233f2 100644 --- a/ClosedXML/Excel/Drawings/XLPicture.cs +++ b/ClosedXML/Excel/Drawings/XLPicture.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; @@ -49,10 +51,8 @@ private XLPicture(IXLWorksheet worksheet) // Calculate default picture ID var allPictures = worksheet.Workbook.Worksheets.SelectMany(ws => ws.Pictures); - if (allPictures.Any()) - this._id = allPictures.Max(p => p.Id) + 1; - else - this._id = 1; + var freeId = allPictures.Select(x => x.Id).DefaultIfEmpty(0).Max() + 1; + _id = freeId; } public IXLCell BottomRightCell diff --git a/ClosedXML/Excel/Drawings/XLPictures.cs b/ClosedXML/Excel/Drawings/XLPictures.cs index 54096fd4b..453352a01 100644 --- a/ClosedXML/Excel/Drawings/XLPictures.cs +++ b/ClosedXML/Excel/Drawings/XLPictures.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; @@ -119,18 +119,18 @@ IEnumerator IEnumerable.GetEnumerator() public IXLPicture Picture(string pictureName) { - if (TryGetPicture(pictureName, out IXLPicture p)) - return p; + if (TryGetPicture(pictureName, out IXLPicture? p)) + return p!; throw new ArgumentOutOfRangeException(nameof(pictureName), $"Picture {pictureName} was not found."); } - public bool TryGetPicture(string pictureName, out IXLPicture picture) + public bool TryGetPicture(string pictureName, out IXLPicture? picture) { - var matches = _pictures.Where(p => p.Name.Equals(pictureName, StringComparison.OrdinalIgnoreCase)); - if (matches.Any()) + var match = _pictures.FirstOrDefault(p => p.Name.Equals(pictureName, StringComparison.OrdinalIgnoreCase)); + if (match is not null) { - picture = matches.First(); + picture = match; return true; } picture = null; @@ -139,7 +139,7 @@ public bool TryGetPicture(string pictureName, out IXLPicture picture) internal IXLPicture Add(Stream stream, string name, int Id) { - var picture = Add(stream) as XLPicture; + var picture = (XLPicture)Add(stream); picture.SetName(name); picture.Id = Id; return picture; diff --git a/ClosedXML/Excel/EnumConverter.cs b/ClosedXML/Excel/EnumConverter.cs index 31b1414bb..7b2691db5 100644 --- a/ClosedXML/Excel/EnumConverter.cs +++ b/ClosedXML/Excel/EnumConverter.cs @@ -1,7 +1,10 @@ +#nullable disable + using ClosedXML.Excel.Drawings; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; using System; +using System.Collections.Generic; using Vml = DocumentFormat.OpenXml.Vml; using X14 = DocumentFormat.OpenXml.Office2010.Excel; using Xdr = DocumentFormat.OpenXml.Drawing.Spreadsheet; @@ -36,6 +39,18 @@ public static UnderlineValues ToOpenXml(this XLFontUnderlineValues value) } } + private static readonly String[] XLFontUnderlineValuesStrings = + { + "double", + "doubleAccounting", + "none", + "single", + "singleAccounting" + }; + + public static string ToOpenXmlString(this XLFontUnderlineValues value) + => XLFontUnderlineValuesStrings[(int)value]; + public static OrientationValues ToOpenXml(this XLPageOrientation value) { switch (value) @@ -72,6 +87,37 @@ public static VerticalAlignmentRunValues ToOpenXml(this XLFontVerticalTextAlignm } } + private static readonly String[] XLFontVerticalTextAlignmentValuesStrings = + { + "baseline", + "subscript", + "superscript" + }; + + public static String ToOpenXmlString(this XLFontVerticalTextAlignmentValues value) + => XLFontVerticalTextAlignmentValuesStrings[(int)value]; + + private static readonly String[] XLFontSchemeStrings = + { + "none", + "major", + "minor" + }; + + public static String ToOpenXml(this XLFontScheme value) + => XLFontSchemeStrings[(int)value]; + + public static FontSchemeValues ToOpenXmlEnum(this XLFontScheme value) + { + return value switch + { + XLFontScheme.None => FontSchemeValues.None, + XLFontScheme.Major => FontSchemeValues.Major, + XLFontScheme.Minor => FontSchemeValues.Minor, + _ => throw new ArgumentOutOfRangeException() + }; + } + public static PatternValues ToOpenXml(this XLFillPatternValues value) { switch (value) @@ -492,26 +538,16 @@ public static SheetStateValues ToOpenXml(this XLWorksheetVisibility value) } } - public static PhoneticAlignmentValues ToOpenXml(this XLPhoneticAlignment value) + private static readonly String[] XLPhoneticAlignmentStrings = { - switch (value) - { - case XLPhoneticAlignment.Center: - return PhoneticAlignmentValues.Center; - - case XLPhoneticAlignment.Distributed: - return PhoneticAlignmentValues.Distributed; + "center", + "distributed", + "left", + "noControl" + }; - case XLPhoneticAlignment.Left: - return PhoneticAlignmentValues.Left; - - case XLPhoneticAlignment.NoControl: - return PhoneticAlignmentValues.NoControl; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } - } + public static String ToOpenXmlString(this XLPhoneticAlignment value) + => XLPhoneticAlignmentStrings[(int)value]; public static PhoneticValues ToOpenXml(this XLPhoneticType value) { @@ -534,6 +570,17 @@ public static PhoneticValues ToOpenXml(this XLPhoneticType value) } } + private static readonly String[] XLPhoneticTypeStrings = + { + "fullwidthKatakana", + "halfwidthKatakana", + "Hiragana", + "noConversion" + }; + + public static String ToOpenXmlString(this XLPhoneticType value) + => XLPhoneticTypeStrings[(int)value]; + public static DataConsolidateFunctionValues ToOpenXml(this XLPivotSummary value) { switch (value) @@ -735,7 +782,7 @@ public static IconSetValues ToOpenXml(this XLIconSetStyle value) case XLIconSetStyle.FiveQuarters: return IconSetValues.FiveQuarters; default: - throw new ArgumentOutOfRangeException("Not implemented value!"); + throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); } } @@ -759,9 +806,25 @@ public static TimePeriodValues ToOpenXml(this XLTimePeriod value) } } - public static ImagePartType ToOpenXml(this XLPictureFormat value) + private static readonly IReadOnlyDictionary PictureFormatMap = + new Dictionary + { + { XLPictureFormat.Unknown, new PartTypeInfo("image/unknown", ".bin") }, + { XLPictureFormat.Bmp, ImagePartType.Bmp }, + { XLPictureFormat.Gif, ImagePartType.Gif }, + { XLPictureFormat.Png, ImagePartType.Png }, + { XLPictureFormat.Tiff, ImagePartType.Tiff }, + { XLPictureFormat.Icon, ImagePartType.Icon }, + { XLPictureFormat.Pcx, ImagePartType.Pcx }, + { XLPictureFormat.Jpeg, ImagePartType.Jpeg }, + { XLPictureFormat.Emf, ImagePartType.Emf }, + { XLPictureFormat.Wmf, ImagePartType.Wmf }, + { XLPictureFormat.Webp, new PartTypeInfo("image/webp", ".webp") } + }; + + public static PartTypeInfo ToOpenXml(this XLPictureFormat value) { - return Enum.Parse(typeof(ImagePartType), value.ToString()).CastTo(); + return PictureFormatMap[value]; } public static Xdr.EditAsValues ToOpenXml(this XLPicturePlacement value) @@ -778,36 +841,36 @@ public static Xdr.EditAsValues ToOpenXml(this XLPicturePlacement value) return Xdr.EditAsValues.TwoCell; default: - throw new ArgumentOutOfRangeException("Not implemented value!"); + throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); } } - public static PivotAreaValues ToOpenXml(this XLPivotAreaValues value) + public static PivotAreaValues ToOpenXml(this XLPivotAreaType value) { switch (value) { - case XLPivotAreaValues.None: + case XLPivotAreaType.None: return PivotAreaValues.None; - case XLPivotAreaValues.Normal: + case XLPivotAreaType.Normal: return PivotAreaValues.Normal; - case XLPivotAreaValues.Data: + case XLPivotAreaType.Data: return PivotAreaValues.Data; - case XLPivotAreaValues.All: + case XLPivotAreaType.All: return PivotAreaValues.All; - case XLPivotAreaValues.Origin: + case XLPivotAreaType.Origin: return PivotAreaValues.Origin; - case XLPivotAreaValues.Button: + case XLPivotAreaType.Button: return PivotAreaValues.Button; - case XLPivotAreaValues.TopRight: + case XLPivotAreaType.TopRight: return PivotAreaValues.TopRight; - case XLPivotAreaValues.TopEnd: + case XLPivotAreaType.TopEnd: return PivotAreaValues.TopEnd; default: @@ -854,329 +917,226 @@ public static X14.DisplayBlanksAsValues ToOpenXml(this XLDisplayBlanksAsValues v } } - #endregion To OpenXml - - #region To ClosedXml - - public static XLFontUnderlineValues ToClosedXml(this UnderlineValues value) + public static FieldSortValues ToOpenXml(this XLPivotSortType value) { switch (value) { - case UnderlineValues.Double: - return XLFontUnderlineValues.Double; - - case UnderlineValues.DoubleAccounting: - return XLFontUnderlineValues.DoubleAccounting; - - case UnderlineValues.None: - return XLFontUnderlineValues.None; - - case UnderlineValues.Single: - return XLFontUnderlineValues.Single; - - case UnderlineValues.SingleAccounting: - return XLFontUnderlineValues.SingleAccounting; + case XLPivotSortType.Default: return FieldSortValues.Manual; + case XLPivotSortType.Ascending: return FieldSortValues.Ascending; + case XLPivotSortType.Descending: return FieldSortValues.Descending; default: throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); } } - public static XLPageOrientation ToClosedXml(this OrientationValues value) - { - switch (value) - { - case OrientationValues.Default: - return XLPageOrientation.Default; + #endregion To OpenXml - case OrientationValues.Landscape: - return XLPageOrientation.Landscape; + #region To ClosedXml - case OrientationValues.Portrait: - return XLPageOrientation.Portrait; + private static readonly IReadOnlyDictionary UnderlineValuesMap = + new Dictionary + { + { UnderlineValues.Double, XLFontUnderlineValues.Double }, + { UnderlineValues.DoubleAccounting, XLFontUnderlineValues.DoubleAccounting }, + { UnderlineValues.None, XLFontUnderlineValues.None }, + { UnderlineValues.Single, XLFontUnderlineValues.Single }, + { UnderlineValues.SingleAccounting, XLFontUnderlineValues.SingleAccounting }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLFontUnderlineValues ToClosedXml(this UnderlineValues value) + { + return UnderlineValuesMap[value]; } - public static XLFontVerticalTextAlignmentValues ToClosedXml(this VerticalAlignmentRunValues value) - { - switch (value) + private static readonly IReadOnlyDictionary FontSchemeMap = + new Dictionary { - case VerticalAlignmentRunValues.Baseline: - return XLFontVerticalTextAlignmentValues.Baseline; - - case VerticalAlignmentRunValues.Subscript: - return XLFontVerticalTextAlignmentValues.Subscript; - - case VerticalAlignmentRunValues.Superscript: - return XLFontVerticalTextAlignmentValues.Superscript; + { FontSchemeValues.None, XLFontScheme.None }, + { FontSchemeValues.Major, XLFontScheme.Major }, + { FontSchemeValues.Minor, XLFontScheme.Minor }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLFontScheme ToClosedXml(this FontSchemeValues value) + { + return FontSchemeMap[value]; } - public static XLFillPatternValues ToClosedXml(this PatternValues value) - { - switch (value) + private static readonly IReadOnlyDictionary OrientationMap = + new Dictionary { - case PatternValues.DarkDown: - return XLFillPatternValues.DarkDown; - - case PatternValues.DarkGray: - return XLFillPatternValues.DarkGray; - - case PatternValues.DarkGrid: - return XLFillPatternValues.DarkGrid; - - case PatternValues.DarkHorizontal: - return XLFillPatternValues.DarkHorizontal; - - case PatternValues.DarkTrellis: - return XLFillPatternValues.DarkTrellis; - - case PatternValues.DarkUp: - return XLFillPatternValues.DarkUp; - - case PatternValues.DarkVertical: - return XLFillPatternValues.DarkVertical; - - case PatternValues.Gray0625: - return XLFillPatternValues.Gray0625; - - case PatternValues.Gray125: - return XLFillPatternValues.Gray125; - - case PatternValues.LightDown: - return XLFillPatternValues.LightDown; - - case PatternValues.LightGray: - return XLFillPatternValues.LightGray; - - case PatternValues.LightGrid: - return XLFillPatternValues.LightGrid; - - case PatternValues.LightHorizontal: - return XLFillPatternValues.LightHorizontal; - - case PatternValues.LightTrellis: - return XLFillPatternValues.LightTrellis; - - case PatternValues.LightUp: - return XLFillPatternValues.LightUp; - - case PatternValues.LightVertical: - return XLFillPatternValues.LightVertical; + { OrientationValues.Default, XLPageOrientation.Default }, + { OrientationValues.Landscape, XLPageOrientation.Landscape }, + { OrientationValues.Portrait, XLPageOrientation.Portrait }, + }; - case PatternValues.MediumGray: - return XLFillPatternValues.MediumGray; - - case PatternValues.None: - return XLFillPatternValues.None; - - case PatternValues.Solid: - return XLFillPatternValues.Solid; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLPageOrientation ToClosedXml(this OrientationValues value) + { + return OrientationMap[value]; } - public static XLBorderStyleValues ToClosedXml(this BorderStyleValues value) - { - switch (value) + private static readonly IReadOnlyDictionary VerticalAlignmentRunMap = + new Dictionary { - case BorderStyleValues.DashDot: - return XLBorderStyleValues.DashDot; - - case BorderStyleValues.DashDotDot: - return XLBorderStyleValues.DashDotDot; - - case BorderStyleValues.Dashed: - return XLBorderStyleValues.Dashed; + { VerticalAlignmentRunValues.Baseline, XLFontVerticalTextAlignmentValues.Baseline }, + { VerticalAlignmentRunValues.Subscript, XLFontVerticalTextAlignmentValues.Subscript }, + { VerticalAlignmentRunValues.Superscript, XLFontVerticalTextAlignmentValues.Superscript }, + }; - case BorderStyleValues.Dotted: - return XLBorderStyleValues.Dotted; - case BorderStyleValues.Double: - return XLBorderStyleValues.Double; - - case BorderStyleValues.Hair: - return XLBorderStyleValues.Hair; - - case BorderStyleValues.Medium: - return XLBorderStyleValues.Medium; - - case BorderStyleValues.MediumDashDot: - return XLBorderStyleValues.MediumDashDot; - - case BorderStyleValues.MediumDashDotDot: - return XLBorderStyleValues.MediumDashDotDot; - - case BorderStyleValues.MediumDashed: - return XLBorderStyleValues.MediumDashed; - - case BorderStyleValues.None: - return XLBorderStyleValues.None; - - case BorderStyleValues.SlantDashDot: - return XLBorderStyleValues.SlantDashDot; - - case BorderStyleValues.Thick: - return XLBorderStyleValues.Thick; - - case BorderStyleValues.Thin: - return XLBorderStyleValues.Thin; + public static XLFontVerticalTextAlignmentValues ToClosedXml(this VerticalAlignmentRunValues value) + { + return VerticalAlignmentRunMap[value]; + } + + private static readonly IReadOnlyDictionary PatternMap = + new Dictionary + { + { PatternValues.DarkDown, XLFillPatternValues.DarkDown }, + { PatternValues.DarkGray, XLFillPatternValues.DarkGray }, + { PatternValues.DarkGrid, XLFillPatternValues.DarkGrid }, + { PatternValues.DarkHorizontal, XLFillPatternValues.DarkHorizontal }, + { PatternValues.DarkTrellis, XLFillPatternValues.DarkTrellis }, + { PatternValues.DarkUp, XLFillPatternValues.DarkUp }, + { PatternValues.DarkVertical, XLFillPatternValues.DarkVertical }, + { PatternValues.Gray0625, XLFillPatternValues.Gray0625 }, + { PatternValues.Gray125, XLFillPatternValues.Gray125 }, + { PatternValues.LightDown, XLFillPatternValues.LightDown }, + { PatternValues.LightGray, XLFillPatternValues.LightGray }, + { PatternValues.LightGrid, XLFillPatternValues.LightGrid }, + { PatternValues.LightHorizontal, XLFillPatternValues.LightHorizontal }, + { PatternValues.LightTrellis, XLFillPatternValues.LightTrellis }, + { PatternValues.LightUp, XLFillPatternValues.LightUp }, + { PatternValues.LightVertical, XLFillPatternValues.LightVertical }, + { PatternValues.MediumGray, XLFillPatternValues.MediumGray }, + { PatternValues.None, XLFillPatternValues.None }, + { PatternValues.Solid, XLFillPatternValues.Solid }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLFillPatternValues ToClosedXml(this PatternValues value) + { + return PatternMap[value]; } - public static XLAlignmentHorizontalValues ToClosedXml(this HorizontalAlignmentValues value) - { - switch (value) + private static readonly IReadOnlyDictionary BorderStyleMap = + new Dictionary { - case HorizontalAlignmentValues.Center: - return XLAlignmentHorizontalValues.Center; - - case HorizontalAlignmentValues.CenterContinuous: - return XLAlignmentHorizontalValues.CenterContinuous; - - case HorizontalAlignmentValues.Distributed: - return XLAlignmentHorizontalValues.Distributed; - - case HorizontalAlignmentValues.Fill: - return XLAlignmentHorizontalValues.Fill; - - case HorizontalAlignmentValues.General: - return XLAlignmentHorizontalValues.General; - - case HorizontalAlignmentValues.Justify: - return XLAlignmentHorizontalValues.Justify; - - case HorizontalAlignmentValues.Left: - return XLAlignmentHorizontalValues.Left; - - case HorizontalAlignmentValues.Right: - return XLAlignmentHorizontalValues.Right; + { BorderStyleValues.DashDot, XLBorderStyleValues.DashDot }, + { BorderStyleValues.DashDotDot, XLBorderStyleValues.DashDotDot }, + { BorderStyleValues.Dashed, XLBorderStyleValues.Dashed }, + { BorderStyleValues.Dotted, XLBorderStyleValues.Dotted }, + { BorderStyleValues.Double, XLBorderStyleValues.Double }, + { BorderStyleValues.Hair, XLBorderStyleValues.Hair }, + { BorderStyleValues.Medium, XLBorderStyleValues.Medium }, + { BorderStyleValues.MediumDashDot, XLBorderStyleValues.MediumDashDot }, + { BorderStyleValues.MediumDashDotDot, XLBorderStyleValues.MediumDashDotDot }, + { BorderStyleValues.MediumDashed, XLBorderStyleValues.MediumDashed }, + { BorderStyleValues.None, XLBorderStyleValues.None }, + { BorderStyleValues.SlantDashDot, XLBorderStyleValues.SlantDashDot }, + { BorderStyleValues.Thick, XLBorderStyleValues.Thick }, + { BorderStyleValues.Thin, XLBorderStyleValues.Thin }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLBorderStyleValues ToClosedXml(this BorderStyleValues value) + { + return BorderStyleMap[value]; } - public static XLAlignmentVerticalValues ToClosedXml(this VerticalAlignmentValues value) - { - switch (value) + private static readonly IReadOnlyDictionary HorizontalAlignmentMap = + new Dictionary { - case VerticalAlignmentValues.Bottom: - return XLAlignmentVerticalValues.Bottom; - - case VerticalAlignmentValues.Center: - return XLAlignmentVerticalValues.Center; - - case VerticalAlignmentValues.Distributed: - return XLAlignmentVerticalValues.Distributed; + { HorizontalAlignmentValues.Center, XLAlignmentHorizontalValues.Center }, + { HorizontalAlignmentValues.CenterContinuous, XLAlignmentHorizontalValues.CenterContinuous }, + { HorizontalAlignmentValues.Distributed, XLAlignmentHorizontalValues.Distributed }, + { HorizontalAlignmentValues.Fill, XLAlignmentHorizontalValues.Fill }, + { HorizontalAlignmentValues.General, XLAlignmentHorizontalValues.General }, + { HorizontalAlignmentValues.Justify, XLAlignmentHorizontalValues.Justify }, + { HorizontalAlignmentValues.Left, XLAlignmentHorizontalValues.Left }, + { HorizontalAlignmentValues.Right, XLAlignmentHorizontalValues.Right }, + }; - case VerticalAlignmentValues.Justify: - return XLAlignmentVerticalValues.Justify; - - case VerticalAlignmentValues.Top: - return XLAlignmentVerticalValues.Top; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLAlignmentHorizontalValues ToClosedXml(this HorizontalAlignmentValues value) + { + return HorizontalAlignmentMap[value]; } - public static XLPageOrderValues ToClosedXml(this PageOrderValues value) - { - switch (value) + private static readonly IReadOnlyDictionary VerticalAlignmentMap = + new Dictionary { - case PageOrderValues.DownThenOver: - return XLPageOrderValues.DownThenOver; + { VerticalAlignmentValues.Bottom, XLAlignmentVerticalValues.Bottom }, + { VerticalAlignmentValues.Center, XLAlignmentVerticalValues.Center }, + { VerticalAlignmentValues.Distributed, XLAlignmentVerticalValues.Distributed }, + { VerticalAlignmentValues.Justify, XLAlignmentVerticalValues.Justify }, + { VerticalAlignmentValues.Top, XLAlignmentVerticalValues.Top }, + }; - case PageOrderValues.OverThenDown: - return XLPageOrderValues.OverThenDown; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLAlignmentVerticalValues ToClosedXml(this VerticalAlignmentValues value) + { + return VerticalAlignmentMap[value]; } - public static XLShowCommentsValues ToClosedXml(this CellCommentsValues value) - { - switch (value) + private static readonly IReadOnlyDictionary PageOrdersMap = + new Dictionary { - case CellCommentsValues.AsDisplayed: - return XLShowCommentsValues.AsDisplayed; - - case CellCommentsValues.AtEnd: - return XLShowCommentsValues.AtEnd; + { PageOrderValues.DownThenOver, XLPageOrderValues.DownThenOver }, + { PageOrderValues.OverThenDown, XLPageOrderValues.OverThenDown }, + }; - case CellCommentsValues.None: - return XLShowCommentsValues.None; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLPageOrderValues ToClosedXml(this PageOrderValues value) + { + return PageOrdersMap[value]; } - public static XLPrintErrorValues ToClosedXml(this PrintErrorValues value) - { - switch (value) + private static readonly IReadOnlyDictionary CellCommentsMap = + new Dictionary { - case PrintErrorValues.Blank: - return XLPrintErrorValues.Blank; + { CellCommentsValues.AsDisplayed, XLShowCommentsValues.AsDisplayed }, + { CellCommentsValues.AtEnd, XLShowCommentsValues.AtEnd }, + { CellCommentsValues.None, XLShowCommentsValues.None }, + }; - case PrintErrorValues.Dash: - return XLPrintErrorValues.Dash; - - case PrintErrorValues.Displayed: - return XLPrintErrorValues.Displayed; - - case PrintErrorValues.NA: - return XLPrintErrorValues.NA; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLShowCommentsValues ToClosedXml(this CellCommentsValues value) + { + return CellCommentsMap[value]; } - public static XLCalculateMode ToClosedXml(this CalculateModeValues value) - { - switch (value) + private static readonly IReadOnlyDictionary PrintErrorMap = + new Dictionary { - case CalculateModeValues.Auto: - return XLCalculateMode.Auto; + { PrintErrorValues.Blank, XLPrintErrorValues.Blank }, + { PrintErrorValues.Dash, XLPrintErrorValues.Dash }, + { PrintErrorValues.Displayed, XLPrintErrorValues.Displayed }, + { PrintErrorValues.NA, XLPrintErrorValues.NA }, + }; - case CalculateModeValues.AutoNoTable: - return XLCalculateMode.AutoNoTable; + public static XLPrintErrorValues ToClosedXml(this PrintErrorValues value) + { + return PrintErrorMap[value]; + } - case CalculateModeValues.Manual: - return XLCalculateMode.Manual; + private static readonly IReadOnlyDictionary CalculateModeMap = + new Dictionary + { + { CalculateModeValues.Auto, XLCalculateMode.Auto }, + { CalculateModeValues.AutoNoTable, XLCalculateMode.AutoNoTable }, + { CalculateModeValues.Manual, XLCalculateMode.Manual }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLCalculateMode ToClosedXml(this CalculateModeValues value) + { + return CalculateModeMap[value]; } - public static XLReferenceStyle ToClosedXml(this ReferenceModeValues value) - { - switch (value) + private static readonly IReadOnlyDictionary ReferenceModeMap = + new Dictionary { - case ReferenceModeValues.R1C1: - return XLReferenceStyle.R1C1; + { ReferenceModeValues.R1C1, XLReferenceStyle.R1C1 }, + { ReferenceModeValues.A1, XLReferenceStyle.A1 }, + }; - case ReferenceModeValues.A1: - return XLReferenceStyle.A1; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLReferenceStyle ToClosedXml(this ReferenceModeValues value) + { + return ReferenceModeMap[value]; } public static XLAlignmentReadingOrderValues ToClosedXml(this uint value) @@ -1197,505 +1157,490 @@ public static XLAlignmentReadingOrderValues ToClosedXml(this uint value) } } - public static XLTotalsRowFunction ToClosedXml(this TotalsRowFunctionValues value) - { - switch (value) + private static readonly IReadOnlyDictionary TotalsRowFunctionMap = + new Dictionary { - case TotalsRowFunctionValues.None: - return XLTotalsRowFunction.None; - - case TotalsRowFunctionValues.Sum: - return XLTotalsRowFunction.Sum; - - case TotalsRowFunctionValues.Minimum: - return XLTotalsRowFunction.Minimum; - - case TotalsRowFunctionValues.Maximum: - return XLTotalsRowFunction.Maximum; - - case TotalsRowFunctionValues.Average: - return XLTotalsRowFunction.Average; - - case TotalsRowFunctionValues.Count: - return XLTotalsRowFunction.Count; - - case TotalsRowFunctionValues.CountNumbers: - return XLTotalsRowFunction.CountNumbers; - - case TotalsRowFunctionValues.StandardDeviation: - return XLTotalsRowFunction.StandardDeviation; + { TotalsRowFunctionValues.None, XLTotalsRowFunction.None }, + { TotalsRowFunctionValues.Sum, XLTotalsRowFunction.Sum }, + { TotalsRowFunctionValues.Minimum, XLTotalsRowFunction.Minimum }, + { TotalsRowFunctionValues.Maximum, XLTotalsRowFunction.Maximum }, + { TotalsRowFunctionValues.Average, XLTotalsRowFunction.Average }, + { TotalsRowFunctionValues.Count, XLTotalsRowFunction.Count }, + { TotalsRowFunctionValues.CountNumbers, XLTotalsRowFunction.CountNumbers }, + { TotalsRowFunctionValues.StandardDeviation, XLTotalsRowFunction.StandardDeviation }, + { TotalsRowFunctionValues.Variance, XLTotalsRowFunction.Variance }, + { TotalsRowFunctionValues.Custom, XLTotalsRowFunction.Custom }, + }; - case TotalsRowFunctionValues.Variance: - return XLTotalsRowFunction.Variance; - case TotalsRowFunctionValues.Custom: - return XLTotalsRowFunction.Custom; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLTotalsRowFunction ToClosedXml(this TotalsRowFunctionValues value) + { + return TotalsRowFunctionMap[value]; } - public static XLAllowedValues ToClosedXml(this DataValidationValues value) - { - switch (value) + private static readonly IReadOnlyDictionary DataValidationMap = + new Dictionary { - case DataValidationValues.None: - return XLAllowedValues.AnyValue; - - case DataValidationValues.Custom: - return XLAllowedValues.Custom; - - case DataValidationValues.Date: - return XLAllowedValues.Date; - - case DataValidationValues.Decimal: - return XLAllowedValues.Decimal; + { DataValidationValues.None, XLAllowedValues.AnyValue }, + { DataValidationValues.Custom, XLAllowedValues.Custom }, + { DataValidationValues.Date, XLAllowedValues.Date }, + { DataValidationValues.Decimal, XLAllowedValues.Decimal }, + { DataValidationValues.List, XLAllowedValues.List }, + { DataValidationValues.TextLength, XLAllowedValues.TextLength }, + { DataValidationValues.Time, XLAllowedValues.Time }, + { DataValidationValues.Whole, XLAllowedValues.WholeNumber }, + }; - case DataValidationValues.List: - return XLAllowedValues.List; - - case DataValidationValues.TextLength: - return XLAllowedValues.TextLength; - - case DataValidationValues.Time: - return XLAllowedValues.Time; - - case DataValidationValues.Whole: - return XLAllowedValues.WholeNumber; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLAllowedValues ToClosedXml(this DataValidationValues value) + { + return DataValidationMap[value]; } - public static XLErrorStyle ToClosedXml(this DataValidationErrorStyleValues value) - { - switch (value) + private static readonly IReadOnlyDictionary DataValidationErrorStyleMap = + new Dictionary { - case DataValidationErrorStyleValues.Information: - return XLErrorStyle.Information; - - case DataValidationErrorStyleValues.Warning: - return XLErrorStyle.Warning; + { DataValidationErrorStyleValues.Information, XLErrorStyle.Information }, + { DataValidationErrorStyleValues.Warning, XLErrorStyle.Warning }, + { DataValidationErrorStyleValues.Stop, XLErrorStyle.Stop }, + }; - case DataValidationErrorStyleValues.Stop: - return XLErrorStyle.Stop; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLErrorStyle ToClosedXml(this DataValidationErrorStyleValues value) + { + return DataValidationErrorStyleMap[value]; } - public static XLOperator ToClosedXml(this DataValidationOperatorValues value) - { - switch (value) + private static readonly IReadOnlyDictionary DataValidationOperatorMap = + new Dictionary { - case DataValidationOperatorValues.Between: - return XLOperator.Between; - - case DataValidationOperatorValues.GreaterThanOrEqual: - return XLOperator.EqualOrGreaterThan; - - case DataValidationOperatorValues.LessThanOrEqual: - return XLOperator.EqualOrLessThan; - - case DataValidationOperatorValues.Equal: - return XLOperator.EqualTo; - - case DataValidationOperatorValues.GreaterThan: - return XLOperator.GreaterThan; + { DataValidationOperatorValues.Between, XLOperator.Between }, + { DataValidationOperatorValues.GreaterThanOrEqual, XLOperator.EqualOrGreaterThan }, + { DataValidationOperatorValues.LessThanOrEqual, XLOperator.EqualOrLessThan }, + { DataValidationOperatorValues.Equal, XLOperator.EqualTo }, + { DataValidationOperatorValues.GreaterThan, XLOperator.GreaterThan }, + { DataValidationOperatorValues.LessThan, XLOperator.LessThan }, + { DataValidationOperatorValues.NotBetween, XLOperator.NotBetween }, + { DataValidationOperatorValues.NotEqual, XLOperator.NotEqualTo }, + }; - case DataValidationOperatorValues.LessThan: - return XLOperator.LessThan; - - case DataValidationOperatorValues.NotBetween: - return XLOperator.NotBetween; - - case DataValidationOperatorValues.NotEqual: - return XLOperator.NotEqualTo; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLOperator ToClosedXml(this DataValidationOperatorValues value) + { + return DataValidationOperatorMap[value]; } - public static XLWorksheetVisibility ToClosedXml(this SheetStateValues value) - { - switch (value) + private static readonly IReadOnlyDictionary SheetStateMap = + new Dictionary { - case SheetStateValues.Visible: - return XLWorksheetVisibility.Visible; - - case SheetStateValues.Hidden: - return XLWorksheetVisibility.Hidden; - - case SheetStateValues.VeryHidden: - return XLWorksheetVisibility.VeryHidden; + { SheetStateValues.Visible, XLWorksheetVisibility.Visible }, + { SheetStateValues.Hidden, XLWorksheetVisibility.Hidden }, + { SheetStateValues.VeryHidden, XLWorksheetVisibility.VeryHidden }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLWorksheetVisibility ToClosedXml(this SheetStateValues value) + { + return SheetStateMap[value]; } - public static XLPhoneticAlignment ToClosedXml(this PhoneticAlignmentValues value) - { - switch (value) + private static readonly IReadOnlyDictionary PhoneticAlignmentMap = + new Dictionary { - case PhoneticAlignmentValues.Center: - return XLPhoneticAlignment.Center; - - case PhoneticAlignmentValues.Distributed: - return XLPhoneticAlignment.Distributed; - - case PhoneticAlignmentValues.Left: - return XLPhoneticAlignment.Left; + { PhoneticAlignmentValues.Center, XLPhoneticAlignment.Center }, + { PhoneticAlignmentValues.Distributed, XLPhoneticAlignment.Distributed }, + { PhoneticAlignmentValues.Left, XLPhoneticAlignment.Left }, + { PhoneticAlignmentValues.NoControl, XLPhoneticAlignment.NoControl }, + }; - case PhoneticAlignmentValues.NoControl: - return XLPhoneticAlignment.NoControl; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLPhoneticAlignment ToClosedXml(this PhoneticAlignmentValues value) + { + return PhoneticAlignmentMap[value]; } - public static XLPhoneticType ToClosedXml(this PhoneticValues value) - { - switch (value) + private static readonly IReadOnlyDictionary PhoneticMap = + new Dictionary { - case PhoneticValues.FullWidthKatakana: return XLPhoneticType.FullWidthKatakana; - case PhoneticValues.HalfWidthKatakana: - return XLPhoneticType.HalfWidthKatakana; + { PhoneticValues.FullWidthKatakana, XLPhoneticType.FullWidthKatakana }, + { PhoneticValues.HalfWidthKatakana, XLPhoneticType.HalfWidthKatakana }, + { PhoneticValues.Hiragana, XLPhoneticType.Hiragana }, + { PhoneticValues.NoConversion, XLPhoneticType.NoConversion }, + }; - case PhoneticValues.Hiragana: - return XLPhoneticType.Hiragana; + public static XLPhoneticType ToClosedXml(this PhoneticValues value) + { + return PhoneticMap[value]; + } - case PhoneticValues.NoConversion: - return XLPhoneticType.NoConversion; + private static readonly IReadOnlyDictionary DataConsolidateFunctionMap = + new Dictionary + { + { DataConsolidateFunctionValues.Sum, XLPivotSummary.Sum }, + { DataConsolidateFunctionValues.Count, XLPivotSummary.Count }, + { DataConsolidateFunctionValues.Average, XLPivotSummary.Average }, + { DataConsolidateFunctionValues.Minimum, XLPivotSummary.Minimum }, + { DataConsolidateFunctionValues.Maximum, XLPivotSummary.Maximum }, + { DataConsolidateFunctionValues.Product, XLPivotSummary.Product }, + { DataConsolidateFunctionValues.CountNumbers, XLPivotSummary.CountNumbers }, + { DataConsolidateFunctionValues.StandardDeviation, XLPivotSummary.StandardDeviation }, + { DataConsolidateFunctionValues.StandardDeviationP, XLPivotSummary.PopulationStandardDeviation }, + { DataConsolidateFunctionValues.Variance, XLPivotSummary.Variance }, + { DataConsolidateFunctionValues.VarianceP, XLPivotSummary.PopulationVariance }, - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } - } + }; public static XLPivotSummary ToClosedXml(this DataConsolidateFunctionValues value) { - switch (value) - { - case DataConsolidateFunctionValues.Sum: return XLPivotSummary.Sum; - case DataConsolidateFunctionValues.Count: return XLPivotSummary.Count; - case DataConsolidateFunctionValues.Average: return XLPivotSummary.Average; - case DataConsolidateFunctionValues.Minimum: return XLPivotSummary.Minimum; - case DataConsolidateFunctionValues.Maximum: return XLPivotSummary.Maximum; - case DataConsolidateFunctionValues.Product: return XLPivotSummary.Product; - case DataConsolidateFunctionValues.CountNumbers: return XLPivotSummary.CountNumbers; - case DataConsolidateFunctionValues.StandardDeviation: return XLPivotSummary.StandardDeviation; - case DataConsolidateFunctionValues.StandardDeviationP: return XLPivotSummary.PopulationStandardDeviation; - case DataConsolidateFunctionValues.Variance: return XLPivotSummary.Variance; - case DataConsolidateFunctionValues.VarianceP: return XLPivotSummary.PopulationVariance; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + return DataConsolidateFunctionMap[value]; } - public static XLPivotCalculation ToClosedXml(this ShowDataAsValues value) - { - switch (value) + private static readonly IReadOnlyDictionary ShowDataAsMap = + new Dictionary { - case ShowDataAsValues.Normal: return XLPivotCalculation.Normal; - case ShowDataAsValues.Difference: return XLPivotCalculation.DifferenceFrom; - case ShowDataAsValues.Percent: return XLPivotCalculation.PercentageOf; - case ShowDataAsValues.PercentageDifference: return XLPivotCalculation.PercentageDifferenceFrom; - case ShowDataAsValues.RunTotal: return XLPivotCalculation.RunningTotal; - case ShowDataAsValues.PercentOfRaw: return XLPivotCalculation.PercentageOfRow; // There's a typo in the OpenXML SDK =) - case ShowDataAsValues.PercentOfColumn: return XLPivotCalculation.PercentageOfColumn; - case ShowDataAsValues.PercentOfTotal: return XLPivotCalculation.PercentageOfTotal; - case ShowDataAsValues.Index: return XLPivotCalculation.Index; + { ShowDataAsValues.Normal, XLPivotCalculation.Normal }, + { ShowDataAsValues.Difference, XLPivotCalculation.DifferenceFrom }, + { ShowDataAsValues.Percent, XLPivotCalculation.PercentageOf }, + { ShowDataAsValues.PercentageDifference, XLPivotCalculation.PercentageDifferenceFrom }, + { ShowDataAsValues.RunTotal, XLPivotCalculation.RunningTotal }, + { ShowDataAsValues.PercentOfRaw, XLPivotCalculation.PercentageOfRow }, // There's a typo in the OpenXML SDK =) + { ShowDataAsValues.PercentOfColumn, XLPivotCalculation.PercentageOfColumn }, + { ShowDataAsValues.PercentOfTotal, XLPivotCalculation.PercentageOfTotal }, + { ShowDataAsValues.Index, XLPivotCalculation.Index }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLPivotCalculation ToClosedXml(this ShowDataAsValues value) + { + return ShowDataAsMap[value]; } - public static XLFilterOperator ToClosedXml(this FilterOperatorValues value) - { - switch (value) + private static readonly IReadOnlyDictionary FilterOperatorMap = + new Dictionary { - case FilterOperatorValues.Equal: return XLFilterOperator.Equal; - case FilterOperatorValues.NotEqual: return XLFilterOperator.NotEqual; - case FilterOperatorValues.GreaterThan: return XLFilterOperator.GreaterThan; - case FilterOperatorValues.LessThan: return XLFilterOperator.LessThan; - case FilterOperatorValues.GreaterThanOrEqual: return XLFilterOperator.EqualOrGreaterThan; - case FilterOperatorValues.LessThanOrEqual: return XLFilterOperator.EqualOrLessThan; + { FilterOperatorValues.Equal, XLFilterOperator.Equal }, + { FilterOperatorValues.NotEqual, XLFilterOperator.NotEqual }, + { FilterOperatorValues.GreaterThan, XLFilterOperator.GreaterThan }, + { FilterOperatorValues.LessThan, XLFilterOperator.LessThan }, + { FilterOperatorValues.GreaterThanOrEqual, XLFilterOperator.EqualOrGreaterThan }, + { FilterOperatorValues.LessThanOrEqual, XLFilterOperator.EqualOrLessThan }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLFilterOperator ToClosedXml(this FilterOperatorValues value) + { + return FilterOperatorMap[value]; } - public static XLFilterDynamicType ToClosedXml(this DynamicFilterValues value) - { - switch (value) + private static readonly IReadOnlyDictionary DynamicFilterMap = + new Dictionary { - case DynamicFilterValues.AboveAverage: return XLFilterDynamicType.AboveAverage; - case DynamicFilterValues.BelowAverage: return XLFilterDynamicType.BelowAverage; + { DynamicFilterValues.AboveAverage, XLFilterDynamicType.AboveAverage }, + { DynamicFilterValues.BelowAverage, XLFilterDynamicType.BelowAverage }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLFilterDynamicType ToClosedXml(this DynamicFilterValues value) + { + return DynamicFilterMap[value]; } - public static XLDateTimeGrouping ToClosedXml(this DateTimeGroupingValues value) - { - switch (value) + private static readonly IReadOnlyDictionary DateTimeGroupingMap = + new Dictionary { - case DateTimeGroupingValues.Year: return XLDateTimeGrouping.Year; - case DateTimeGroupingValues.Month: return XLDateTimeGrouping.Month; - case DateTimeGroupingValues.Day: return XLDateTimeGrouping.Day; - case DateTimeGroupingValues.Hour: return XLDateTimeGrouping.Hour; - case DateTimeGroupingValues.Minute: return XLDateTimeGrouping.Minute; - case DateTimeGroupingValues.Second: return XLDateTimeGrouping.Second; + { DateTimeGroupingValues.Year, XLDateTimeGrouping.Year }, + { DateTimeGroupingValues.Month, XLDateTimeGrouping.Month }, + { DateTimeGroupingValues.Day, XLDateTimeGrouping.Day }, + { DateTimeGroupingValues.Hour, XLDateTimeGrouping.Hour }, + { DateTimeGroupingValues.Minute, XLDateTimeGrouping.Minute }, + { DateTimeGroupingValues.Second, XLDateTimeGrouping.Second }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLDateTimeGrouping ToClosedXml(this DateTimeGroupingValues value) + { + return DateTimeGroupingMap[value]; } - public static XLSheetViewOptions ToClosedXml(this SheetViewValues value) - { - switch (value) + private static readonly IReadOnlyDictionary SheetViewMap = + new Dictionary { - case SheetViewValues.Normal: return XLSheetViewOptions.Normal; - case SheetViewValues.PageBreakPreview: return XLSheetViewOptions.PageBreakPreview; - case SheetViewValues.PageLayout: return XLSheetViewOptions.PageLayout; + { SheetViewValues.Normal, XLSheetViewOptions.Normal }, + { SheetViewValues.PageBreakPreview, XLSheetViewOptions.PageBreakPreview }, + { SheetViewValues.PageLayout, XLSheetViewOptions.PageLayout }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLSheetViewOptions ToClosedXml(this SheetViewValues value) + { + return SheetViewMap[value]; } - public static XLLineStyle ToClosedXml(this Vml.StrokeLineStyleValues value) - { - switch (value) + private static readonly IReadOnlyDictionary StrokeLineStyleMap = + new Dictionary { - case Vml.StrokeLineStyleValues.Single: return XLLineStyle.Single; - case Vml.StrokeLineStyleValues.ThickBetweenThin: return XLLineStyle.ThickBetweenThin; - case Vml.StrokeLineStyleValues.ThickThin: return XLLineStyle.ThickThin; - case Vml.StrokeLineStyleValues.ThinThick: return XLLineStyle.ThinThick; - case Vml.StrokeLineStyleValues.ThinThin: return XLLineStyle.ThinThin; + { Vml.StrokeLineStyleValues.Single, XLLineStyle.Single }, + { Vml.StrokeLineStyleValues.ThickBetweenThin, XLLineStyle.ThickBetweenThin }, + { Vml.StrokeLineStyleValues.ThickThin, XLLineStyle.ThickThin }, + { Vml.StrokeLineStyleValues.ThinThick, XLLineStyle.ThinThick }, + { Vml.StrokeLineStyleValues.ThinThin, XLLineStyle.ThinThin }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } - } + public static XLLineStyle ToClosedXml(this Vml.StrokeLineStyleValues value) + { + return StrokeLineStyleMap[value]; + } + + private static readonly IReadOnlyDictionary ConditionalFormatMap = + new Dictionary + { + { ConditionalFormatValues.Expression, XLConditionalFormatType.Expression }, + { ConditionalFormatValues.CellIs, XLConditionalFormatType.CellIs }, + { ConditionalFormatValues.ColorScale, XLConditionalFormatType.ColorScale }, + { ConditionalFormatValues.DataBar, XLConditionalFormatType.DataBar }, + { ConditionalFormatValues.IconSet, XLConditionalFormatType.IconSet }, + { ConditionalFormatValues.Top10, XLConditionalFormatType.Top10 }, + { ConditionalFormatValues.UniqueValues, XLConditionalFormatType.IsUnique }, + { ConditionalFormatValues.DuplicateValues, XLConditionalFormatType.IsDuplicate }, + { ConditionalFormatValues.ContainsText, XLConditionalFormatType.ContainsText }, + { ConditionalFormatValues.NotContainsText, XLConditionalFormatType.NotContainsText }, + { ConditionalFormatValues.BeginsWith, XLConditionalFormatType.StartsWith }, + { ConditionalFormatValues.EndsWith, XLConditionalFormatType.EndsWith }, + { ConditionalFormatValues.ContainsBlanks, XLConditionalFormatType.IsBlank }, + { ConditionalFormatValues.NotContainsBlanks, XLConditionalFormatType.NotBlank }, + { ConditionalFormatValues.ContainsErrors, XLConditionalFormatType.IsError }, + { ConditionalFormatValues.NotContainsErrors, XLConditionalFormatType.NotError }, + { ConditionalFormatValues.TimePeriod, XLConditionalFormatType.TimePeriod }, + { ConditionalFormatValues.AboveAverage, XLConditionalFormatType.AboveAverage }, + }; public static XLConditionalFormatType ToClosedXml(this ConditionalFormatValues value) { - switch (value) - { - case ConditionalFormatValues.Expression: return XLConditionalFormatType.Expression; - case ConditionalFormatValues.CellIs: return XLConditionalFormatType.CellIs; - case ConditionalFormatValues.ColorScale: return XLConditionalFormatType.ColorScale; - case ConditionalFormatValues.DataBar: return XLConditionalFormatType.DataBar; - case ConditionalFormatValues.IconSet: return XLConditionalFormatType.IconSet; - case ConditionalFormatValues.Top10: return XLConditionalFormatType.Top10; - case ConditionalFormatValues.UniqueValues: return XLConditionalFormatType.IsUnique; - case ConditionalFormatValues.DuplicateValues: return XLConditionalFormatType.IsDuplicate; - case ConditionalFormatValues.ContainsText: return XLConditionalFormatType.ContainsText; - case ConditionalFormatValues.NotContainsText: return XLConditionalFormatType.NotContainsText; - case ConditionalFormatValues.BeginsWith: return XLConditionalFormatType.StartsWith; - case ConditionalFormatValues.EndsWith: return XLConditionalFormatType.EndsWith; - case ConditionalFormatValues.ContainsBlanks: return XLConditionalFormatType.IsBlank; - case ConditionalFormatValues.NotContainsBlanks: return XLConditionalFormatType.NotBlank; - case ConditionalFormatValues.ContainsErrors: return XLConditionalFormatType.IsError; - case ConditionalFormatValues.NotContainsErrors: return XLConditionalFormatType.NotError; - case ConditionalFormatValues.TimePeriod: return XLConditionalFormatType.TimePeriod; - case ConditionalFormatValues.AboveAverage: return XLConditionalFormatType.AboveAverage; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + return ConditionalFormatMap[value]; } - public static XLCFContentType ToClosedXml(this ConditionalFormatValueObjectValues value) - { - switch (value) + private static readonly IReadOnlyDictionary ConditionalFormatValueObjectMap = + new Dictionary { - case ConditionalFormatValueObjectValues.Number: return XLCFContentType.Number; - case ConditionalFormatValueObjectValues.Percent: return XLCFContentType.Percent; - case ConditionalFormatValueObjectValues.Max: return XLCFContentType.Maximum; - case ConditionalFormatValueObjectValues.Min: return XLCFContentType.Minimum; - case ConditionalFormatValueObjectValues.Formula: return XLCFContentType.Formula; - case ConditionalFormatValueObjectValues.Percentile: return XLCFContentType.Percentile; + { ConditionalFormatValueObjectValues.Number, XLCFContentType.Number }, + { ConditionalFormatValueObjectValues.Percent, XLCFContentType.Percent }, + { ConditionalFormatValueObjectValues.Max, XLCFContentType.Maximum }, + { ConditionalFormatValueObjectValues.Min, XLCFContentType.Minimum }, + { ConditionalFormatValueObjectValues.Formula, XLCFContentType.Formula }, + { ConditionalFormatValueObjectValues.Percentile, XLCFContentType.Percentile }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLCFContentType ToClosedXml(this ConditionalFormatValueObjectValues value) + { + return ConditionalFormatValueObjectMap[value]; } - public static XLCFOperator ToClosedXml(this ConditionalFormattingOperatorValues value) - { - switch (value) + private static readonly IReadOnlyDictionary ConditionalFormattingOperatorMap = + new Dictionary { - case ConditionalFormattingOperatorValues.LessThan: return XLCFOperator.LessThan; - case ConditionalFormattingOperatorValues.LessThanOrEqual: return XLCFOperator.EqualOrLessThan; - case ConditionalFormattingOperatorValues.Equal: return XLCFOperator.Equal; - case ConditionalFormattingOperatorValues.NotEqual: return XLCFOperator.NotEqual; - case ConditionalFormattingOperatorValues.GreaterThanOrEqual: return XLCFOperator.EqualOrGreaterThan; - case ConditionalFormattingOperatorValues.GreaterThan: return XLCFOperator.GreaterThan; - case ConditionalFormattingOperatorValues.Between: return XLCFOperator.Between; - case ConditionalFormattingOperatorValues.NotBetween: return XLCFOperator.NotBetween; - case ConditionalFormattingOperatorValues.ContainsText: return XLCFOperator.Contains; - case ConditionalFormattingOperatorValues.NotContains: return XLCFOperator.NotContains; - case ConditionalFormattingOperatorValues.BeginsWith: return XLCFOperator.StartsWith; - case ConditionalFormattingOperatorValues.EndsWith: return XLCFOperator.EndsWith; + { ConditionalFormattingOperatorValues.LessThan, XLCFOperator.LessThan }, + { ConditionalFormattingOperatorValues.LessThanOrEqual, XLCFOperator.EqualOrLessThan }, + { ConditionalFormattingOperatorValues.Equal, XLCFOperator.Equal }, + { ConditionalFormattingOperatorValues.NotEqual, XLCFOperator.NotEqual }, + { ConditionalFormattingOperatorValues.GreaterThanOrEqual, XLCFOperator.EqualOrGreaterThan }, + { ConditionalFormattingOperatorValues.GreaterThan, XLCFOperator.GreaterThan }, + { ConditionalFormattingOperatorValues.Between, XLCFOperator.Between }, + { ConditionalFormattingOperatorValues.NotBetween, XLCFOperator.NotBetween }, + { ConditionalFormattingOperatorValues.ContainsText, XLCFOperator.Contains }, + { ConditionalFormattingOperatorValues.NotContains, XLCFOperator.NotContains }, + { ConditionalFormattingOperatorValues.BeginsWith, XLCFOperator.StartsWith }, + { ConditionalFormattingOperatorValues.EndsWith, XLCFOperator.EndsWith }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } - } + public static XLCFOperator ToClosedXml(this ConditionalFormattingOperatorValues value) + { + return ConditionalFormattingOperatorMap[value]; + } + + private static readonly IReadOnlyDictionary IconSetMap = + new Dictionary + { + { IconSetValues.ThreeArrows, XLIconSetStyle.ThreeArrows }, + { IconSetValues.ThreeArrowsGray, XLIconSetStyle.ThreeArrowsGray }, + { IconSetValues.ThreeFlags, XLIconSetStyle.ThreeFlags }, + { IconSetValues.ThreeTrafficLights1, XLIconSetStyle.ThreeTrafficLights1 }, + { IconSetValues.ThreeTrafficLights2, XLIconSetStyle.ThreeTrafficLights2 }, + { IconSetValues.ThreeSigns, XLIconSetStyle.ThreeSigns }, + { IconSetValues.ThreeSymbols, XLIconSetStyle.ThreeSymbols }, + { IconSetValues.ThreeSymbols2, XLIconSetStyle.ThreeSymbols2 }, + { IconSetValues.FourArrows, XLIconSetStyle.FourArrows }, + { IconSetValues.FourArrowsGray, XLIconSetStyle.FourArrowsGray }, + { IconSetValues.FourRedToBlack, XLIconSetStyle.FourRedToBlack }, + { IconSetValues.FourRating, XLIconSetStyle.FourRating }, + { IconSetValues.FourTrafficLights, XLIconSetStyle.FourTrafficLights }, + { IconSetValues.FiveArrows, XLIconSetStyle.FiveArrows }, + { IconSetValues.FiveArrowsGray, XLIconSetStyle.FiveArrowsGray }, + { IconSetValues.FiveRating, XLIconSetStyle.FiveRating }, + { IconSetValues.FiveQuarters, XLIconSetStyle.FiveQuarters }, + }; public static XLIconSetStyle ToClosedXml(this IconSetValues value) { - switch (value) - { - case IconSetValues.ThreeArrows: return XLIconSetStyle.ThreeArrows; - case IconSetValues.ThreeArrowsGray: return XLIconSetStyle.ThreeArrowsGray; - case IconSetValues.ThreeFlags: return XLIconSetStyle.ThreeFlags; - case IconSetValues.ThreeTrafficLights1: return XLIconSetStyle.ThreeTrafficLights1; - case IconSetValues.ThreeTrafficLights2: return XLIconSetStyle.ThreeTrafficLights2; - case IconSetValues.ThreeSigns: return XLIconSetStyle.ThreeSigns; - case IconSetValues.ThreeSymbols: return XLIconSetStyle.ThreeSymbols; - case IconSetValues.ThreeSymbols2: return XLIconSetStyle.ThreeSymbols2; - case IconSetValues.FourArrows: return XLIconSetStyle.FourArrows; - case IconSetValues.FourArrowsGray: return XLIconSetStyle.FourArrowsGray; - case IconSetValues.FourRedToBlack: return XLIconSetStyle.FourRedToBlack; - case IconSetValues.FourRating: return XLIconSetStyle.FourRating; - case IconSetValues.FourTrafficLights: return XLIconSetStyle.FourTrafficLights; - case IconSetValues.FiveArrows: return XLIconSetStyle.FiveArrows; - case IconSetValues.FiveArrowsGray: return XLIconSetStyle.FiveArrowsGray; - case IconSetValues.FiveRating: return XLIconSetStyle.FiveRating; - case IconSetValues.FiveQuarters: return XLIconSetStyle.FiveQuarters; - - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + return IconSetMap[value]; } - public static XLTimePeriod ToClosedXml(this TimePeriodValues value) - { - switch (value) + private static readonly IReadOnlyDictionary TimePeriodMap = + new Dictionary { - case TimePeriodValues.Yesterday: return XLTimePeriod.Yesterday; - case TimePeriodValues.Today: return XLTimePeriod.Today; - case TimePeriodValues.Tomorrow: return XLTimePeriod.Tomorrow; - case TimePeriodValues.Last7Days: return XLTimePeriod.InTheLast7Days; - case TimePeriodValues.LastWeek: return XLTimePeriod.LastWeek; - case TimePeriodValues.ThisWeek: return XLTimePeriod.ThisWeek; - case TimePeriodValues.NextWeek: return XLTimePeriod.NextWeek; - case TimePeriodValues.LastMonth: return XLTimePeriod.LastMonth; - case TimePeriodValues.ThisMonth: return XLTimePeriod.ThisMonth; - case TimePeriodValues.NextMonth: return XLTimePeriod.NextMonth; + { TimePeriodValues.Yesterday, XLTimePeriod.Yesterday }, + { TimePeriodValues.Today, XLTimePeriod.Today }, + { TimePeriodValues.Tomorrow, XLTimePeriod.Tomorrow }, + { TimePeriodValues.Last7Days, XLTimePeriod.InTheLast7Days }, + { TimePeriodValues.LastWeek, XLTimePeriod.LastWeek }, + { TimePeriodValues.ThisWeek, XLTimePeriod.ThisWeek }, + { TimePeriodValues.NextWeek, XLTimePeriod.NextWeek }, + { TimePeriodValues.LastMonth, XLTimePeriod.LastMonth }, + { TimePeriodValues.ThisMonth, XLTimePeriod.ThisMonth }, + { TimePeriodValues.NextMonth, XLTimePeriod.NextMonth }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLTimePeriod ToClosedXml(this TimePeriodValues value) + { + return TimePeriodMap[value]; } - public static XLPictureFormat ToClosedXml(this ImagePartType value) + private static readonly IReadOnlyDictionary PivotAreaMap = + new Dictionary + { + { PivotAreaValues.None, XLPivotAreaType.None }, + { PivotAreaValues.Normal, XLPivotAreaType.Normal }, + { PivotAreaValues.Data, XLPivotAreaType.Data }, + { PivotAreaValues.All, XLPivotAreaType.All }, + { PivotAreaValues.Origin, XLPivotAreaType.Origin }, + { PivotAreaValues.Button, XLPivotAreaType.Button }, + { PivotAreaValues.TopRight, XLPivotAreaType.TopRight }, + { PivotAreaValues.TopEnd, XLPivotAreaType.TopEnd }, + }; + + public static XLPivotAreaType ToClosedXml(this PivotAreaValues value) { - return Enum.Parse(typeof(XLPictureFormat), value.ToString()).CastTo(); + return PivotAreaMap[value]; } - public static XLPicturePlacement ToClosedXml(this Xdr.EditAsValues value) - { - switch (value) + private static readonly IReadOnlyDictionary SparklineTypeMap = + new Dictionary { - case Xdr.EditAsValues.Absolute: - return XLPicturePlacement.FreeFloating; + { X14.SparklineTypeValues.Line, XLSparklineType.Line }, + { X14.SparklineTypeValues.Column, XLSparklineType.Column }, + { X14.SparklineTypeValues.Stacked, XLSparklineType.Stacked }, + }; - case Xdr.EditAsValues.OneCell: - return XLPicturePlacement.Move; + public static XLSparklineType ToClosedXml(this X14.SparklineTypeValues value) + { + return SparklineTypeMap[value]; + } - case Xdr.EditAsValues.TwoCell: - return XLPicturePlacement.MoveAndSize; + private static readonly IReadOnlyDictionary SparklineAxisMinMaxMap = + new Dictionary + { + { X14.SparklineAxisMinMaxValues.Individual, XLSparklineAxisMinMax.Automatic }, + { X14.SparklineAxisMinMaxValues.Group, XLSparklineAxisMinMax.SameForAll }, + { X14.SparklineAxisMinMaxValues.Custom, XLSparklineAxisMinMax.Custom }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + public static XLSparklineAxisMinMax ToClosedXml(this X14.SparklineAxisMinMaxValues value) + { + return SparklineAxisMinMaxMap[value]; } - public static XLPivotAreaValues ToClosedXml(this PivotAreaValues value) - { - switch (value) + private static readonly IReadOnlyDictionary DisplayBlanksAsMap = + new Dictionary { - case PivotAreaValues.None: - return XLPivotAreaValues.None; - - case PivotAreaValues.Normal: - return XLPivotAreaValues.Normal; + { X14.DisplayBlanksAsValues.Span, XLDisplayBlanksAsValues.Interpolate }, + { X14.DisplayBlanksAsValues.Gap, XLDisplayBlanksAsValues.NotPlotted }, + { X14.DisplayBlanksAsValues.Zero, XLDisplayBlanksAsValues.Zero }, + }; - case PivotAreaValues.Data: - return XLPivotAreaValues.Data; + public static XLDisplayBlanksAsValues ToClosedXml(this X14.DisplayBlanksAsValues value) + { + return DisplayBlanksAsMap[value]; + } - case PivotAreaValues.All: - return XLPivotAreaValues.All; + private static readonly IReadOnlyDictionary FieldSortMap = + new Dictionary + { + { FieldSortValues.Manual, XLPivotSortType.Default }, + { FieldSortValues.Ascending, XLPivotSortType.Ascending }, + { FieldSortValues.Descending, XLPivotSortType.Descending }, + }; - case PivotAreaValues.Origin: - return XLPivotAreaValues.Origin; + public static XLPivotSortType ToClosedXml(this FieldSortValues value) + { + return FieldSortMap[value]; + } - case PivotAreaValues.Button: - return XLPivotAreaValues.Button; + private static readonly IReadOnlyDictionary PivotTableAxisMap = + new Dictionary + { + { PivotTableAxisValues.AxisRow, XLPivotAxis.AxisRow }, + { PivotTableAxisValues.AxisColumn, XLPivotAxis.AxisCol }, + { PivotTableAxisValues.AxisPage, XLPivotAxis.AxisPage }, + { PivotTableAxisValues.AxisValues, XLPivotAxis.AxisValues }, + }; - case PivotAreaValues.TopRight: - return XLPivotAreaValues.TopRight; + internal static XLPivotAxis ToClosedXml(this PivotTableAxisValues value) + { + return PivotTableAxisMap[value]; + } - case PivotAreaValues.TopEnd: - return XLPivotAreaValues.TopEnd; + private static readonly IReadOnlyDictionary ItemMap = + new Dictionary + { + { ItemValues.Data, XLPivotItemType.Data }, + { ItemValues.Default, XLPivotItemType.Default }, + { ItemValues.Sum, XLPivotItemType.Sum }, + { ItemValues.CountA, XLPivotItemType.CountA }, + { ItemValues.Average, XLPivotItemType.Avg }, + { ItemValues.Maximum, XLPivotItemType.Max }, + { ItemValues.Minimum, XLPivotItemType.Min }, + { ItemValues.Product, XLPivotItemType.Product }, + { ItemValues.Count, XLPivotItemType.Count }, + { ItemValues.StandardDeviation, XLPivotItemType.StdDev }, + { ItemValues.StandardDeviationP, XLPivotItemType.StdDevP }, + { ItemValues.Variance, XLPivotItemType.Var }, + { ItemValues.VarianceP, XLPivotItemType.VarP }, + { ItemValues.Grand, XLPivotItemType.Grand }, + { ItemValues.Blank, XLPivotItemType.Blank }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "PivotAreaValues value not implemented"); - } + internal static XLPivotItemType ToClosedXml(this ItemValues value) + { + return ItemMap[value]; } - public static XLSparklineType ToClosedXml(this X14.SparklineTypeValues value) - { - switch (value) + private static readonly IReadOnlyDictionary FormatActionMap = + new Dictionary { - case X14.SparklineTypeValues.Line: return XLSparklineType.Line; - case X14.SparklineTypeValues.Column: return XLSparklineType.Column; - case X14.SparklineTypeValues.Stacked: return XLSparklineType.Stacked; + { FormatActionValues.Blank, XLPivotFormatAction.Blank }, + { FormatActionValues.Formatting, XLPivotFormatAction.Formatting }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + internal static XLPivotFormatAction ToClosedXml(this FormatActionValues value) + { + return FormatActionMap[value]; } - public static XLSparklineAxisMinMax ToClosedXml(this X14.SparklineAxisMinMaxValues value) - { - switch (value) + private static readonly IReadOnlyDictionary ScopeMap = + new Dictionary { - case X14.SparklineAxisMinMaxValues.Individual: return XLSparklineAxisMinMax.Automatic; - case X14.SparklineAxisMinMaxValues.Group: return XLSparklineAxisMinMax.SameForAll; - case X14.SparklineAxisMinMaxValues.Custom: return XLSparklineAxisMinMax.Custom; + { ScopeValues.Selection, XLPivotCfScope.SelectedCells }, + { ScopeValues.Data, XLPivotCfScope.DataFields }, + { ScopeValues.Field, XLPivotCfScope.FieldIntersections }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + internal static XLPivotCfScope ToClosedXml(this ScopeValues value) + { + return ScopeMap[value]; } - public static XLDisplayBlanksAsValues ToClosedXml(this X14.DisplayBlanksAsValues value) - { - switch (value) + private static readonly IReadOnlyDictionary RuleMap = + new Dictionary { - case X14.DisplayBlanksAsValues.Span: return XLDisplayBlanksAsValues.Interpolate; - case X14.DisplayBlanksAsValues.Gap: return XLDisplayBlanksAsValues.NotPlotted; - case X14.DisplayBlanksAsValues.Zero: return XLDisplayBlanksAsValues.Zero; + { RuleValues.None, XLPivotCfRuleType.None }, + { RuleValues.All, XLPivotCfRuleType.All }, + { RuleValues.Row, XLPivotCfRuleType.Row }, + { RuleValues.Column, XLPivotCfRuleType.Column }, + }; - default: - throw new ArgumentOutOfRangeException(nameof(value), "Not implemented value!"); - } + internal static XLPivotCfRuleType ToClosedXml(this RuleValues value) + { + return RuleMap[value]; } #endregion To ClosedXml diff --git a/ClosedXML/Excel/Exceptions/ClosedXMLException.cs b/ClosedXML/Excel/Exceptions/ClosedXMLException.cs index 3a0081653..22b60bde2 100644 --- a/ClosedXML/Excel/Exceptions/ClosedXMLException.cs +++ b/ClosedXML/Excel/Exceptions/ClosedXMLException.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel.Exceptions { diff --git a/ClosedXML/Excel/Exceptions/EmptyTableException.cs b/ClosedXML/Excel/Exceptions/EmptyTableException.cs index 1f657e298..22ce5eb06 100644 --- a/ClosedXML/Excel/Exceptions/EmptyTableException.cs +++ b/ClosedXML/Excel/Exceptions/EmptyTableException.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel.Exceptions { diff --git a/ClosedXML/Excel/Exceptions/InvalidReferenceException.cs b/ClosedXML/Excel/Exceptions/InvalidReferenceException.cs new file mode 100644 index 000000000..9f4da2a6c --- /dev/null +++ b/ClosedXML/Excel/Exceptions/InvalidReferenceException.cs @@ -0,0 +1,25 @@ +using System; + +namespace ClosedXML.Excel.Exceptions +{ + /// + /// A reference to the data in a worksheet is not valid. E.g. sheet with + /// specific name doesn't exist, name doesn't exist. + /// + public class InvalidReferenceException : Exception + { + public InvalidReferenceException() : base("Reference to the data is not valid.") + { + } + + public InvalidReferenceException(string message) + : base(message) + { + } + + public InvalidReferenceException(string message, Exception inner) + : base(message, inner) + { + } + } +} diff --git a/ClosedXML/Excel/Hyperlinks/IXLHyperlinks.cs b/ClosedXML/Excel/Hyperlinks/IXLHyperlinks.cs index 5e035767c..9c7808c86 100644 --- a/ClosedXML/Excel/Hyperlinks/IXLHyperlinks.cs +++ b/ClosedXML/Excel/Hyperlinks/IXLHyperlinks.cs @@ -1,14 +1,51 @@ using System.Collections.Generic; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +public interface IXLHyperlinks: IEnumerable { - public interface IXLHyperlinks: IEnumerable - { - void Add(XLHyperlink hyperlink); - void Delete(XLHyperlink hyperlink); - void Delete(IXLAddress address); - bool TryDelete(IXLAddress address); - XLHyperlink Get(IXLAddress address); - bool TryGet(IXLAddress address, out XLHyperlink hyperlink); - } + /// + /// Remove the hyperlink from a worksheet. Doesn't throw if hyperlinks is + /// not attached to a worksheet. + /// + /// + /// If hyperlink range uses a hyperlink + /// theme color, the style is reset to the sheet style font color. + /// The is also set to sheet style + /// underline. + /// + /// Hyperlink to remove. + /// true if hyperlink was part of the worksheet and was + /// removed. false otherwise. + bool Delete(XLHyperlink hyperlink); + + /// + /// Delete a hyperlink defined for a single cell. It doesn't delete + /// hyperlinks that cover the cell. + /// + /// + /// If hyperlink range uses a hyperlink + /// theme color, the style is reset to the sheet style font color. + /// The is also set to sheet style + /// underline. + /// + /// Address of the cell. + /// true if there was such hyperlink and was deleted. + /// false otherwise. + bool Delete(IXLAddress address); + + /// + /// Get a hyperlink for a single cell. + /// + /// Address of the cell. + /// Cell doesn't have a hyperlink. + XLHyperlink Get(IXLAddress address); + + /// + /// Get a hyperlink for a single cell. + /// + /// Address of the cell. + /// Found hyperlink. + /// true if there was a hyperlink for , false otherwise. + bool TryGet(IXLAddress address, out XLHyperlink hyperlink); } diff --git a/ClosedXML/Excel/Hyperlinks/XLHyperlink_Internal.cs b/ClosedXML/Excel/Hyperlinks/XLHyperlink_Internal.cs index 370556813..3c517c6ae 100644 --- a/ClosedXML/Excel/Hyperlinks/XLHyperlink_Internal.cs +++ b/ClosedXML/Excel/Hyperlinks/XLHyperlink_Internal.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -61,6 +63,6 @@ internal void SetValues(IXLRangeBase range, String tooltip) IsExternal = false; } - internal XLWorksheet Worksheet { get; set; } + internal XLHyperlinks Container { get; set; } } } diff --git a/ClosedXML/Excel/Hyperlinks/XLHyperlink_public.cs b/ClosedXML/Excel/Hyperlinks/XLHyperlink_public.cs index cbc96f529..2f4ae6b38 100644 --- a/ClosedXML/Excel/Hyperlinks/XLHyperlink_public.cs +++ b/ClosedXML/Excel/Hyperlinks/XLHyperlink_public.cs @@ -1,6 +1,6 @@ -using ClosedXML.Extensions; +#nullable disable + using System; -using System.Linq; namespace ClosedXML.Excel { @@ -64,7 +64,22 @@ public Uri ExternalAddress } } - public IXLCell Cell { get; internal set; } +#nullable enable + /// + /// Gets top left cell of a hyperlink range. Return null, + /// if the hyperlink isn't in a worksheet. + /// + public IXLCell? Cell + { + get + { + if (Container is null) + return null; + + return Container.GetCell(this); + } + } +#nullable disable public String InternalAddress { @@ -83,8 +98,13 @@ public String InternalAddress _internalAddress.Substring(_internalAddress.IndexOf('!') + 1)) : _internalAddress; } + + if (Container is null) + throw new InvalidOperationException("Hyperlink is not attached to a worksheet."); + + var sheetName = Container.WorksheetName; return String.Concat( - Worksheet.Name.EscapeSheetName(), + sheetName.EscapeSheetName(), '!', _internalAddress); } @@ -95,16 +115,16 @@ public String InternalAddress } } + /// + /// Tooltip displayed when user hovers over the hyperlink range. If not specified, + /// the link target is displayed in the tooltip. + /// public String Tooltip { get; set; } + /// public void Delete() { - if (Cell == null) return; - Worksheet.Hyperlinks.Delete(Cell.Address); - if (Cell.Style.Font.FontColor.Equals(XLColor.FromTheme(XLThemeColor.Hyperlink))) - Cell.Style.Font.FontColor = Worksheet.StyleValue.Font.FontColor; - - Cell.Style.Font.Underline = Worksheet.StyleValue.Font.Underline; + Container?.Delete(this); } } } diff --git a/ClosedXML/Excel/Hyperlinks/XLHyperlinks.cs b/ClosedXML/Excel/Hyperlinks/XLHyperlinks.cs index 90c1bcaf2..66e435ff9 100644 --- a/ClosedXML/Excel/Hyperlinks/XLHyperlinks.cs +++ b/ClosedXML/Excel/Hyperlinks/XLHyperlinks.cs @@ -1,55 +1,203 @@ +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +internal class XLHyperlinks : IXLHyperlinks, ISheetListener { - internal class XLHyperlinks: IXLHyperlinks + private readonly XLWorksheet _worksheet; + private readonly Dictionary _hyperlinks = new(); + + private delegate (bool Success, XLSheetRange? RepositionedArea) RepositionFunc(XLSheetRange hyperlinkArea); + + internal XLHyperlinks(XLWorksheet worksheet) { - private readonly Dictionary _hyperlinks = new Dictionary(); + _worksheet = worksheet; + } + + internal string WorksheetName => _worksheet.Name; - public IEnumerator GetEnumerator() + #region ISheetListener + + void ISheetListener.OnInsertAreaAndShiftDown(XLWorksheet sheet, XLSheetRange insertedArea) + { + RepositionOnChange(sheet, hyperlinkArea => { - return _hyperlinks.Values.GetEnumerator(); - } + var success = hyperlinkArea.TryInsertAreaAndShiftDown(insertedArea, out var newHlArea); + return (success, newHlArea); + }); + } - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + void ISheetListener.OnInsertAreaAndShiftRight(XLWorksheet sheet, XLSheetRange insertedArea) + { + RepositionOnChange(sheet, hyperlinkArea => { - return GetEnumerator(); - } + var success = hyperlinkArea.TryInsertAreaAndShiftRight(insertedArea, out var newHlArea); + return (success, newHlArea); + }); + } - public void Add(XLHyperlink hyperlink) + void ISheetListener.OnDeleteAreaAndShiftLeft(XLWorksheet sheet, XLSheetRange deletedArea) + { + RepositionOnChange(sheet, hyperlinkArea => { - _hyperlinks.Add(hyperlink.Cell.Address, hyperlink); - } + var success = hyperlinkArea.TryDeleteAreaAndShiftLeft(deletedArea, out var newHlArea); + return (success, newHlArea); + }); + } + + void ISheetListener.OnDeleteAreaAndShiftUp(XLWorksheet sheet, XLSheetRange deletedArea) + { + RepositionOnChange(sheet, hyperlinkArea => + { + var success = hyperlinkArea.TryDeleteAreaAndShiftUp(deletedArea, out var newHlArea); + return (success, newHlArea); + }); + } - public void Delete(XLHyperlink hyperlink) + private void RepositionOnChange(XLWorksheet sheet, RepositionFunc reposition) + { + if (sheet != _worksheet) + return; + + var hyperlinkAreas = _hyperlinks.Keys.ToArray(); + foreach (var hyperlinkArea in hyperlinkAreas) { - _hyperlinks.Remove(hyperlink.Cell.Address); + var (success, newHlArea) = reposition(hyperlinkArea); + if (!success) + continue; // Partial cover, don't move. + + if (hyperlinkArea == newHlArea) + continue; // Nothing changed + + _hyperlinks.Remove(hyperlinkArea, out var hyperlink); + if (newHlArea is not null) + _hyperlinks.Add(newHlArea.Value, hyperlink); } + } + + #endregion ISheetListener + + public IEnumerator GetEnumerator() + { + return _hyperlinks.Values.GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + public bool Delete(XLHyperlink hyperlink) + { + if (!TryGet(hyperlink, out var range)) + return false; - public void Delete(IXLAddress address) + Clear(range.Value); + ClearHyperlinkStyle(range.Value); + return true; + } + + /// + public bool Delete(IXLAddress address) + { + var point = XLSheetPoint.FromAddress(address); + if (Clear(point)) { - _hyperlinks.Remove(address); + ClearHyperlinkStyle(point); + return true; } - public bool TryDelete(IXLAddress address) + return false; + } + + /// + public XLHyperlink Get(IXLAddress address) + { + return _hyperlinks[XLSheetPoint.FromAddress(address)]; + } + + /// + public bool TryGet(IXLAddress address, out XLHyperlink hyperlink) + { + return _hyperlinks.TryGetValue(XLSheetPoint.FromAddress(address), out hyperlink); + } + + /// + /// Add a hyperlink. Doesn't modify style, unlike public API. + /// + internal void Add(XLSheetRange range, XLHyperlink hyperlink) + { + if (hyperlink.Container is not null && hyperlink.Container != this) { - if (_hyperlinks.ContainsKey(address)) - { - _hyperlinks.Remove(address); - return true; - } + throw new InvalidOperationException("Hyperlink is attached to a different worksheet. Either remove it from the original worksheet or create a new hyperlink."); + } - return false; + _hyperlinks.Remove(range); + _hyperlinks.Add(range, hyperlink); + hyperlink.Container = this; + } + + internal bool TryGet(XLSheetRange range, [NotNullWhen(true)] out XLHyperlink? hyperlink) + { + return _hyperlinks.TryGetValue(range, out hyperlink); + } + + /// + /// Remove a hyperlink. Doesn't modify style, unlike public API. + /// + internal bool Clear(XLSheetRange range) + { + if (_hyperlinks.Remove(range, out var hyperlink)) + { + hyperlink.Container = null; + return true; } - public XLHyperlink Get(IXLAddress address) + return false; + } + + internal XLCell? GetCell(XLHyperlink hyperlink) + { + if (!TryGet(hyperlink, out var range)) + return null; + + return new XLCell(_worksheet, range.Value.FirstPoint); + } + + private bool TryGet(XLHyperlink hyperlink, [NotNullWhen(true)] out XLSheetRange? range) + { + var ranges = _hyperlinks + .Where(x => x.Value == hyperlink) + .Select(x => x.Key) + .ToList(); + if (ranges.Count == 0) { - return _hyperlinks[address]; + range = null; + return false; } - public bool TryGet(IXLAddress address, out XLHyperlink hyperlink) + range = ranges.Single(); + return true; + } + + private void ClearHyperlinkStyle(XLSheetRange range) + { + var sheetColor = _worksheet.StyleValue.Font.FontColor; + var sheetUnderline = _worksheet.StyleValue.Font.Underline; + foreach (var point in range) { - return _hyperlinks.TryGetValue(address, out hyperlink); + var cell = _worksheet.GetCell(point); + if (cell is null) + continue; + + if (cell.Style.Font.FontColor.Equals(XLColor.FromTheme(XLThemeColor.Hyperlink))) + cell.Style.Font.FontColor = sheetColor; + + cell.Style.Font.Underline = sheetUnderline; } } } diff --git a/ClosedXML/Excel/IO/AutoFilterReader.cs b/ClosedXML/Excel/IO/AutoFilterReader.cs new file mode 100644 index 000000000..9d14004b3 --- /dev/null +++ b/ClosedXML/Excel/IO/AutoFilterReader.cs @@ -0,0 +1,185 @@ +using ClosedXML.Utils; +using DocumentFormat.OpenXml.Spreadsheet; +using System; +using System.Globalization; +using System.Linq; + +namespace ClosedXML.Excel.IO; + +#nullable disable +internal class AutoFilterReader +{ + internal static void LoadAutoFilter(AutoFilter af, XLWorksheet ws) + { + if (af != null) + { + ws.Range(af.Reference.Value).SetAutoFilter(); + var autoFilter = ws.AutoFilter; + LoadAutoFilterSort(af, ws, autoFilter); + LoadAutoFilterColumns(af, autoFilter); + } + } + + internal static void LoadAutoFilterColumns(AutoFilter af, XLAutoFilter autoFilter) + { + foreach (var filterColumn in af.Elements()) + { + Int32 column = (int)filterColumn.ColumnId.Value + 1; + var xlFilterColumn = autoFilter.Column(column); + if (filterColumn.CustomFilters is { } customFilters) + { + xlFilterColumn.FilterType = XLFilterType.Custom; + var connector = OpenXmlHelper.GetBooleanValueAsBool(customFilters.And, false) ? XLConnector.And : XLConnector.Or; + + foreach (var filter in customFilters.OfType()) + { + // Equal or NotEqual use wildcards, not value comparison. The rest does value comparison. + // There is no filter operation for equal of numbers (maybe combine >= and <=). + var op = filter.Operator is not null ? filter.Operator.Value.ToClosedXml() : XLFilterOperator.Equal; + XLFilter xlFilter; + var filterValue = filter.Val.Value; + switch (op) + { + case XLFilterOperator.Equal: + xlFilter = XLFilter.CreateCustomPatternFilter(filterValue, true, connector); + break; + case XLFilterOperator.NotEqual: + xlFilter = XLFilter.CreateCustomPatternFilter(filterValue, false, connector); + break; + default: + // OOXML allows only string, so do your best to convert back to a properly typed + // variable. It's not perfect, but let's mimic Excel. + var customValue = XLCellValue.FromText(filterValue, CultureInfo.InvariantCulture); + xlFilter = XLFilter.CreateCustomFilter(customValue, op, connector); + break; + } + + xlFilterColumn.AddFilter(xlFilter); + } + } + else if (filterColumn.Filters is { } filters) + { + xlFilterColumn.FilterType = XLFilterType.Regular; + foreach (var filter in filters.OfType()) + { + xlFilterColumn.AddFilter(XLFilter.CreateRegularFilter(filter.Val.Value)); + } + + foreach (var dateGroupItem in filters.OfType()) + { + if (dateGroupItem.DateTimeGrouping is null || !dateGroupItem.DateTimeGrouping.HasValue) + continue; + + var xlGrouping = dateGroupItem.DateTimeGrouping.Value.ToClosedXml(); + var year = 1900; + var month = 1; + var day = 1; + var hour = 0; + var minute = 0; + var second = 0; + + var valid = true; + + if (xlGrouping >= XLDateTimeGrouping.Year) + { + if (dateGroupItem.Year?.HasValue ?? false) + year = dateGroupItem.Year.Value; + else + valid = false; + } + + if (xlGrouping >= XLDateTimeGrouping.Month) + { + if (dateGroupItem.Month?.HasValue ?? false) + month = dateGroupItem.Month.Value; + else + valid = false; + } + + if (xlGrouping >= XLDateTimeGrouping.Day) + { + if (dateGroupItem.Day?.HasValue ?? false) + day = dateGroupItem.Day.Value; + else + valid = false; + } + + if (xlGrouping >= XLDateTimeGrouping.Hour) + { + if (dateGroupItem.Hour?.HasValue ?? false) + hour = dateGroupItem.Hour.Value; + else + valid = false; + } + + if (xlGrouping >= XLDateTimeGrouping.Minute) + { + if (dateGroupItem.Minute?.HasValue ?? false) + minute = dateGroupItem.Minute.Value; + else + valid = false; + } + + if (xlGrouping >= XLDateTimeGrouping.Second) + { + if (dateGroupItem.Second?.HasValue ?? false) + second = dateGroupItem.Second.Value; + else + valid = false; + } + + if (valid) + { + var date = new DateTime(year, month, day, hour, minute, second); + var xlDateGroupFilter = XLFilter.CreateDateGroupFilter(date, xlGrouping); + xlFilterColumn.AddFilter(xlDateGroupFilter); + } + } + } + else if (filterColumn.Top10 is { } top10) + { + xlFilterColumn.FilterType = XLFilterType.TopBottom; + xlFilterColumn.TopBottomType = OpenXmlHelper.GetBooleanValueAsBool(top10.Percent, false) + ? XLTopBottomType.Percent + : XLTopBottomType.Items; + var takeTop = OpenXmlHelper.GetBooleanValueAsBool(top10.Top, true); + xlFilterColumn.TopBottomPart = takeTop ? XLTopBottomPart.Top : XLTopBottomPart.Bottom; + + // Value contains how many percent or items, so it can only be int. + // Filter value is optional, so we don't rely on it. + var percentsOrItems = (int)top10.Val.Value; + xlFilterColumn.TopBottomValue = percentsOrItems; + xlFilterColumn.AddFilter(XLFilter.CreateTopBottom(takeTop, percentsOrItems)); + } + else if (filterColumn.DynamicFilter is { } dynamicFilter) + { + xlFilterColumn.FilterType = XLFilterType.Dynamic; + var dynamicType = dynamicFilter.Type is { } dynamicFilterType + ? dynamicFilterType.Value.ToClosedXml() + : XLFilterDynamicType.AboveAverage; + var dynamicValue = filterColumn.DynamicFilter.Val.Value; + + xlFilterColumn.DynamicType = dynamicType; + xlFilterColumn.DynamicValue = dynamicValue; + xlFilterColumn.AddFilter(XLFilter.CreateAverage(dynamicValue, dynamicType == XLFilterDynamicType.AboveAverage)); + } + } + } + + private static void LoadAutoFilterSort(AutoFilter af, XLWorksheet ws, XLAutoFilter autoFilter) + { + var sort = af.Elements().FirstOrDefault(); + if (sort != null) + { + var condition = sort.Elements().FirstOrDefault(); + if (condition != null) + { + Int32 column = ws.Range(condition.Reference.Value).FirstCell().Address.ColumnNumber - autoFilter.Range.FirstCell().Address.ColumnNumber + 1; + autoFilter.SortColumn = column; + autoFilter.Sorted = true; + autoFilter.SortOrder = condition.Descending != null && condition.Descending.Value ? XLSortOrder.Descending : XLSortOrder.Ascending; + } + } + } + +} diff --git a/ClosedXML/Excel/IO/CalculationChainPartWriter.cs b/ClosedXML/Excel/IO/CalculationChainPartWriter.cs new file mode 100644 index 000000000..079144deb --- /dev/null +++ b/ClosedXML/Excel/IO/CalculationChainPartWriter.cs @@ -0,0 +1,73 @@ +#nullable disable + +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using System; +using System.Linq; +using static ClosedXML.Excel.XLWorkbook; + +namespace ClosedXML.Excel.IO +{ + internal class CalculationChainPartWriter + { + internal static void GenerateContent(WorkbookPart workbookPart, XLWorkbook workbook, SaveContext context) + { + if (workbookPart.CalculationChainPart == null) + workbookPart.AddNewPart(context.RelIdGenerator.GetNext(RelType.Workbook)); + + if (workbookPart.CalculationChainPart.CalculationChain == null) + workbookPart.CalculationChainPart.CalculationChain = new CalculationChain(); + + var calculationChain = workbookPart.CalculationChainPart.CalculationChain; + calculationChain.RemoveAllChildren(); + + foreach (var worksheet in workbook.WorksheetsInternal) + { + foreach (var c in worksheet.Internals.CellsCollection.GetCells().Where(c => c.HasFormula)) + { + if (c.Formula.Type == FormulaType.DataTable) + { + // Do nothing, Excel doesn't generate calc chain for data table + } + else if (c.HasArrayFormula) + { + if (c.FormulaReference == null) + c.FormulaReference = c.AsRange().RangeAddress; + + if (c.FormulaReference.FirstAddress.Equals(c.Address)) + { + var cc = new CalculationCell + { + CellReference = c.Address.ToString(), + SheetId = (Int32)worksheet.SheetId + }; + + cc.Array = true; + calculationChain.AppendChild(cc); + + foreach (var childCell in worksheet.Range(c.FormulaReference).Cells()) + { + calculationChain.AppendChild(new CalculationCell + { + CellReference = childCell.Address.ToString(), + SheetId = (Int32)worksheet.SheetId, + }); + } + } + } + else + { + calculationChain.AppendChild(new CalculationCell + { + CellReference = c.Address.ToString(), + SheetId = (Int32)worksheet.SheetId + }); + } + } + } + + if (!calculationChain.Any()) + workbookPart.DeletePart(workbookPart.CalculationChainPart); + } + } +} diff --git a/ClosedXML/Excel/IO/CommentPartWriter.cs b/ClosedXML/Excel/IO/CommentPartWriter.cs new file mode 100644 index 000000000..0077349f5 --- /dev/null +++ b/ClosedXML/Excel/IO/CommentPartWriter.cs @@ -0,0 +1,81 @@ +#nullable disable + +using DocumentFormat.OpenXml.Packaging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml; +using ClosedXML.Extensions; +using static ClosedXML.Excel.IO.OpenXmlConst; + +namespace ClosedXML.Excel.IO +{ + internal class CommentPartWriter + { + internal static void GenerateWorksheetCommentsPartContent(WorksheetCommentsPart worksheetCommentsPart, + XLWorksheet xlWorksheet) + { + var settings = new XmlWriterSettings + { + CloseOutput = true, + Encoding = XLHelper.NoBomUTF8 + }; + var partStream = worksheetCommentsPart.GetStream(FileMode.Create); + using var xml = XmlWriter.Create(partStream, settings); + + var commentCells = new List(); + var authorsDict = new Dictionary(); + xml.WriteStartElement("x", "comments", Main2006SsNs); + foreach (var c in xlWorksheet.Internals.CellsCollection.GetCells(c => c.HasComment)) + { + var authorName = c.GetComment().Author; + + if (!authorsDict.TryGetValue(authorName, out var authorId)) + { + authorId = authorsDict.Count; + authorsDict.Add(authorName, authorId); + } + + commentCells.Add(c); + } + + xml.WriteStartElement("authors", Main2006SsNs); + foreach (var author in authorsDict) + xml.WriteElementString("author", Main2006SsNs, author.Key); + + xml.WriteEndElement(); // authors + + var refBuffer = new char[10]; + xml.WriteStartElement("commentList", Main2006SsNs); + foreach (var commentCell in commentCells) + { + var comment = commentCell.GetComment(); + xml.WriteStartElement("comment", Main2006SsNs); + + var refLen = commentCell.SheetPoint.Format(refBuffer); + xml.WriteStartAttribute("ref"); + xml.WriteRaw(refBuffer, 0, refLen); + xml.WriteEndAttribute(); // ref + + var authorId = authorsDict[comment.Author]; + xml.WriteAttribute("authorId", authorId); + + // Excel specifies @guid is optional if the workbook is not shared + // Excel ignores the shapeId attribute. + + xml.WriteStartElement("text", Main2006SsNs); + var richText = XLImmutableRichText.Create(comment); + foreach (var run in richText.Runs) + TextSerializer.WriteRun(xml, richText, run); + + xml.WriteEndElement(); // text + xml.WriteEndElement(); // comment + } + + xml.WriteEndElement(); // commentList + xml.WriteEndElement(); // comments + + xml.Close(); + } + } +} diff --git a/ClosedXML/Excel/IO/CustomFilePropertiesPartWriter.cs b/ClosedXML/Excel/IO/CustomFilePropertiesPartWriter.cs new file mode 100644 index 000000000..66e062807 --- /dev/null +++ b/ClosedXML/Excel/IO/CustomFilePropertiesPartWriter.cs @@ -0,0 +1,59 @@ +using DocumentFormat.OpenXml.CustomProperties; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.VariantTypes; +using System; + +namespace ClosedXML.Excel.IO +{ + internal class CustomFilePropertiesPartWriter + { + internal static void GenerateContent(CustomFilePropertiesPart customFilePropertiesPart, XLWorkbook workbook) + { + var properties = new Properties(); + properties.AddNamespaceDeclaration("vt", + "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"); + var propertyId = 1; + foreach (var p in workbook.CustomProperties) + { + propertyId++; + var customDocumentProperty = new CustomDocumentProperty + { + FormatId = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}", + PropertyId = propertyId, + Name = p.Name + }; + if (p.Type == XLCustomPropertyType.Text) + { + var vTlpwstr1 = new VTLPWSTR { Text = p.GetValue() }; + customDocumentProperty.AppendChild(vTlpwstr1); + } + else if (p.Type == XLCustomPropertyType.Date) + { + var vTFileTime1 = new VTFileTime + { + Text = + p.GetValue().ToUniversalTime().ToString( + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'") + }; + customDocumentProperty.AppendChild(vTFileTime1); + } + else if (p.Type == XLCustomPropertyType.Number) + { + var vTDouble1 = new VTDouble + { + Text = p.GetValue().ToInvariantString() + }; + customDocumentProperty.AppendChild(vTDouble1); + } + else + { + var vTBool1 = new VTBool { Text = p.GetValue().ToString().ToLower() }; + customDocumentProperty.AppendChild(vTBool1); + } + properties.AppendChild(customDocumentProperty); + } + + customFilePropertiesPart.Properties = properties; + } + } +} diff --git a/ClosedXML/Excel/IO/ExtendedFilePropertiesPartWriter.cs b/ClosedXML/Excel/IO/ExtendedFilePropertiesPartWriter.cs new file mode 100644 index 000000000..c5195d887 --- /dev/null +++ b/ClosedXML/Excel/IO/ExtendedFilePropertiesPartWriter.cs @@ -0,0 +1,143 @@ +#nullable disable + +using DocumentFormat.OpenXml.ExtendedProperties; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.VariantTypes; +using DocumentFormat.OpenXml; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel.IO +{ + internal class ExtendedFilePropertiesPartWriter + { + internal static void GenerateContent(ExtendedFilePropertiesPart extendedFilePropertiesPart, XLWorkbook workbook) + { + if (extendedFilePropertiesPart.Properties == null) + extendedFilePropertiesPart.Properties = new Properties(); + + var properties = extendedFilePropertiesPart.Properties; + if ( + !properties.NamespaceDeclarations.Contains(new KeyValuePair("vt", + "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"))) + { + properties.AddNamespaceDeclaration("vt", + "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"); + } + + if (properties.Application == null) + properties.AppendChild(new Application { Text = "Microsoft Excel" }); + + if (properties.DocumentSecurity == null) + properties.AppendChild(new DocumentSecurity { Text = "0" }); + + if (properties.ScaleCrop == null) + properties.AppendChild(new ScaleCrop { Text = "false" }); + + if (properties.HeadingPairs == null) + properties.HeadingPairs = new HeadingPairs(); + + if (properties.TitlesOfParts == null) + properties.TitlesOfParts = new TitlesOfParts(); + + properties.HeadingPairs.VTVector = new VTVector { BaseType = VectorBaseValues.Variant }; + + properties.TitlesOfParts.VTVector = new VTVector { BaseType = VectorBaseValues.Lpstr }; + + var vTVectorOne = properties.HeadingPairs.VTVector; + + var vTVectorTwo = properties.TitlesOfParts.VTVector; + + var modifiedWorksheets = + ((IEnumerable)workbook.WorksheetsInternal).Select(w => new { w.Name, Order = w.Position }).ToList(); + var modifiedNamedRanges = GetModifiedNamedRanges(workbook); + var modifiedWorksheetsCount = modifiedWorksheets.Count; + var modifiedNamedRangesCount = modifiedNamedRanges.Count; + + InsertOnVtVector(vTVectorOne, "Worksheets", 0, modifiedWorksheetsCount.ToInvariantString()); + InsertOnVtVector(vTVectorOne, "Named Ranges", 2, modifiedNamedRangesCount.ToInvariantString()); + + vTVectorTwo.Size = (UInt32)(modifiedNamedRangesCount + modifiedWorksheetsCount); + + foreach ( + var vTlpstr3 in modifiedWorksheets.OrderBy(w => w.Order).Select(w => new VTLPSTR { Text = w.Name })) + vTVectorTwo.AppendChild(vTlpstr3); + + foreach (var vTlpstr7 in modifiedNamedRanges.Select(nr => new VTLPSTR { Text = nr })) + vTVectorTwo.AppendChild(vTlpstr7); + + if (workbook.Properties.Manager != null) + { + if (!String.IsNullOrWhiteSpace(workbook.Properties.Manager)) + { + if (properties.Manager == null) + properties.Manager = new Manager(); + + properties.Manager.Text = workbook.Properties.Manager; + } + else + properties.Manager = null; + } + + if (workbook.Properties.Company == null) return; + + if (!String.IsNullOrWhiteSpace(workbook.Properties.Company)) + { + if (properties.Company == null) + properties.Company = new Company(); + + properties.Company.Text = workbook.Properties.Company; + } + else + properties.Company = null; + } + + private static void InsertOnVtVector(VTVector vTVector, String property, Int32 index, String text) + { + var m = from e1 in vTVector.Elements() + where e1.Elements().Any(e2 => e2.Text == property) + select e1; + if (!m.Any()) + { + if (vTVector.Size == null) + vTVector.Size = new UInt32Value(0U); + + vTVector.Size += 2U; + var variant1 = new Variant(); + var vTlpstr1 = new VTLPSTR { Text = property }; + variant1.AppendChild(vTlpstr1); + vTVector.InsertAt(variant1, index); + + var variant2 = new Variant(); + var vTInt321 = new VTInt32(); + variant2.AppendChild(vTInt321); + vTVector.InsertAt(variant2, index + 1); + } + + var targetIndex = 0; + foreach (var e in vTVector.Elements()) + { + if (e.Elements().Any(e2 => e2.Text == property)) + { + vTVector.ElementAt(targetIndex + 1).GetFirstChild().Text = text; + break; + } + targetIndex++; + } + } + + private static List GetModifiedNamedRanges(XLWorkbook workbook) + { + var namedRanges = new List(); + foreach (var sheet in workbook.WorksheetsInternal) + { + namedRanges.AddRange(sheet.DefinedNames.Select(n => sheet.Name + "!" + n.Name)); + namedRanges.Add(sheet.Name + "!Print_Area"); + namedRanges.Add(sheet.Name + "!Print_Titles"); + } + namedRanges.AddRange(workbook.DefinedNamesInternal.Select(n => n.Name)); + return namedRanges; + } + } +} diff --git a/ClosedXML/Excel/IO/LoadContext.cs b/ClosedXML/Excel/IO/LoadContext.cs new file mode 100644 index 000000000..c86522389 --- /dev/null +++ b/ClosedXML/Excel/IO/LoadContext.cs @@ -0,0 +1,92 @@ +using DocumentFormat.OpenXml.Spreadsheet; +using System; +using System.Collections.Generic; +using System.Linq; +using ClosedXML.IO; + +namespace ClosedXML.Excel.IO; + +internal class LoadContext +{ + /// + /// Conditional formats for pivot tables, loaded from sheets. Key is sheet name, value is the + /// conditional formats. + /// + private readonly Dictionary> _pivotCfs = new(XLHelper.SheetComparer); + + /// + /// A dictionary of styles from styles.xml. Used in other places that reference number style by id reference. + /// + private readonly Dictionary _numberFormats = new(); + + internal void AddPivotTableCf(string sheetName, XLConditionalFormat conditionalFormat) + { + if (!_pivotCfs.TryGetValue(sheetName, out var list)) + { + list = new List(); + _pivotCfs[sheetName] = list; + } + + list.Add(conditionalFormat); + } + + internal XLConditionalFormat GetPivotCf(string sheetName, int priority) + { + if (!_pivotCfs.TryGetValue(sheetName, out var list)) + throw PivotCfNotFoundException(sheetName, priority); + + var pivotCf = list.SingleOrDefault(x => x.Priority == priority); + if (pivotCf is null) + throw PivotCfNotFoundException(sheetName, priority); + + return pivotCf; + } + + internal void LoadNumberFormats(NumberingFormats? numberingFormats) + { + if (numberingFormats is null) + return; + + foreach (var nf in numberingFormats.ChildElements.Cast()) + { + var numberFormatId = checked((int?)nf.NumberFormatId?.Value); + var formatCode = nf.FormatCode?.Value; + if (numberFormatId is null || string.IsNullOrEmpty(formatCode)) + continue; + + _numberFormats.Add(numberFormatId.Value, formatCode); + } + } + + internal XLNumberFormatValue? GetNumberFormat(int? numberFormatId) + { + if (numberFormatId is null) + { + return null; + } + + if (_numberFormats.TryGetValue(numberFormatId.Value, out var formatCode)) + { + var customFormatKey = new XLNumberFormatKey + { + NumberFormatId = -1, + Format = formatCode, + }; + return XLNumberFormatValue.FromKey(ref customFormatKey); + } + else + { + var predefinedFormatKey = new XLNumberFormatKey + { + NumberFormatId = numberFormatId.Value, + Format = string.Empty, + }; + return XLNumberFormatValue.FromKey(ref predefinedFormatKey); + } + } + + private static Exception PivotCfNotFoundException(string sheetName, int priority) + { + return PartStructureException.ExpectedElementNotFound($"conditional formatting for pivot table in sheet {sheetName} with priority {priority}"); + } +} diff --git a/ClosedXML/Excel/IO/OpenXmlConst.cs b/ClosedXML/Excel/IO/OpenXmlConst.cs new file mode 100644 index 000000000..b7f9c28e8 --- /dev/null +++ b/ClosedXML/Excel/IO/OpenXmlConst.cs @@ -0,0 +1,36 @@ +using System; + +namespace ClosedXML.Excel.IO +{ + /// + /// Constants used across writers. + /// + internal static class OpenXmlConst + { + public const string Main2006SsNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + + public const string XmMain2006 = "http://schemas.microsoft.com/office/excel/2006/main"; + + public const string X14Main2009SsNs = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; + + public const string X14Ac2009SsNs = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac"; + + public const string Xml1998Ns = "http://www.w3.org/XML/1998/namespace"; + + public const string MarkupCompatibilityNs = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + + public const string RelationshipsNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + public const string RevisionNs = "http://schemas.microsoft.com/office/spreadsheetml/2014/revision"; + + /// + /// Valid and shorter than normal true. + /// + public static readonly String TrueValue = "1"; + + /// + /// Valid and shorter than normal false. + /// + public static readonly String FalseValue = "0"; + } +} diff --git a/ClosedXML/Excel/IO/PivotCacheRecordsReader.cs b/ClosedXML/Excel/IO/PivotCacheRecordsReader.cs new file mode 100644 index 000000000..c083c56c6 --- /dev/null +++ b/ClosedXML/Excel/IO/PivotCacheRecordsReader.cs @@ -0,0 +1,112 @@ +using ClosedXML.Extensions; +using ClosedXML.IO; +using System; + +namespace ClosedXML.Excel.IO; + +internal partial class PivotCacheRecordsReader +{ + private readonly string _ns = OpenXmlConst.Main2006SsNs; + private readonly XmlTreeReader _reader; + private readonly XLPivotCache _pivotCache; + + /// + /// Index of current field that is read from the r element. + /// + private int _fieldIdx; + + public PivotCacheRecordsReader(XmlTreeReader reader, XLPivotCache pivotCache) + { + _reader = reader; + _pivotCache = pivotCache; + } + + internal void ReadRecordsToCache() + { + // Don't add values to the shared items of a cache when record value is added, because we want 1:1 + // read/write. Read them from definition. Whatever is in shared items now should be written out, + // unless there is a cache refresh. Basically trust the author of the workbook that it is valid. + _reader.Open("pivotCacheRecords", _ns); + var recordCount = _reader.GetCount(); + _pivotCache.AllocateRecordCapacity(recordCount); + + while (_reader.TryOpen("r", _ns)) + { + ParseRecord("r"); + } + + if (_reader.TryOpen("extLst", _ns)) + { + _reader.Skip("extLst"); + } + + _reader.Close("pivotCacheRecords", _ns); + } + + partial void OnRecordParsed() + { + // Each record should have element for each field + var fieldsCount = _pivotCache.FieldCount; + if (_fieldIdx != fieldsCount) + throw PartStructureException.IncorrectElementsCount(); + + // Record was read, reset field index for next record. + _fieldIdx = 0; + } + + partial void OnMissingParsed(bool? u, bool? f, string? c, uint? cp, uint? @in, uint? bc, uint? fc, bool i, bool un, bool st, bool b) + { + var fieldValues = GetFieldValues(); + fieldValues.AddMissing(); + } + + partial void OnNumberParsed(double v, bool? u, bool? f, string? c, uint? cp, uint? @in, uint? bc, uint? fc, bool i, bool un, bool st, bool b) + { + var fieldValues = GetFieldValues(); + fieldValues.AddNumber(v); + } + + partial void OnBooleanParsed(bool v, bool? u, bool? f, string? c, uint? cp) + { + var fieldValues = GetFieldValues(); + fieldValues.AddBoolean(v); + } + + partial void OnErrorParsed(string v, bool? u, bool? f, string? c, uint? cp, uint? @in, uint? bc, uint? fc, bool i, bool un, bool st, bool b) + { + var fieldValues = GetFieldValues(); + if (!XLErrorParser.TryParseError(v, out var error)) + throw PartStructureException.InvalidAttributeFormat(); + + fieldValues.AddError(error); + } + + partial void OnStringParsed(string v, bool? u, bool? f, string? c, uint? cp, uint? @in, uint? bc, uint? fc, bool i, bool un, bool st, bool b) + { + var fieldValues = GetFieldValues(); + fieldValues.AddString(v); + } + + partial void OnDateTimeParsed(DateTime v, bool? u, bool? f, string? c, uint? cp) + { + var fieldValues = GetFieldValues(); + fieldValues.AddDateTime(v); + } + + partial void OnIndexParsed(uint v) + { + var fieldValues = GetFieldValues(); + if (v >= fieldValues.SharedCount) + throw PartStructureException.InvalidAttributeValue(); + + fieldValues.AddIndex(v); + } + + private XLPivotCacheValues GetFieldValues() + { + if (_fieldIdx >= _pivotCache.FieldCount) + throw PartStructureException.IncorrectElementsCount(); + + return _pivotCache.GetFieldValues(_fieldIdx++); + } +} diff --git a/ClosedXML/Excel/IO/PivotCacheRecordsReader.g.cs b/ClosedXML/Excel/IO/PivotCacheRecordsReader.g.cs new file mode 100644 index 000000000..68d1c07e8 --- /dev/null +++ b/ClosedXML/Excel/IO/PivotCacheRecordsReader.g.cs @@ -0,0 +1,240 @@ +#nullable enable + +using ClosedXML.IO; + +namespace ClosedXML.Excel.IO; + +internal partial class PivotCacheRecordsReader +{ + private void ParseRecord(string elementName) + { + do + { + if (_reader.TryOpen("m", _ns)) + { + ParseMissing("m"); + } + else if (_reader.TryOpen("n", _ns)) + { + ParseNumber("n"); + } + else if (_reader.TryOpen("b", _ns)) + { + ParseBoolean("b"); + } + else if (_reader.TryOpen("e", _ns)) + { + ParseError("e"); + } + else if (_reader.TryOpen("s", _ns)) + { + ParseString("s"); + } + else if (_reader.TryOpen("d", _ns)) + { + ParseDateTime("d"); + } + else if (_reader.TryOpen("x", _ns)) + { + ParseIndex("x"); + } + else + { + throw PartStructureException.ExpectedChoiceElementNotFound(_reader); + } + } + while (!_reader.TryClose(elementName, _ns)); + OnRecordParsed(); + } + + partial void OnRecordParsed(); + + private void ParseMissing(string elementName) + { + var u = _reader.GetOptionalBool("u"); + var f = _reader.GetOptionalBool("f"); + var c = _reader.GetOptionalXString("c"); + var cp = _reader.GetOptionalUInt("cp"); + var @in = _reader.GetOptionalUInt("in"); + var bc = _reader.GetOptionalUIntHex("bc"); + var fc = _reader.GetOptionalUIntHex("fc"); + var i = _reader.GetOptionalBool("i") ?? false; + var un = _reader.GetOptionalBool("un") ?? false; + var st = _reader.GetOptionalBool("st") ?? false; + var b = _reader.GetOptionalBool("b") ?? false; + while (_reader.TryOpen("tpls", _ns)) + { + ParseTuples("tpls"); + } + while (_reader.TryOpen("x", _ns)) + { + ParseX("x"); + } + _reader.Close(elementName, _ns); + OnMissingParsed(u, f, c, cp, @in, bc, fc, i, un, st, b); + } + + partial void OnMissingParsed(bool? u, bool? f, string? c, uint? cp, uint? @in, uint? bc, uint? fc, bool i, bool un, bool st, bool b); + + private void ParseNumber(string elementName) + { + var v = _reader.GetDouble("v"); + var u = _reader.GetOptionalBool("u"); + var f = _reader.GetOptionalBool("f"); + var c = _reader.GetOptionalXString("c"); + var cp = _reader.GetOptionalUInt("cp"); + var @in = _reader.GetOptionalUInt("in"); + var bc = _reader.GetOptionalUIntHex("bc"); + var fc = _reader.GetOptionalUIntHex("fc"); + var i = _reader.GetOptionalBool("i") ?? false; + var un = _reader.GetOptionalBool("un") ?? false; + var st = _reader.GetOptionalBool("st") ?? false; + var b = _reader.GetOptionalBool("b") ?? false; + while (_reader.TryOpen("tpls", _ns)) + { + ParseTuples("tpls"); + } + while (_reader.TryOpen("x", _ns)) + { + ParseX("x"); + } + _reader.Close(elementName, _ns); + OnNumberParsed(v, u, f, c, cp, @in, bc, fc, i, un, st, b); + } + + partial void OnNumberParsed(double v, bool? u, bool? f, string? c, uint? cp, uint? @in, uint? bc, uint? fc, bool i, bool un, bool st, bool b); + + private void ParseBoolean(string elementName) + { + var v = _reader.GetBool("v"); + var u = _reader.GetOptionalBool("u"); + var f = _reader.GetOptionalBool("f"); + var c = _reader.GetOptionalXString("c"); + var cp = _reader.GetOptionalUInt("cp"); + while (_reader.TryOpen("x", _ns)) + { + ParseX("x"); + } + _reader.Close(elementName, _ns); + OnBooleanParsed(v, u, f, c, cp); + } + + partial void OnBooleanParsed(bool v, bool? u, bool? f, string? c, uint? cp); + + private void ParseError(string elementName) + { + var v = _reader.GetXString("v"); + var u = _reader.GetOptionalBool("u"); + var f = _reader.GetOptionalBool("f"); + var c = _reader.GetOptionalXString("c"); + var cp = _reader.GetOptionalUInt("cp"); + var @in = _reader.GetOptionalUInt("in"); + var bc = _reader.GetOptionalUIntHex("bc"); + var fc = _reader.GetOptionalUIntHex("fc"); + var i = _reader.GetOptionalBool("i") ?? false; + var un = _reader.GetOptionalBool("un") ?? false; + var st = _reader.GetOptionalBool("st") ?? false; + var b = _reader.GetOptionalBool("b") ?? false; + if (_reader.TryOpen("tpls", _ns)) + { + ParseTuples("tpls"); + } + while (_reader.TryOpen("x", _ns)) + { + ParseX("x"); + } + _reader.Close(elementName, _ns); + OnErrorParsed(v, u, f, c, cp, @in, bc, fc, i, un, st, b); + } + + partial void OnErrorParsed(string v, bool? u, bool? f, string? c, uint? cp, uint? @in, uint? bc, uint? fc, bool i, bool un, bool st, bool b); + + private void ParseString(string elementName) + { + var v = _reader.GetXString("v"); + var u = _reader.GetOptionalBool("u"); + var f = _reader.GetOptionalBool("f"); + var c = _reader.GetOptionalXString("c"); + var cp = _reader.GetOptionalUInt("cp"); + var @in = _reader.GetOptionalUInt("in"); + var bc = _reader.GetOptionalUIntHex("bc"); + var fc = _reader.GetOptionalUIntHex("fc"); + var i = _reader.GetOptionalBool("i") ?? false; + var un = _reader.GetOptionalBool("un") ?? false; + var st = _reader.GetOptionalBool("st") ?? false; + var b = _reader.GetOptionalBool("b") ?? false; + while (_reader.TryOpen("tpls", _ns)) + { + ParseTuples("tpls"); + } + while (_reader.TryOpen("x", _ns)) + { + ParseX("x"); + } + _reader.Close(elementName, _ns); + OnStringParsed(v, u, f, c, cp, @in, bc, fc, i, un, st, b); + } + + partial void OnStringParsed(string v, bool? u, bool? f, string? c, uint? cp, uint? @in, uint? bc, uint? fc, bool i, bool un, bool st, bool b); + + private void ParseDateTime(string elementName) + { + var v = _reader.GetDateTime("v"); + var u = _reader.GetOptionalBool("u"); + var f = _reader.GetOptionalBool("f"); + var c = _reader.GetOptionalXString("c"); + var cp = _reader.GetOptionalUInt("cp"); + while (_reader.TryOpen("x", _ns)) + { + ParseX("x"); + } + _reader.Close(elementName, _ns); + OnDateTimeParsed(v, u, f, c, cp); + } + + partial void OnDateTimeParsed(System.DateTime v, bool? u, bool? f, string? c, uint? cp); + + private void ParseIndex(string elementName) + { + var v = _reader.GetUInt("v"); + _reader.Close(elementName, _ns); + OnIndexParsed(v); + } + + partial void OnIndexParsed(uint v); + + private void ParseX(string elementName) + { + var v = _reader.GetOptionalInt("v") ?? 0; + _reader.Close(elementName, _ns); + OnXParsed(v); + } + + partial void OnXParsed(int v); + + private void ParseTuples(string elementName) + { + var c = _reader.GetOptionalUInt("c"); + _reader.Open("tpl", _ns); + do + { + ParseTuple("tpl"); + } + while (_reader.TryOpen("tpl", _ns)); + _reader.Close(elementName, _ns); + OnTuplesParsed(c); + } + + partial void OnTuplesParsed(uint? c); + + private void ParseTuple(string elementName) + { + var fld = _reader.GetOptionalUInt("fld"); + var hier = _reader.GetOptionalUInt("hier"); + var item = _reader.GetUInt("item"); + _reader.Close(elementName, _ns); + OnTupleParsed(fld, hier, item); + } + + partial void OnTupleParsed(uint? fld, uint? hier, uint item); +} diff --git a/ClosedXML/Excel/IO/PivotCacheRecordsWriter.cs b/ClosedXML/Excel/IO/PivotCacheRecordsWriter.cs new file mode 100644 index 000000000..9406ff0de --- /dev/null +++ b/ClosedXML/Excel/IO/PivotCacheRecordsWriter.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using System.Xml; +using ClosedXML.Extensions; +using DocumentFormat.OpenXml.Packaging; +using static ClosedXML.Excel.IO.OpenXmlConst; + +namespace ClosedXML.Excel.IO +{ + internal class PivotCacheRecordsWriter + { + internal static void WriteContent(PivotTableCacheRecordsPart recordsPart, XLPivotCache pivotCache) + { + var settings = new XmlWriterSettings + { + Encoding = XLHelper.NoBomUTF8 + }; + + using var partStream = recordsPart.GetStream(FileMode.Create); + using var xml = XmlWriter.Create(partStream, settings); + + xml.WriteStartDocument(); + xml.WriteStartElement("pivotCacheRecords", Main2006SsNs); + xml.WriteAttributeString("xmlns", "r", null, RelationshipsNs); + xml.WriteAttributeString("xmlns", "mc", null, MarkupCompatibilityNs); + + // Mark revision as ignorable extension + xml.WriteAttributeString("mc", "Ignorable", null, "xr"); + xml.WriteAttributeString("xmlns", "xr", null, RevisionNs); + + var recordCount = pivotCache.RecordCount; + var fieldCount = pivotCache.FieldCount; + for (var recordIdx = 0; recordIdx < recordCount; ++recordIdx) + { + xml.WriteStartElement("r"); + for (var fieldIdx = 0; fieldIdx < fieldCount; ++fieldIdx) + { + var fieldValues = pivotCache.GetFieldValues(fieldIdx); + var value = fieldValues.GetValue(recordIdx); + switch (value.Type) + { + case XLPivotCacheValueType.Missing: + xml.WriteEmptyElement("m"); + break; + case XLPivotCacheValueType.Number: + xml.WriteStartElement("n"); + xml.WriteAttribute("v", value.GetNumber()); + xml.WriteEndElement(); + break; + case XLPivotCacheValueType.Boolean: + xml.WriteStartElement("b"); + xml.WriteAttribute("v", value.GetBoolean()); + xml.WriteEndElement(); + break; + case XLPivotCacheValueType.Error: + xml.WriteStartElement("b"); + xml.WriteAttribute("v", value.GetError().ToDisplayString()); + xml.WriteEndElement(); + break; + case XLPivotCacheValueType.String: + xml.WriteStartElement("s"); + xml.WriteAttribute("v", fieldValues.GetText(value)); + xml.WriteEndElement(); + break; + case XLPivotCacheValueType.DateTime: + xml.WriteStartElement("d"); + xml.WriteAttribute("v", value.GetDateTime()); + xml.WriteEndElement(); + break; + case XLPivotCacheValueType.Index: + xml.WriteStartElement("x"); + xml.WriteAttribute("v", value.GetIndex()); + xml.WriteEndElement(); + break; + default: + throw new NotSupportedException(); + } + } + xml.WriteEndElement(); // r + } + + xml.WriteEndElement(); // pivotCacheRecords + } + } +} diff --git a/ClosedXML/Excel/IO/PivotTableCacheDefinitionPartReader.cs b/ClosedXML/Excel/IO/PivotTableCacheDefinitionPartReader.cs new file mode 100644 index 000000000..156d8e304 --- /dev/null +++ b/ClosedXML/Excel/IO/PivotTableCacheDefinitionPartReader.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClosedXML.Extensions; +using ClosedXML.IO; +using ClosedXML.Utils; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; + +namespace ClosedXML.Excel.IO +{ + internal class PivotTableCacheDefinitionPartReader + { + internal static XLPivotCache Load(WorkbookPart workbookPart, PivotTableCacheDefinitionPart pivotTableCacheDefinitionPart, XLWorkbook workbook) + { + var cacheDefinition = pivotTableCacheDefinitionPart.PivotCacheDefinition; + if (cacheDefinition.CacheSource is not { } cacheSource) + throw PartStructureException.RequiredElementIsMissing("cacheSource"); + + var pivotSourceReference = ParsePivotSourceReference(cacheSource); + var pivotCache = workbook.PivotCachesInternal.Add(pivotSourceReference); + + // If WorkbookCacheRelId already has a value, it means the pivot source is being reused + if (string.IsNullOrWhiteSpace(pivotCache.WorkbookCacheRelId)) + { + pivotCache.WorkbookCacheRelId = workbookPart.GetIdOfPart(pivotTableCacheDefinitionPart); + } + + if (cacheDefinition.MissingItemsLimit?.Value is { } missingItemsLimit) + { + pivotCache.ItemsToRetainPerField = missingItemsLimit switch + { + 0 => XLItemsToRetain.None, + XLHelper.MaxRowNumber => XLItemsToRetain.Max, + _ => XLItemsToRetain.Automatic, + }; + } + + if (cacheDefinition.CacheFields is { } cacheFields) + { + ReadCacheFields(cacheFields, pivotCache); + } + + pivotCache.SaveSourceData = cacheDefinition.SaveData?.Value ?? true; + return pivotCache; + } + + internal static IXLPivotSource ParsePivotSourceReference(CacheSource cacheSource) + { + // Cache source has several types. Each has a specific required format. Do not use different + // combinations, Excel will crash or at least try to repair + // [worksheet] uses a worksheet source: + // * An unnamed range in a sheet: Uses `sheet` and `ref`. + // * An table: Uses `name` that contains a name of the table. + // [external] + // * `connectionId` link to external relationships. + // [consolidation] + // * uses consolidation tag and a list of range sets plus optionally + // page fields to add a custom report fields that allow user to select + // ranges from rangeSet to calculate values. + // [scenario] + // * only type attribute tag is specified, no other value. Likely linked + // through cacheField names (e.g. ). + + // Not all sources are supported, but at least pipe the data through so the load/save works + IEnumValue sourceType = cacheSource.Type?.Value ?? throw PartStructureException.MissingAttribute(); + if (sourceType.Equals(SourceValues.Worksheet)) + { + var sheetSource = cacheSource.WorksheetSource; + if (sheetSource is null) + throw PartStructureException.ExpectedElementNotFound("'worksheetSource' element is required for type 'worksheet'."); + + // If the source is a defined name, it must be a single area reference + if (sheetSource.Name?.Value is { } tableOrName) + { + if (sheetSource.Id?.Value is { } externalWorkbookRelId) + return new XLPivotSourceExternalWorkbook(externalWorkbookRelId, tableOrName); + + return new XLPivotSourceReference(tableOrName); + } + + if (sheetSource.Sheet?.Value is { } sheetName && + sheetSource.Reference?.Value is { } areaRef && + XLSheetRange.TryParse(areaRef.AsSpan(), out var sheetArea)) + { + var area = new XLBookArea(sheetName, sheetArea); + if (sheetSource.Id?.Value is { } externalWorkbookRelId) + return new XLPivotSourceExternalWorkbook(externalWorkbookRelId, area); + + // area is in this workbook + return new XLPivotSourceReference(area); + } + + throw PartStructureException.IncorrectElementFormat("worksheetSource"); + } + + if (sourceType.Equals(SourceValues.External)) + { + if (cacheSource.ConnectionId?.Value is not { } connectionId) + throw PartStructureException.MissingAttribute("connectionId"); + + return new XLPivotSourceConnection(connectionId); + } + + if (sourceType.Equals(SourceValues.Consolidation)) + { + if (cacheSource.Consolidation is not { } consolidation) + throw PartStructureException.ExpectedElementNotFound("consolidation"); + + var autoPage = consolidation.AutoPage?.Value ?? true; + var xlPages = new List(); + if (consolidation.Pages is { } pages) + { + // There is 1..4 pages + foreach (var page in pages.Cast()) + { + var xlPageItems = new List(); + foreach (var pageItem in page.Cast()) + { + var pageItemName = pageItem.Name?.Value ?? throw PartStructureException.MissingAttribute(); + xlPageItems.Add(pageItemName); + } + + xlPages.Add(new XLPivotCacheSourceConsolidationPage(xlPageItems)); + } + } + + if (consolidation.RangeSets is not { } rangeSets) + throw PartStructureException.RequiredElementIsMissing("rangeSets"); + + var xlRangeSets = new List(); + foreach (var rangeSet in rangeSets.Cast()) + xlRangeSets.Add(GetRangeSet(rangeSet, xlPages)); + + if (xlRangeSets.Count < 1) + throw PartStructureException.IncorrectElementsCount(); + + return new XLPivotSourceConsolidation + { + AutoPage = autoPage, + Pages = xlPages, + RangeSets = xlRangeSets + }; + } + + if (sourceType.Equals(SourceValues.Scenario)) + { + return new XLPivotSourceScenario(); + } + + throw PartStructureException.InvalidAttributeValue(sourceType.Value); + + static XLPivotCacheSourceConsolidationRangeSet GetRangeSet(RangeSet rangeSet, List xlPages) + { + var pageIndexes = new[] + { + rangeSet.FieldItemIndexPage1?.Value, + rangeSet.FieldItemIndexPage2?.Value, + rangeSet.FieldItemIndexPage3?.Value, + rangeSet.FieldItemIndexPage4?.Value, + }; + + // Validate that supplied indexes reference existing page and page items + for (var i = 0; i < pageIndexes.Length; ++i) + { + var pageIndex = pageIndexes[i]; + + // If there is a page and rangeSet doesn't define index to the page, it is displayed as blank + if (pageIndex is null) + continue; + + // Range set points to a non-existent page filter + if (i >= xlPages.Count) + throw PartStructureException.InvalidAttributeValue(); + + // Range set points to a non-existent item in a page filter + var pageFilter = xlPages[i]; + if (pageIndex.Value >= pageFilter.PageItems.Count) + throw PartStructureException.InvalidAttributeValue(); + } + + if (rangeSet.Name?.Value is { } tableOrName) + { + return new XLPivotCacheSourceConsolidationRangeSet + { + Indexes = pageIndexes, + RelId = rangeSet.Id?.Value, + TableOrName = tableOrName, + }; + } + + if (rangeSet.Sheet?.Value is { } sheet && + rangeSet.Reference?.Value is { } reference && + XLSheetRange.TryParse(reference.AsSpan(), out var area)) + { + return new XLPivotCacheSourceConsolidationRangeSet + { + Indexes = pageIndexes, + RelId = rangeSet.Id?.Value, + Area = new XLBookArea(sheet, area) + }; + } + + throw PartStructureException.IncorrectElementFormat("rangeSet"); + } + } + + private static void ReadCacheFields(CacheFields cacheFields, XLPivotCache pivotCache) + { + foreach (var cacheField in cacheFields.Elements()) + { + if (cacheField.Name?.Value is not { } fieldName) + throw PartStructureException.MissingAttribute(); + + if (pivotCache.ContainsField(fieldName)) + { + // We don't allow duplicate field names... but what do we do if we find one? Let's just skip it. + continue; + } + + var fieldStats = ReadCacheFieldStats(cacheField); + var fieldSharedItems = cacheField.SharedItems is not null + ? ReadSharedItems(cacheField) + : new XLPivotCacheSharedItems(); + + var fieldValues = new XLPivotCacheValues(fieldSharedItems, fieldStats); + pivotCache.AddCachedField(fieldName, fieldValues); + } + } + + private static XLPivotCacheValuesStats ReadCacheFieldStats(CacheField cacheField) + { + var sharedItems = cacheField.SharedItems; + + // Various statistics about the records of the field, not just shared items. + var containsBlank = OpenXmlHelper.GetBooleanValueAsBool(sharedItems?.ContainsBlank, false); + var containsNumber = OpenXmlHelper.GetBooleanValueAsBool(sharedItems?.ContainsNumber, false); + var containsOnlyInteger = OpenXmlHelper.GetBooleanValueAsBool(sharedItems?.ContainsInteger, false); + var minValue = sharedItems?.MinValue?.Value; + var maxValue = sharedItems?.MaxValue?.Value; + var containsDate = OpenXmlHelper.GetBooleanValueAsBool(sharedItems?.ContainsDate, false); + var minDate = sharedItems?.MinDate?.Value; + var maxDate = sharedItems?.MaxDate?.Value; + var containsString = OpenXmlHelper.GetBooleanValueAsBool(sharedItems?.ContainsString, true); + var longText = OpenXmlHelper.GetBooleanValueAsBool(sharedItems?.LongText, false); + + // The containsMixedTypes, containsNonDate and containsSemiMixedTypes are derived from primary stats. + return new XLPivotCacheValuesStats( + containsBlank, + containsNumber, + containsOnlyInteger, + minValue, + maxValue, + containsString, + longText, + containsDate, + minDate, + maxDate); + } + + private static XLPivotCacheSharedItems ReadSharedItems(CacheField cacheField) + { + var sharedItems = new XLPivotCacheSharedItems(); + + // If there are no shared items, the cache record can't contain field items + // referencing the shared items. + if (cacheField.SharedItems is not { } fieldSharedItems) + return sharedItems; + + foreach (var item in fieldSharedItems.Elements()) + { + // Shared items can't contain element of type index (`x`), + // because index references shared items. That is main reason + // for rather significant duplication with reading records. + switch (item) + { + case MissingItem: + sharedItems.AddMissing(); + break; + + case NumberItem numberItem: + if (numberItem.Val?.Value is not { } number) + throw PartStructureException.MissingAttribute(); + + sharedItems.AddNumber(number); + break; + + case BooleanItem booleanItem: + if (booleanItem.Val?.Value is not { } boolean) + throw PartStructureException.MissingAttribute(); + + sharedItems.AddBoolean(boolean); + break; + + case ErrorItem errorItem: + if (errorItem.Val?.Value is not { } errorText) + throw PartStructureException.MissingAttribute(); + + if (!XLErrorParser.TryParseError(errorText, out var error)) + throw PartStructureException.InvalidAttributeFormat(); + + sharedItems.AddError(error); + break; + + case StringItem stringItem: + if (stringItem.Val?.Value is not { } text) + throw PartStructureException.MissingAttribute(); + + sharedItems.AddString(text); + break; + + case DateTimeItem dateTimeItem: + if (dateTimeItem.Val?.Value is not { } dateTime) + throw PartStructureException.MissingAttribute(); + + sharedItems.AddDateTime(dateTime); + break; + + default: + throw PartStructureException.ExpectedElementNotFound(); + } + } + + return sharedItems; + } + } +} diff --git a/ClosedXML/Excel/IO/PivotTableCacheDefinitionPartWriter.cs b/ClosedXML/Excel/IO/PivotTableCacheDefinitionPartWriter.cs new file mode 100644 index 000000000..13769695a --- /dev/null +++ b/ClosedXML/Excel/IO/PivotTableCacheDefinitionPartWriter.cs @@ -0,0 +1,324 @@ +#nullable disable + +using ClosedXML.Utils; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using DocumentFormat.OpenXml; +using System; +using System.Diagnostics; +using System.Linq; +using ClosedXML.Extensions; +using static ClosedXML.Excel.XLWorkbook; + +namespace ClosedXML.Excel.IO +{ + internal class PivotTableCacheDefinitionPartWriter + { + internal static void GenerateContent( + PivotTableCacheDefinitionPart pivotTableCacheDefinitionPart, + XLPivotCache pivotCache, + SaveContext context) + { + var pivotCacheDefinition = pivotTableCacheDefinitionPart.PivotCacheDefinition; + + if (pivotCacheDefinition == null) + { + pivotCacheDefinition = new PivotCacheDefinition { Id = "rId1" }; + + pivotCacheDefinition.AddNamespaceDeclaration("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); + pivotTableCacheDefinitionPart.PivotCacheDefinition = pivotCacheDefinition; + } + + #region CreatedVersion + + byte createdVersion = XLConstants.PivotTable.CreatedVersion; + + if (pivotCacheDefinition.CreatedVersion?.HasValue ?? false) + pivotCacheDefinition.CreatedVersion = Math.Max(createdVersion, pivotCacheDefinition.CreatedVersion.Value); + else + pivotCacheDefinition.CreatedVersion = createdVersion; + + #endregion CreatedVersion + + #region RefreshedVersion + + byte refreshedVersion = XLConstants.PivotTable.RefreshedVersion; + if (pivotCacheDefinition.RefreshedVersion?.HasValue ?? false) + pivotCacheDefinition.RefreshedVersion = Math.Max(refreshedVersion, pivotCacheDefinition.RefreshedVersion.Value); + else + pivotCacheDefinition.RefreshedVersion = refreshedVersion; + + #endregion RefreshedVersion + + #region MinRefreshableVersion + + byte minRefreshableVersion = 3; + if (pivotCacheDefinition.MinRefreshableVersion?.HasValue ?? false) + pivotCacheDefinition.MinRefreshableVersion = Math.Max(minRefreshableVersion, pivotCacheDefinition.MinRefreshableVersion.Value); + else + pivotCacheDefinition.MinRefreshableVersion = minRefreshableVersion; + + #endregion MinRefreshableVersion + + pivotCacheDefinition.SaveData = pivotCache.SaveSourceData; + pivotCacheDefinition.RefreshOnLoad = true; //pt.RefreshDataOnOpen + + if (pivotCache.ItemsToRetainPerField == XLItemsToRetain.None) + pivotCacheDefinition.MissingItemsLimit = 0U; + else if (pivotCache.ItemsToRetainPerField == XLItemsToRetain.Max) + pivotCacheDefinition.MissingItemsLimit = XLHelper.MaxRowNumber; + + // Begin CacheSource + var cacheSource = new CacheSource(); + + if (pivotCache.Source is XLPivotSourceReference localSource) + { + // Do not quote worksheet name with whitespace here - issue #955 + var worksheetSource = localSource.UsesName + ? new WorksheetSource { Name = localSource.Name } + : new WorksheetSource { Reference = localSource.Area.Value.Area.ToString(), Sheet = localSource.Area.Value.Name }; + cacheSource.Type = SourceValues.Worksheet; + cacheSource.AddChild(worksheetSource); + } + else if (pivotCache.Source is XLPivotSourceExternalWorkbook externalSource) + { + var worksheetSource = externalSource.UsesName + ? new WorksheetSource { Id = externalSource.RelId, Name = externalSource.TableOrName } + : new WorksheetSource { Id = externalSource.RelId, Sheet = externalSource.Area.Value.Name, Reference = externalSource.Area.Value.Area.ToString() }; + cacheSource.Type = SourceValues.Worksheet; + cacheSource.AddChild(worksheetSource); + } + else if (pivotCache.Source is XLPivotSourceConnection connectionSource) + { + cacheSource.Type = SourceValues.External; + cacheSource.ConnectionId = connectionSource.ConnectionId; + } + else if (pivotCache.Source is XLPivotSourceConsolidation consolidationSource) + { + cacheSource.Type = SourceValues.Consolidation; + var consolidation = new Consolidation + { + AutoPage = consolidationSource.AutoPage + }; + + // OpenXML SDK has few bugs here. Use AppendChild to add more children, AddChild keeps only one child. + if (consolidationSource.Pages.Count > 0) + { + var pages = new Pages(); + foreach (var xlPageFilter in consolidationSource.Pages) + { + var page = new Page(); + foreach (var xlPageItem in xlPageFilter.PageItems) + page.AppendChild(new PageItem { Name = xlPageItem }); + + pages.AppendChild(page); + } + + consolidation.AddChild(pages); + } + + var rangeSets = new RangeSets(); + foreach (var xlRangeSet in consolidationSource.RangeSets) + { + var indexes = xlRangeSet.Indexes; + var rangeSet = new RangeSet + { + FieldItemIndexPage1 = indexes.Count > 0 ? indexes[0] : null, + FieldItemIndexPage2 = indexes.Count > 1 ? indexes[1] : null, + FieldItemIndexPage3 = indexes.Count > 2 ? indexes[2] : null, + FieldItemIndexPage4 = indexes.Count > 3 ? indexes[3] : null, + }; + + // Properties can't be set to null and be skipped, OpenXML SDK would + // write out empty string. Don't touch them unless setting a value. + if (xlRangeSet.RelId is not null) + rangeSet.Id = xlRangeSet.RelId; + + if (xlRangeSet.UsesName) + { + rangeSet.Name = xlRangeSet.TableOrName; + } + else + { + var rangeArea = xlRangeSet.Area.Value; + rangeSet.Sheet = rangeArea.Name; + rangeSet.Reference = rangeArea.Area.ToString(); + } + + rangeSets.AppendChild(rangeSet); + } + + consolidation.AddChild(rangeSets); + cacheSource.AddChild(consolidation); + } + else if (pivotCache.Source is XLPivotSourceScenario) + { + cacheSource.Type = SourceValues.Scenario; + } + else + { + throw new UnreachableException(); + } + + pivotCacheDefinition.CacheSource = cacheSource; + + // End CacheSource + + // Begin CacheFields + var cacheFields = pivotCacheDefinition.CacheFields; + if (cacheFields == null) + { + cacheFields = new CacheFields(); + pivotCacheDefinition.CacheFields = cacheFields; + } + + for (var fieldIdx = 0; fieldIdx < pivotCache.FieldCount; ++fieldIdx) + { + var cacheFieldName = pivotCache.FieldNames[fieldIdx]; + var fieldValues = pivotCache.GetFieldValues(fieldIdx); + var xlSharedItems = pivotCache.GetFieldSharedItems(fieldIdx) + .GetCellValues() + .ToArray(); + + // .CacheFields is cleared when workbook is begin saved + // So if there are any entries, it would be from previous pivot tables + // with an identical source range. + // When pivot sources get its refactoring, this will not be necessary + var cacheField = pivotCacheDefinition + .CacheFields + .Elements() + .FirstOrDefault(f => f.Name == cacheFieldName); + + if (cacheField == null) + { + cacheField = new CacheField + { + Name = cacheFieldName, + SharedItems = new SharedItems() + }; + cacheFields.AppendChild(cacheField); + } + var sharedItems = cacheField.SharedItems; + + var ptfi = new PivotTableFieldInfo + { + IsTotallyBlankField = xlSharedItems.Length == 0, + MixedDataType = xlSharedItems + .Select(v => v.Type) + .Distinct() + .Count() > 1, + DistinctValues = xlSharedItems, + }; + + var stats = fieldValues.Stats; + + sharedItems.Count = fieldValues.SharedCount != 0 ? checked((uint)xlSharedItems.Length) : null; + + // https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.shareditems?view=openxml-2.8.1#remarks + // The following attributes are not required or used if there are no items in sharedItems. + // - containsBlank + // - containsSemiMixedTypes + // - containsMixedTypes + // - longText + + // Specifies a boolean value that indicates whether this field contains a blank value. + sharedItems.ContainsBlank = OpenXmlHelper.GetBooleanValue(stats.ContainsBlank, false); + + sharedItems.ContainsDate = OpenXmlHelper.GetBooleanValue(stats.ContainsDate, false); + + // Remember: Blank is not a type in OOXML, but is a value + var typesCount = 0; + if (stats.ContainsNumber) + typesCount++; + + if (stats.ContainsString) + typesCount++; + + if (stats.ContainsDate) + typesCount++; + + // ISO29500: Specifies a boolean value that indicates whether this field contains more than one data type. + // MS-OI29500: In Office, the containsMixedTypes attribute assumes that boolean and error shall be considered part of the string type. + sharedItems.ContainsMixedTypes = OpenXmlHelper.GetBooleanValue(typesCount > 1, false); + + // ISO29500: Specifies a boolean value that indicates that the field contains at least one value that is not a date. + var containsNonDate = stats.ContainsString || stats.ContainsNumber; + sharedItems.ContainsNonDate = OpenXmlHelper.GetBooleanValue(containsNonDate, true); + + // Excel will have to repair the cache definition, if both @containsNumber and @containsDate are specified. Likely because + // ultimately they are both numbers, but date has preference. + if (stats.ContainsDate) + { + // If the field contains a date, the number values are considered serial date times. + + // This is an exception to the "1900 is a leap year". Values are saved correctly, i.e starting at 1899-12-30. + long? minValueAsDateTime = stats.MinValue is not null ? DateTime.FromOADate(stats.MinValue.Value).Ticks : null; + long? maxValueAsDateTime = stats.MaxValue is not null ? DateTime.FromOADate(stats.MaxValue.Value).Ticks : null; + + long? minDateTicks = Min(stats.MinDate?.Ticks, minValueAsDateTime); + long? maxDateTicks = Max(stats.MaxDate?.Ticks, maxValueAsDateTime); + + // @minDate/@maxDate can be present, only if at least one child is a d element. + sharedItems.MinDate = minDateTicks is not null ? new DateTime(minDateTicks.Value) : null; + sharedItems.MaxDate = maxDateTicks is not null ? new DateTime(maxDateTicks.Value) : null; + + static long? Min(long? val1, long? val2) + { + if (val1 is null || val2 is null) + return val1 ?? val2; + + return Math.Min(val1.Value, val2.Value); + } + + static long? Max(long? val1, long? val2) + { + if (val1 is null || val2 is null) + return val1 ?? val2; + + return Math.Max(val1.Value, val2.Value); + } + } + else if (stats.ContainsNumber) + { + // Don't indicate that date field with numbers contains numbers, Excel would refuse to load the file + sharedItems.ContainsNumber = OpenXmlHelper.GetBooleanValue(stats.ContainsNumber, false); + + // @containsInteger has a prerequisite @containsNumber, MS-OI29500: In Office, @containsNumber shall be 1 or true when @containsInteger is specified. + // MS-OI29500: In Office, a value of 1 or true for the containsInteger attribute indicates this field contains only integer values and does not contain non - integer numeric values. + sharedItems.ContainsInteger = OpenXmlHelper.GetBooleanValue(stats.ContainsInteger, false); + + sharedItems.MinValue = stats.MinValue; + sharedItems.MaxValue = stats.MaxValue; + } + + // ISO29500: A value of 1 or true indicates at least one text value, and can also contain a mix of other data types and blank values. + // MS-OI29500: Office expects that the containsSemiMixedTypes attribute is true when the field contains text, blank, boolean or error values. + var containsSemiMixedTypes = stats.ContainsString || stats.ContainsBlank; + sharedItems.ContainsSemiMixedTypes = OpenXmlHelper.GetBooleanValue(containsSemiMixedTypes, true); + + // MS-OI29500: In Office, boolean and error are considered strings in the context of the containsString attribute. + sharedItems.ContainsString = OpenXmlHelper.GetBooleanValue(stats.ContainsString, true); + + sharedItems.LongText = OpenXmlHelper.GetBooleanValue(stats.LongText, false); + + foreach (var value in xlSharedItems) + { + OpenXmlElement toAdd = value.Type switch + { + XLDataType.Blank => new MissingItem(), + XLDataType.Boolean => new BooleanItem { Val = value.GetBoolean() }, + XLDataType.Number => new NumberItem { Val = value.GetNumber() }, + XLDataType.Text => new StringItem { Val = value.GetText() }, + XLDataType.Error => new ErrorItem { Val = value.GetError().ToDisplayString() }, + XLDataType.DateTime => new DateTimeItem { Val = value.GetDateTime() }, + XLDataType.TimeSpan => new DateTimeItem { Val = DateTime.FromOADate(value.GetUnifiedNumber()) }, + _ => throw new InvalidOperationException() + }; + sharedItems.AppendChild(toAdd); + } + } + + // End CacheFields + } + } +} diff --git a/ClosedXML/Excel/IO/PivotTableDefinitionPartReader.cs b/ClosedXML/Excel/IO/PivotTableDefinitionPartReader.cs new file mode 100644 index 000000000..06141fcde --- /dev/null +++ b/ClosedXML/Excel/IO/PivotTableDefinitionPartReader.cs @@ -0,0 +1,717 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using ClosedXML.IO; +using ClosedXML.Utils; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; + +namespace ClosedXML.Excel.IO; + +internal class PivotTableDefinitionPartReader +{ + /// + /// A field displayed as ∑Values in a pivot table that contains names of all aggregation + /// function in value fields collection. Also commonly called 'data' field. + /// + private const int ValuesFieldIndex = -2; + + internal static void Load(WorkbookPart workbookPart, Dictionary differentialFormats, PivotTablePart pivotTablePart, WorksheetPart worksheetPart, XLWorksheet ws, LoadContext context) + { + var workbook = ws.Workbook; + var cache = pivotTablePart.PivotTableCacheDefinitionPart; + var cacheDefinitionRelId = workbookPart.GetIdOfPart(cache); + + var pivotSource = workbook.PivotCachesInternal + .FirstOrDefault(ps => ps.WorkbookCacheRelId == cacheDefinitionRelId); + + if (pivotSource == null) + { + // If it's missing, find a 'similar' pivot cache, i.e. one that's based on the same source range/table + pivotSource = workbook.PivotCachesInternal + .FirstOrDefault(ps => cache.PivotCacheDefinition?.CacheSource is { } cacheSource && + ps.Source.Equals(PivotTableCacheDefinitionPartReader.ParsePivotSourceReference(cacheSource))); + } + + var pivotTableDefinition = pivotTablePart.PivotTableDefinition; + + var target = ws.FirstCell(); + if (pivotTableDefinition?.Location?.Reference?.HasValue ?? false) + { + ws.Range(pivotTableDefinition.Location.Reference.Value).Clear(XLClearOptions.All); + target = ws.Range(pivotTableDefinition.Location.Reference.Value).FirstCell(); + } + + if (target != null && pivotSource != null) + { + var pt = LoadPivotTableDefinition(pivotTableDefinition, ws, pivotSource, differentialFormats, context); + ws.PivotTables.Add(pt); + + pt.RelId = worksheetPart.GetIdOfPart(pivotTablePart); + pt.CacheDefinitionRelId = pivotTablePart.GetIdOfPart(cache); + } + } + +#nullable enable + private static XLPivotTable LoadPivotTableDefinition(PivotTableDefinition pivotTable, XLWorksheet sheet, XLPivotCache cache, Dictionary differentialFormats, LoadContext context) + { + // Load base attributes + var xlPivotTable = LoadPivotTableAttributes(pivotTable, sheet, cache); + + // Load location + var location = pivotTable.Location; + if (location is null) + throw PartStructureException.ExpectedElementNotFound(); + + var referenceText = location.Reference?.Value ?? throw PartStructureException.MissingAttribute(); + xlPivotTable.Area = XLSheetRange.Parse(referenceText); + xlPivotTable.FirstHeaderRow = location.FirstHeaderRow?.Value ?? throw PartStructureException.MissingAttribute(); + xlPivotTable.FirstDataRow = location.FirstDataRow?.Value ?? throw PartStructureException.MissingAttribute(); + xlPivotTable.FirstDataCol = location.FirstDataColumn?.Value ?? throw PartStructureException.MissingAttribute(); + + // Skip `rowPageCount` and `colPageCount`, because they are derived from filterAreaOrder, filterFieldsPageWrap and pageField count + + // Load pivot fields + var pivotFields = pivotTable.PivotFields; + if (pivotFields is not null) + { + foreach (var pivotField in pivotFields.Cast()) + xlPivotTable.AddField(LoadPivotField(pivotField, xlPivotTable, context)); + } + + // Load row axis fields and items + LoadAxisFields(pivotTable.RowFields, xlPivotTable.RowAxis, xlPivotTable); + LoadAxisItems(pivotTable.RowItems, xlPivotTable.RowAxis); + + // Load column axis fields and items + LoadAxisFields(pivotTable.ColumnFields, xlPivotTable.ColumnAxis, xlPivotTable); + LoadAxisItems(pivotTable.ColumnItems, xlPivotTable.ColumnAxis); + + // Load page fields, i.e. the filters region. + var pageFields = pivotTable.PageFields; + if (pageFields is not null) + { + foreach (var pageField in pageFields.Cast()) + { + var field = pageField.Field?.Value ?? throw PartStructureException.MissingAttribute(); + var itemIndex = checked((int?)pageField.Item?.Value); + var hierarchyIndex = pageField.Hierarchy?.Value; + var hierarchyUniqueName = pageField.Name; + var hierarchyDisplayName = pageField.Caption; + var xlPageField = new XLPivotPageField(field) + { + ItemIndex = itemIndex, + HierarchyIndex = hierarchyIndex, + HierarchyUniqueName = hierarchyUniqueName, + HierarchyDisplayName = hierarchyDisplayName, + }; + xlPivotTable.Filters.AddField(xlPageField); + } + } + + // Load data fields. + var dataFields = pivotTable.DataFields; + if (dataFields is not null) + { + foreach (var dataField in dataFields.Cast()) + { + var name = dataField.Name?.Value; + var field = dataField.Field?.Value ?? throw PartStructureException.MissingAttribute(); + var subtotal = dataField.Subtotal?.Value.ToClosedXml() ?? XLPivotSummary.Sum; + var showDataAsFormat = dataField.ShowDataAs?.Value.ToClosedXml() ?? XLPivotCalculation.Normal; + var baseField = dataField.BaseField?.Value ?? -1; + var baseItem = dataField.BaseItem?.Value ?? 1048832; + var numberFormatId = checked((int?)dataField.NumberFormatId?.Value); + var numberFormat = context.GetNumberFormat(numberFormatId); + var xlDataField = new XLPivotDataField(xlPivotTable, checked((int)field)) + { + DataFieldName = name, + Subtotal = subtotal, + ShowDataAsFormat = showDataAsFormat, + BaseField = baseField, + BaseItem = baseItem, + NumberFormatValue = numberFormat, + }; + xlPivotTable.DataFields.AddField(xlDataField); + } + } + + // Load formats + var formats = pivotTable.Formats; + if (formats is not null) + { + foreach (var format in formats.Cast()) + { + var action = format.Action?.Value.ToClosedXml() ?? XLPivotFormatAction.Formatting; + var dxfStyle = XLStyle.Default; + if (format.FormatId is not null) + { + // TODO: What about alignment? + var df = differentialFormats[checked((int)format.FormatId.Value)]; + OpenXmlHelper.LoadFont(df.Font, dxfStyle.Font); + OpenXmlHelper.LoadFill(df.Fill, dxfStyle.Fill, differentialFillFormat: true); + OpenXmlHelper.LoadBorder(df.Border, dxfStyle.Border); + OpenXmlHelper.LoadNumberFormat(df.NumberingFormat, dxfStyle.NumberFormat); + } + + var pivotArea = format.PivotArea ?? throw PartStructureException.ExpectedElementNotFound(); + var xlPivotArea = LoadPivotArea(pivotArea); + var xlFormat = new XLPivotFormat(xlPivotArea) + { + Action = action, + DxfStyleValue = dxfStyle.Value, + }; + xlPivotTable.AddFormat(xlFormat); + } + } + + var conditionalFormats = pivotTable.ConditionalFormats; + if (conditionalFormats is not null) + { + foreach (var conditionalFormat in conditionalFormats.Cast()) + { + var scope = conditionalFormat.Scope?.Value.ToClosedXml() ?? XLPivotCfScope.SelectedCells; + var type = conditionalFormat.Type?.Value.ToClosedXml() ?? XLPivotCfRuleType.None; + var priority = conditionalFormat.Priority?.Value ?? throw PartStructureException.MissingAttribute(); + var format = context.GetPivotCf(sheet.Name, checked((int)priority)); + var xlConditionalFormat = new XLPivotConditionalFormat(format) + { + Scope = scope, + Type = type, + }; + var pivotAreas = conditionalFormat.PivotAreas; + if (pivotAreas is not null) + { + foreach (var pivotArea in pivotAreas.Cast()) + { + var xlPivotArea = LoadPivotArea(pivotArea); + xlConditionalFormat.AddArea(xlPivotArea); + } + } + + xlPivotTable.AddConditionalFormat(xlConditionalFormat); + } + } + + // TODO: chartFormats + // pivotHierarchies is OLAP and thus for now out of scope. + var pivotTableStyle = pivotTable.GetFirstChild(); + LoadPivotTableStyle(pivotTableStyle, xlPivotTable); + + // TODO: filters + // rowHierarchiesUsage is OLAP and thus for now out of scope. + // colHierarchiesUsage is OLAP and thus for now out of scope. + LoadExtensionList(pivotTable, xlPivotTable); + + return xlPivotTable; + } + + private static XLPivotTable LoadPivotTableAttributes(PivotTableDefinition pivotTable, XLWorksheet sheet, XLPivotCache cache) + { + var name = pivotTable.Name?.Value ?? throw PartStructureException.MissingAttribute(); + var cacheId = pivotTable.CacheId?.Value ?? throw PartStructureException.MissingAttribute(); + var dataOnRows = pivotTable.DataOnRows?.Value ?? false; + + // DataPosition attribute is skipped, because it basically represents a field on one of axis. + // Excel requires that dataPosition and field with index -2 must be in list of respective axis + // at correct place, otherwise it crashes. To make things simple, we set the value when it is + // encountered on the correct axis (plus there is a check that field is not used on multiple axes + // that would cause exception). + var autoFormatId = pivotTable.AutoFormatId?.Value; + var applyNumberFormats = pivotTable.ApplyNumberFormats?.Value ?? false; + var applyBorderFormats = pivotTable.ApplyBorderFormats?.Value ?? false; + var applyFontFormats = pivotTable.ApplyFontFormats?.Value ?? false; + var applyPatternFormats = pivotTable.ApplyPatternFormats?.Value ?? false; + var applyAlignmentFormats = pivotTable.ApplyAlignmentFormats?.Value ?? false; + var applyWidthHeightFormats = pivotTable.ApplyWidthHeightFormats?.Value ?? false; + var dataCaption = pivotTable.DataCaption?.Value ?? throw PartStructureException.MissingAttribute(); + var grandTotalCaption = pivotTable.GrandTotalCaption?.Value; + var errorCaption = pivotTable.ErrorCaption?.Value; + var showError = pivotTable.ShowError?.Value ?? false; + var missingCaption = pivotTable.MissingCaption?.Value ?? string.Empty; + var showMissing = pivotTable.ShowMissing?.Value ?? true; + var pageStyle = pivotTable.PageStyle?.Value; + var pivotTableStyleName = pivotTable.PivotTableStyleName?.Value; + var vacatedStyle = pivotTable.VacatedStyle?.Value; + var tag = pivotTable.Tag?.Value; + var updatedVersion = pivotTable.UpdatedVersion?.Value ?? 0; + var minRefreshableVersion = pivotTable.MinRefreshableVersion?.Value ?? 0; + var asteriskTotals = pivotTable.AsteriskTotals?.Value ?? false; + var showItems = pivotTable.ShowItems?.Value ?? true; + var editData = pivotTable.EditData?.Value ?? false; + var disableFieldList = pivotTable.DisableFieldList?.Value ?? false; + var showCalculatedMembers = pivotTable.ShowCalculatedMembers?.Value ?? true; + var visualTotals = pivotTable.VisualTotals?.Value ?? true; + var showMultipleLabel = pivotTable.ShowMultipleLabel?.Value ?? true; + var showDataDropDown = pivotTable.ShowDataDropDown?.Value ?? true; + var showDrill = pivotTable.ShowDrill?.Value ?? true; + var printDrill = pivotTable.PrintDrill?.Value ?? false; + var showMemberPropertyTips = pivotTable.ShowMemberPropertyTips?.Value ?? true; + var showDataTips = pivotTable.ShowDataTips?.Value ?? true; + var enableWizard = pivotTable.EnableWizard?.Value ?? true; + var enableDrill = pivotTable.EnableDrill?.Value ?? true; + var enableFieldProperties = pivotTable.EnableFieldProperties?.Value ?? true; + var preserveFormatting = pivotTable.PreserveFormatting?.Value ?? true; + var useAutoFormatting = pivotTable.UseAutoFormatting?.Value ?? false; + var pageWrap = pivotTable.PageWrap?.Value ?? 0; + var pageOverThenDown = pivotTable.PageOverThenDown?.Value ?? false; + var subtotalHiddenItems = pivotTable.SubtotalHiddenItems?.Value ?? false; + var rowGrandTotals = pivotTable.RowGrandTotals?.Value ?? true; + var columnGrandTotals = pivotTable.ColumnGrandTotals?.Value ?? true; + var fieldPrintTitles = pivotTable.FieldPrintTitles?.Value ?? false; + var itemPrintTitles = pivotTable.ItemPrintTitles?.Value ?? false; + var mergeItem = pivotTable.MergeItem?.Value ?? false; + var showDropZones = pivotTable.ShowDropZones?.Value ?? true; + var createdVersion = pivotTable.CreatedVersion?.Value ?? 0; + var indent = pivotTable.Indent?.Value ?? 1; + var showEmptyRow = pivotTable.ShowEmptyRow?.Value ?? false; + var showEmptyColumn = pivotTable.ShowEmptyColumn?.Value ?? false; + var showHeaders = pivotTable.ShowHeaders?.Value ?? true; + var compact = pivotTable.Compact?.Value ?? true; + var outline = pivotTable.Outline?.Value ?? false; + var outlineData = pivotTable.OutlineData?.Value ?? false; + var compactData = pivotTable.CompactData?.Value ?? true; + var published = pivotTable.Published?.Value ?? false; + var gridDropZones = pivotTable.GridDropZones?.Value ?? false; + var stopImmersiveUi = pivotTable.StopImmersiveUi?.Value ?? true; + var multipleFieldFilters = pivotTable.MultipleFieldFilters?.Value ?? true; + var chartFormat = pivotTable.ChartFormat?.Value ?? 0; + var rowHeaderCaption = pivotTable.RowHeaderCaption?.Value; + var columnHeaderCaption = pivotTable.ColumnHeaderCaption?.Value; + var fieldListSortAscending = pivotTable.FieldListSortAscending?.Value ?? false; + var mdxSubQueries = pivotTable.MdxSubqueries?.Value ?? false; + var customSortList = pivotTable.CustomListSort?.Value ?? true; + + var xlPivotTable = new XLPivotTable(sheet, cache) + { + Name = name, + DataOnRows = dataOnRows, + DataPosition = null, // 'data' field is set when during axis loading (if present). + AutoFormatId = autoFormatId, + ApplyNumberFormats = applyNumberFormats, + ApplyBorderFormats = applyBorderFormats, + ApplyFontFormats = applyFontFormats, + ApplyPatternFormats = applyPatternFormats, + ApplyAlignmentFormats = applyAlignmentFormats, + ApplyWidthHeightFormats = applyWidthHeightFormats, + DataCaption = dataCaption, + GrandTotalCaption = grandTotalCaption, + ErrorValueReplacement = errorCaption, + ShowError = showError, + MissingCaption = missingCaption, + ShowMissing = showMissing, + PageStyle = pageStyle, + PivotTableStyleName = pivotTableStyleName, + VacatedStyle = vacatedStyle, + Tag = tag, + UpdatedVersion = updatedVersion, + MinRefreshableVersion = minRefreshableVersion, + AsteriskTotals = asteriskTotals, + DisplayItemLabels = showItems, + EditData = editData, + DisableFieldList = disableFieldList, + ShowCalculatedMembers = showCalculatedMembers, + VisualTotals = visualTotals, + ShowMultipleLabel = showMultipleLabel, + ShowDataDropDown = showDataDropDown, + ShowExpandCollapseButtons = showDrill, + PrintExpandCollapsedButtons = printDrill, + ShowPropertiesInTooltips = showMemberPropertyTips, + ShowContextualTooltips = showDataTips, + EnableEditingMechanism = enableWizard, + EnableShowDetails = enableDrill, + EnableFieldProperties = enableFieldProperties, + PreserveCellFormatting = preserveFormatting, + AutofitColumns = useAutoFormatting, + FilterFieldsPageWrap = checked((int)pageWrap), + FilterAreaOrder = pageOverThenDown ? XLFilterAreaOrder.OverThenDown : XLFilterAreaOrder.DownThenOver, + FilteredItemsInSubtotals = subtotalHiddenItems, + ShowGrandTotalsRows = rowGrandTotals, + ShowGrandTotalsColumns = columnGrandTotals, + PrintTitles = fieldPrintTitles, + RepeatRowLabels = itemPrintTitles, + MergeAndCenterWithLabels = mergeItem, + ShowDropZones = showDropZones, + PivotCacheCreatedVersion = createdVersion, + RowLabelIndent = checked((int)indent), + ShowEmptyItemsOnRows = showEmptyRow, + ShowEmptyItemsOnColumns = showEmptyColumn, + DisplayCaptionsAndDropdowns = showHeaders, + Compact = compact, + Outline = outline, + OutlineData = outlineData, + CompactData = compactData, + Published = published, + ClassicPivotTableLayout = gridDropZones, + StopImmersiveUi = stopImmersiveUi, + AllowMultipleFilters = multipleFieldFilters, + ChartFormat = chartFormat, + RowHeaderCaption = rowHeaderCaption, + ColumnHeaderCaption = columnHeaderCaption, + SortFieldsAtoZ = fieldListSortAscending, + MdxSubQueries = mdxSubQueries, + UseCustomListsForSorting = customSortList, + }; + return xlPivotTable; + } + + private static XLPivotTableField LoadPivotField(PivotField pivotField, XLPivotTable xlPivotTable, LoadContext context) + { + var customName = pivotField.Name?.Value; + var axis = pivotField.Axis?.Value.ToClosedXml(); + var dataField = pivotField.DataField?.Value ?? false; + var subtotalCaption = pivotField.SubtotalCaption?.Value; + var showDropDowns = pivotField.ShowDropDowns?.Value ?? true; + var hiddenLevel = pivotField.HiddenLevel?.Value ?? false; + var uniqueMemberProperty = pivotField.UniqueMemberProperty?.Value; + var compact = pivotField.Compact?.Value ?? true; + var allDrilled = pivotField.AllDrilled?.Value ?? false; + var numberFormatId = checked((int?)pivotField.NumberFormatId?.Value); + var numberFormat = context.GetNumberFormat(numberFormatId); + var outline = pivotField.Outline?.Value ?? true; + var subtotalTop = pivotField.SubtotalTop?.Value ?? true; + var dragToRow = pivotField.DragToRow?.Value ?? true; + var dragToColumn = pivotField.DragToColumn?.Value ?? true; + var multipleItemSelectionAllowed = pivotField.MultipleItemSelectionAllowed?.Value ?? false; + var dragToPage = pivotField.DragToPage?.Value ?? true; + var dragToData = pivotField.DragToData?.Value ?? true; + var dragOff = pivotField.DragOff?.Value ?? true; + var showAll = pivotField.ShowAll?.Value ?? true; + var insertBlankRow = pivotField.InsertBlankRow?.Value ?? false; + var serverField = pivotField.ServerField?.Value ?? false; + var insertPageBreak = pivotField.InsertPageBreak?.Value ?? false; + var autoShow = pivotField.AutoShow?.Value ?? false; + var topAutoShow = pivotField.TopAutoShow?.Value ?? true; + var hideNewItems = pivotField.HideNewItems?.Value ?? false; + var measureFilter = pivotField.MeasureFilter?.Value ?? false; + var includeNewItemsInFilter = pivotField.IncludeNewItemsInFilter?.Value ?? false; + var itemPageCount = pivotField.ItemPageCount?.Value ?? 10u; + var sortType = pivotField.SortType?.Value.ToClosedXml() ?? XLPivotSortType.Default; + var dataSourceSort = pivotField.DataSourceSort?.Value; + var nonAutoSortDefault = pivotField.NonAutoSortDefault?.Value ?? false; + var rankBy = pivotField.RankBy?.Value; + var defaultSubtotal = pivotField.DefaultSubtotal?.Value ?? true; + var sumSubtotal = pivotField.SumSubtotal?.Value ?? false; + var countASubtotal = pivotField.CountASubtotal?.Value ?? false; + var avgSubtotal = pivotField.AverageSubTotal?.Value ?? false; + var maxSubtotal = pivotField.MaxSubtotal?.Value ?? false; + var minSubtotal = pivotField.MinSubtotal?.Value ?? false; + var productSubtotal = pivotField.ApplyProductInSubtotal?.Value ?? false; + var countSubtotal = pivotField.CountSubtotal?.Value ?? false; + var stdDevSubtotal = pivotField.ApplyStandardDeviationInSubtotal?.Value ?? false; + var stdDevPSubtotal = pivotField.ApplyStandardDeviationPInSubtotal?.Value ?? false; + var varSubtotal = pivotField.ApplyVarianceInSubtotal?.Value ?? false; + var varPSubtotal = pivotField.ApplyVariancePInSubtotal?.Value ?? false; + var showPropCell = pivotField.ShowPropCell?.Value ?? false; + var showPropTip = pivotField.ShowPropertyTooltip?.Value ?? false; + var showPropAsCaption = pivotField.ShowPropAsCaption?.Value ?? false; + var defaultAttributeDrillState = pivotField.DefaultAttributeDrillState?.Value ?? false; + + var subtotals = new HashSet(); + if (defaultSubtotal) + subtotals.Add(XLSubtotalFunction.Automatic); + + if (sumSubtotal) + subtotals.Add(XLSubtotalFunction.Sum); + + if (countASubtotal) + subtotals.Add(XLSubtotalFunction.Count); + + if (avgSubtotal) + subtotals.Add(XLSubtotalFunction.Average); + + if (maxSubtotal) + subtotals.Add(XLSubtotalFunction.Maximum); + + if (minSubtotal) + subtotals.Add(XLSubtotalFunction.Minimum); + + if (productSubtotal) + subtotals.Add(XLSubtotalFunction.Product); + + if (countSubtotal) + subtotals.Add(XLSubtotalFunction.CountNumbers); + + if (stdDevSubtotal) + subtotals.Add(XLSubtotalFunction.StandardDeviation); + + if (stdDevPSubtotal) + subtotals.Add(XLSubtotalFunction.PopulationStandardDeviation); + + if (varSubtotal) + subtotals.Add(XLSubtotalFunction.Variance); + + if (varPSubtotal) + subtotals.Add(XLSubtotalFunction.PopulationVariance); + + var xlField = new XLPivotTableField(xlPivotTable) + { + Name = customName, + Axis = axis, + DataField = dataField, + SubtotalCaption = subtotalCaption ?? string.Empty, + ShowDropDowns = showDropDowns, + HiddenLevel = hiddenLevel, + UniqueMemberProperty = uniqueMemberProperty, + Compact = compact, + AllDrilled = allDrilled, + NumberFormatValue = numberFormat, + Outline = outline, + SubtotalTop = subtotalTop, + DragToRow = dragToRow, + DragToColumn = dragToColumn, + MultipleItemSelectionAllowed = multipleItemSelectionAllowed, + DragToPage = dragToPage, + DragToData = dragToData, + DragOff = dragOff, + ShowAll = showAll, + InsertBlankRow = insertBlankRow, + ServerField = serverField, + InsertPageBreak = insertPageBreak, + AutoShow = autoShow, + TopAutoShow = topAutoShow, + HideNewItems = hideNewItems, + MeasureFilter = measureFilter, + IncludeNewItemsInFilter = includeNewItemsInFilter, + ItemPageCount = itemPageCount, + SortType = sortType, + DataSourceSort = dataSourceSort, + NonAutoSortDefault = nonAutoSortDefault, + RankBy = rankBy, + Subtotals = subtotals, + ShowPropCell = showPropCell, + ShowPropTip = showPropTip, + ShowPropAsCaption = showPropAsCaption, + DefaultAttributeDrillState = defaultAttributeDrillState, + }; + + var items = pivotField.Items; + if (items is not null) + { + foreach (var item in items.Cast()) + { + // Attributes `sd` and `d` were swapped in spec. + var approximatelyHasChildren = item.ChildItems?.Value ?? false; + var details = item.Expanded?.Value ?? false; + var drillAcrossAttributes = item.DrillAcrossAttributes?.Value ?? true; + var calculatedMember = item.Calculated?.Value ?? false; + var hidden = item.Hidden?.Value ?? false; + var missing = item.Missing?.Value ?? false; + var itemUserCaption = item.ItemName; + var valueIsString = item.HasStringVlue?.Value ?? false; + var showDetails = item.HideDetails?.Value ?? true; + var itemIndex = item.Index?.Value; + var itemType = item.ItemType?.Value.ToClosedXml() ?? XLPivotItemType.Data; + var xlItem = new XLPivotFieldItem(xlField, itemIndex is null ? null : checked((int)itemIndex.Value)) + { + ApproximatelyHasChildren = approximatelyHasChildren, + Details = details, + DrillAcrossAttributes = drillAcrossAttributes, + CalculatedMember = calculatedMember, + Hidden = hidden, + Missing = missing, + ItemUserCaption = itemUserCaption, + ValueIsString = valueIsString, + ShowDetails = showDetails, + ItemType = itemType, + }; + + xlField.AddItem(xlItem); + } + } + + // TODO: autoSortScope + + // extLst + var pivotFieldExtensionList = pivotField.GetFirstChild(); + var pivotFieldExtension = pivotFieldExtensionList?.GetFirstChild(); + var field2010 = pivotFieldExtension?.GetFirstChild(); + xlField.RepeatItemLabels = field2010?.FillDownLabels?.Value ?? false; + + return xlField; + } + + private static void LoadAxisFields(OpenXmlCompositeElement? fields, XLPivotTableAxis axis, XLPivotTable xlPivotTable) + { + if (fields is not null) + { + foreach (var field in fields.Cast()) + { + // Axis can contain 'data' field. + var fieldIndex = field.Index?.Value ?? throw PartStructureException.MissingAttribute(); + if (fieldIndex >= xlPivotTable.PivotFields.Count || (fieldIndex < 0 && fieldIndex != ValuesFieldIndex)) + throw PartStructureException.InvalidAttributeValue(); + + axis.AddField(fieldIndex); + } + } + } + + private static void LoadAxisItems(OpenXmlCompositeElement? axisItems, XLPivotTableAxis axis) + { + if (axisItems is not null) + { + // Both row and column use RowItem type for axis item. + var previous = new List(); + foreach (var axisItem in axisItems.Cast()) + { + var xlItemType = axisItem.ItemType?.Value.ToClosedXml() ?? XLPivotItemType.Data; + var dataFieldIndex = checked((int)(axisItem.Index?.Value ?? 0)); // This is used by 'data' field + var repeatedCount = axisItem.RepeatedItemCount?.Value ?? 0; + var fieldIndexes = new List(); + foreach (var dataIndex in axisItem.ChildElements.Cast()) + fieldIndexes.Add(dataIndex.Val?.Value ?? 0); + + var allFieldIndexes = previous.Take((int)repeatedCount).Concat(fieldIndexes).ToList(); + axis.AddItem(new XLPivotFieldAxisItem(xlItemType, dataFieldIndex, allFieldIndexes)); + previous = allFieldIndexes; + } + } + } + + private static XLPivotArea LoadPivotArea(PivotArea pivotArea) + { + var field = pivotArea.Field?.Value; + var type = pivotArea.Type?.Value.ToClosedXml() ?? XLPivotAreaType.Normal; + var dataOnly = pivotArea.DataOnly?.Value ?? true; + var labelOnly = pivotArea.LabelOnly?.Value ?? false; + var grandRow = pivotArea.GrandRow?.Value ?? false; + var grandCol = pivotArea.GrandColumn?.Value ?? false; + var cacheIndex = pivotArea.CacheIndex?.Value ?? false; + var outline = pivotArea.Outline?.Value ?? true; + var offset = pivotArea.Offset?.Value is { } offsetRefText ? XLSheetRange.Parse(offsetRefText) : (XLSheetRange?)null; + var collapsedLevelsAreSubtotals = pivotArea.CollapsedLevelsAreSubtotals?.Value ?? false; + var axis = pivotArea.Axis?.Value.ToClosedXml(); + var fieldPosition = pivotArea.FieldPosition?.Value; + var xlPivotArea = new XLPivotArea + { + Field = field, + Type = type, + DataOnly = dataOnly, + LabelOnly = labelOnly, + GrandRow = grandRow, + GrandCol = grandCol, + CacheIndex = cacheIndex, + Outline = outline, + Offset = offset, + CollapsedLevelsAreSubtotals = collapsedLevelsAreSubtotals, + Axis = axis, + FieldPosition = fieldPosition + }; + + // Can contain extensions, in theory at least. + var references = pivotArea.PivotAreaReferences; + if (references is not null) + { + foreach (var reference in references.Cast()) + xlPivotArea.AddReference(LoadPivotReference(reference)); + } + + return xlPivotArea; + } + + private static XLPivotReference LoadPivotReference(PivotAreaReference reference) + { + var field = reference.Field?.Value; + var selected = reference.Selected?.Value ?? true; + var byPosition = reference.ByPosition?.Value ?? false; + var relative = reference.Relative?.Value ?? false; + var defaultSubtotal = reference.DefaultSubtotal?.Value ?? false; + var sumSubtotal = reference.SumSubtotal?.Value ?? false; + var countASubtotal = reference.CountASubtotal?.Value ?? false; + var avgSubtotal = reference.AverageSubtotal?.Value ?? false; + var maxSubtotal = reference.MaxSubtotal?.Value ?? false; + var minSubtotal = reference.MinSubtotal?.Value ?? false; + var productSubtotal = reference.ApplyProductInSubtotal?.Value ?? false; + var countSubtotal = reference.CountSubtotal?.Value ?? false; + var stdDevSubtotal = reference.ApplyStandardDeviationInSubtotal?.Value ?? false; + var stdDevPSubtotal = reference.ApplyStandardDeviationPInSubtotal?.Value ?? false; + var varSubtotal = reference.ApplyVarianceInSubtotal?.Value ?? false; + var varPSubtotal = reference.ApplyVariancePInSubtotal?.Value ?? false; + + var subtotals = new HashSet(); + if (defaultSubtotal) + subtotals.Add(XLSubtotalFunction.Automatic); + + if (sumSubtotal) + subtotals.Add(XLSubtotalFunction.Sum); + + if (countASubtotal) + subtotals.Add(XLSubtotalFunction.Count); + + if (avgSubtotal) + subtotals.Add(XLSubtotalFunction.Average); + + if (maxSubtotal) + subtotals.Add(XLSubtotalFunction.Maximum); + + if (minSubtotal) + subtotals.Add(XLSubtotalFunction.Minimum); + + if (productSubtotal) + subtotals.Add(XLSubtotalFunction.Product); + + if (countSubtotal) + subtotals.Add(XLSubtotalFunction.CountNumbers); + + if (stdDevSubtotal) + subtotals.Add(XLSubtotalFunction.StandardDeviation); + + if (stdDevPSubtotal) + subtotals.Add(XLSubtotalFunction.PopulationStandardDeviation); + + if (varSubtotal) + subtotals.Add(XLSubtotalFunction.Variance); + + if (varPSubtotal) + subtotals.Add(XLSubtotalFunction.PopulationVariance); + + var xlReference = new XLPivotReference + { + Field = field, + Selected = selected, + ByPosition = byPosition, + Relative = relative, + Subtotals = subtotals, + }; + + // Add indexes after the reference is initialized, so it can check values by cacheIndex/byPosition. + foreach (var fieldItem in reference.OfType()) + { + var fieldItemValue = fieldItem.Val?.Value ?? throw PartStructureException.MissingAttribute(); + xlReference.AddFieldItem(fieldItemValue); + } + + return xlReference; + } + + private static void LoadPivotTableStyle(PivotTableStyle? pivotTableStyle, XLPivotTable xlPivotTable) + { + if (pivotTableStyle is not null) + { + xlPivotTable.Theme = pivotTableStyle.Name is not null && Enum.TryParse(pivotTableStyle.Name, out var xlPivotTableTheme) + ? xlPivotTableTheme + : XLPivotTableTheme.None; + xlPivotTable.ShowRowHeaders = pivotTableStyle.ShowRowHeaders?.Value ?? false; + xlPivotTable.ShowColumnHeaders = pivotTableStyle.ShowColumnHeaders?.Value ?? false; + xlPivotTable.ShowRowStripes = pivotTableStyle.ShowRowStripes?.Value ?? false; + xlPivotTable.ShowColumnStripes = pivotTableStyle.ShowColumnStripes?.Value ?? false; + xlPivotTable.ShowLastColumn = pivotTableStyle.ShowColumnStripes?.Value ?? false; + } + } + + private static void LoadExtensionList(PivotTableDefinition pivotTable, XLPivotTable xlPivotTable) + { + var extList = pivotTable.GetFirstChild(); + var ext2010 = extList?.GetFirstChild(); + var ptExt2010 = ext2010?.GetFirstChild(); + if (ptExt2010 is not null) + { + xlPivotTable.EnableCellEditing = ptExt2010.EnableEdit?.Value ?? false; + var hideValuesRow = ptExt2010.HideValuesRow?.Value ?? false; + xlPivotTable.ShowValuesRow = !hideValuesRow; + } + } +} diff --git a/ClosedXML/Excel/IO/PivotTableDefinitionPartWriter2.cs b/ClosedXML/Excel/IO/PivotTableDefinitionPartWriter2.cs new file mode 100644 index 000000000..08c22d03c --- /dev/null +++ b/ClosedXML/Excel/IO/PivotTableDefinitionPartWriter2.cs @@ -0,0 +1,612 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Xml; +using ClosedXML.Extensions; +using DocumentFormat.OpenXml.Packaging; +using static ClosedXML.Excel.IO.OpenXmlConst; +using static ClosedXML.Excel.XLWorkbook; +using Array = System.Array; + +namespace ClosedXML.Excel.IO; + +internal class PivotTableDefinitionPartWriter2 +{ + internal static void WriteContent(PivotTablePart pivotTablePart, XLPivotTable pt, SaveContext context) + { + var settings = new XmlWriterSettings + { + Encoding = XLHelper.NoBomUTF8 + }; + + using var partStream = pivotTablePart.GetStream(FileMode.Create); + using var xml = XmlWriter.Create(partStream, settings); + + xml.WriteStartDocument(); + xml.WriteStartElement("pivotTableDefinition", Main2006SsNs); + xml.WriteAttributeString("xmlns", Main2006SsNs); + + xml.WriteAttribute("name", pt.Name); + xml.WriteAttribute("cacheId", pt.PivotCache.CacheId!.Value); // TODO: Maybe not nullable? + xml.WriteAttributeDefault("dataOnRows", pt.DataOnRows, false); + xml.WriteAttributeOptional("dataPosition", pt.DataPosition); + xml.WriteAttributeOptional("autoFormatId", pt.AutoFormatId); + + // Although apply*Formats do have default value `false`, Excel always writes them. + xml.WriteAttribute("applyNumberFormats", pt.ApplyNumberFormats); + xml.WriteAttribute("applyBorderFormats", pt.ApplyBorderFormats); + xml.WriteAttribute("applyFontFormats", pt.ApplyFontFormats); + xml.WriteAttribute("applyPatternFormats", pt.ApplyPatternFormats); + xml.WriteAttribute("applyAlignmentFormats", pt.ApplyAlignmentFormats); + xml.WriteAttribute("applyWidthHeightFormats", pt.ApplyWidthHeightFormats); + + xml.WriteAttribute("dataCaption", pt.DataCaption); + xml.WriteAttributeOptional("grandTotalCaption", pt.GrandTotalCaption); + xml.WriteAttributeOptional("errorCaption", pt.ErrorValueReplacement); + xml.WriteAttributeDefault("showError", pt.ShowError, false); + xml.WriteAttributeOptional("missingCaption", pt.MissingCaption); + xml.WriteAttributeDefault("showMissing", pt.ShowMissing, true); + xml.WriteAttributeOptional("pageStyle", pt.PageStyle); + xml.WriteAttributeOptional("pivotTableStyle", pt.PivotTableStyleName); + xml.WriteAttributeOptional("vacatedStyle", pt.VacatedStyle); + xml.WriteAttributeOptional("tag", pt.Tag); + xml.WriteAttributeDefault("updatedVersion", pt.UpdatedVersion, 0); + xml.WriteAttributeDefault("minRefreshableVersion", pt.MinRefreshableVersion, 0); + xml.WriteAttributeDefault("asteriskTotals", pt.AsteriskTotals, false); + xml.WriteAttributeDefault("showItems", pt.DisplayItemLabels, true); + xml.WriteAttributeDefault("editData", pt.EditData, false); + xml.WriteAttributeDefault("disableFieldList", pt.DisableFieldList, false); + xml.WriteAttributeDefault(@"showCalcMbrs", pt.ShowCalculatedMembers, true); + xml.WriteAttributeDefault("visualTotals", pt.VisualTotals, true); + xml.WriteAttributeDefault("showMultipleLabel", pt.ShowMultipleLabel, true); + xml.WriteAttributeDefault("showDataDropDown", pt.ShowDataDropDown, true); + xml.WriteAttributeDefault("showDrill", pt.ShowExpandCollapseButtons, true); + xml.WriteAttributeDefault("printDrill", pt.PrintExpandCollapsedButtons, false); + xml.WriteAttributeDefault("showMemberPropertyTips", pt.ShowPropertiesInTooltips, true); + xml.WriteAttributeDefault("showDataTips", pt.ShowContextualTooltips, true); + xml.WriteAttributeDefault("enableWizard", pt.EnableEditingMechanism, true); + xml.WriteAttributeDefault("enableDrill", pt.EnableShowDetails, true); + xml.WriteAttributeDefault("enableFieldProperties", pt.EnableFieldProperties, true); + xml.WriteAttributeDefault("preserveFormatting", pt.PreserveCellFormatting, true); + xml.WriteAttributeDefault("useAutoFormatting", pt.AutofitColumns, false); + xml.WriteAttributeDefault("pageWrap", checked((uint)pt.FilterFieldsPageWrap), 0); + xml.WriteAttributeDefault("pageOverThenDown", pt.FilterAreaOrder == XLFilterAreaOrder.OverThenDown, false); + xml.WriteAttributeDefault("subtotalHiddenItems", pt.FilteredItemsInSubtotals, false); + xml.WriteAttributeDefault("rowGrandTotals", pt.ShowGrandTotalsRows, true); + xml.WriteAttributeDefault("colGrandTotals", pt.ShowGrandTotalsColumns, true); + xml.WriteAttributeDefault("fieldPrintTitles", pt.PrintTitles, false); + xml.WriteAttributeDefault("itemPrintTitles", pt.RepeatRowLabels, false); + xml.WriteAttributeDefault("mergeItem", pt.MergeAndCenterWithLabels, false); + xml.WriteAttributeDefault("showDropZones", pt.ShowDropZones, true); + xml.WriteAttributeDefault("createdVersion", pt.PivotCacheCreatedVersion, 0); + xml.WriteAttributeDefault("indent", checked((uint)pt.RowLabelIndent), 1); + xml.WriteAttributeDefault("showEmptyRow", pt.ShowEmptyItemsOnRows, false); + xml.WriteAttributeDefault("showEmptyCol", pt.ShowEmptyItemsOnColumns, false); + xml.WriteAttributeDefault("showHeaders", pt.DisplayCaptionsAndDropdowns, true); + xml.WriteAttributeDefault("compact", pt.Compact, true); + xml.WriteAttributeDefault("outline", pt.Outline, false); + xml.WriteAttributeDefault("outlineData", pt.OutlineData, false); + xml.WriteAttributeDefault("compactData", pt.CompactData, true); + xml.WriteAttributeDefault("published", pt.Published, false); + xml.WriteAttributeDefault("gridDropZones", pt.ClassicPivotTableLayout, false); + xml.WriteAttributeDefault("immersive", pt.StopImmersiveUi, true); + xml.WriteAttributeDefault("multipleFieldFilters", pt.AllowMultipleFilters, true); + xml.WriteAttributeDefault("chartFormat", pt.ChartFormat, 0); + xml.WriteAttributeOptional("rowHeaderCaption", pt.RowHeaderCaption); + xml.WriteAttributeOptional("colHeaderCaption", pt.ColumnHeaderCaption); + xml.WriteAttributeDefault("fieldListSortAscending", pt.SortFieldsAtoZ, false); + xml.WriteAttributeDefault(@"mdxSubqueries", pt.MdxSubQueries, false); + xml.WriteAttributeDefault("customListSort", pt.UseCustomListsForSorting, true); + + // Location + xml.WriteStartElement("location", Main2006SsNs); + xml.WriteAttribute("ref", pt.Area.ToString()); + xml.WriteAttribute("firstHeaderRow", pt.FirstHeaderRow); + xml.WriteAttribute("firstDataRow", pt.FirstDataRow); + xml.WriteAttribute("firstDataCol", pt.FirstDataCol); + + var filterArea = pt.Filters.GetSize(); + xml.WriteAttributeDefault("rowPageCount", filterArea.Height, 0); + xml.WriteAttributeDefault("colPageCount", filterArea.Width, 0); + xml.WriteEndElement(); // location + + // Pivot Fields + xml.WriteStartElement("pivotFields", Main2006SsNs); + xml.WriteAttribute("count", pt.PivotFields.Count); + + foreach (var pf in pt.PivotFields) + { + xml.WriteStartElement("pivotField", Main2006SsNs); + xml.WriteAttributeOptional("name", pf.Name); + + if (pf.Axis is not null) + { + var axisAttr = GetAxisAttr(pf.Axis.Value); + xml.WriteAttribute("axis", axisAttr); + } + + xml.WriteAttributeDefault("dataField", pf.DataField, false); + xml.WriteAttributeOptional("subtotalCaption", pf.SubtotalCaption); + xml.WriteAttributeDefault("showDropDowns", pf.ShowDropDowns, true); + xml.WriteAttributeDefault("hiddenLevel", pf.HiddenLevel, false); + xml.WriteAttributeOptional("uniqueMemberProperty", pf.UniqueMemberProperty); + xml.WriteAttributeDefault("compact", pf.Compact, true); + xml.WriteAttributeDefault("allDrilled", pf.AllDrilled, false); + xml.WriteAttributeOptional("numFmtId", context.GetNumberFormat(pf.NumberFormatValue)); + xml.WriteAttributeDefault("outline", pf.Outline, true); + xml.WriteAttributeDefault("subtotalTop", pf.SubtotalTop, true); + xml.WriteAttributeDefault("dragToRow", pf.DragToRow, true); + xml.WriteAttributeDefault("dragToCol", pf.DragToColumn, true); + xml.WriteAttributeDefault("multipleItemSelectionAllowed", pf.MultipleItemSelectionAllowed, false); + xml.WriteAttributeDefault("dragToPage", pf.DragToPage, true); + xml.WriteAttributeDefault("dragToData", pf.DragToData, true); + xml.WriteAttributeDefault("dragOff", pf.DragOff, true); + xml.WriteAttributeDefault("showAll", pf.ShowAll, true); + xml.WriteAttributeDefault("insertBlankRow", pf.InsertBlankRow, false); + xml.WriteAttributeDefault("serverField", pf.ServerField, false); + xml.WriteAttributeDefault("insertPageBreak", pf.InsertPageBreak, false); + xml.WriteAttributeDefault("autoShow", pf.AutoShow, false); + xml.WriteAttributeDefault("topAutoShow", pf.TopAutoShow, true); + xml.WriteAttributeDefault("hideNewItems", pf.HideNewItems, false); + xml.WriteAttributeDefault("measureFilter", pf.MeasureFilter, false); + xml.WriteAttributeDefault("includeNewItemsInFilter", pf.IncludeNewItemsInFilter, false); + xml.WriteAttributeDefault("itemPageCount", pf.ItemPageCount, 10); + if (pf.SortType != XLPivotSortType.Default) + { + var sortTypeAttr = pf.SortType switch + { + XLPivotSortType.Default => "manual", + XLPivotSortType.Ascending => "ascending", + XLPivotSortType.Descending => "descending", + _ => throw new UnreachableException(), + }; + xml.WriteAttribute("sortType", sortTypeAttr); + } + + xml.WriteAttributeOptional("dataSourceSort", pf.DataSourceSort); + xml.WriteAttributeDefault("nonAutoSortDefault", pf.NonAutoSortDefault, false); + xml.WriteAttributeOptional("rankBy", pf.RankBy); + xml.WriteAttributeDefault("defaultSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.Automatic), true); + xml.WriteAttributeDefault("sumSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.Sum), false); + xml.WriteAttributeDefault("countASubtotal", pf.Subtotals.Contains(XLSubtotalFunction.Count), false); + xml.WriteAttributeDefault("avgSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.Average), false); + xml.WriteAttributeDefault("maxSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.Maximum), false); + xml.WriteAttributeDefault("minSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.Minimum), false); + xml.WriteAttributeDefault("productSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.Product), false); + xml.WriteAttributeDefault("countSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.CountNumbers), false); + xml.WriteAttributeDefault("stdDevSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.StandardDeviation), false); + xml.WriteAttributeDefault("stdDevPSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.PopulationStandardDeviation), false); + xml.WriteAttributeDefault("varSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.Variance), false); + xml.WriteAttributeDefault("varPSubtotal", pf.Subtotals.Contains(XLSubtotalFunction.PopulationVariance), false); + xml.WriteAttributeDefault("showPropCell", pf.ShowPropCell, false); + xml.WriteAttributeDefault("showPropTip", pf.ShowPropTip, false); + xml.WriteAttributeDefault("showPropAsCaption", pf.ShowPropAsCaption, false); + xml.WriteAttributeDefault("defaultAttributeDrillState", pf.DefaultAttributeDrillState, false); + + // items + if (pf.Items.Count > 0) + { + xml.WriteStartElement("items", Main2006SsNs); + xml.WriteAttribute("count", pf.Items.Count); + foreach (var pfItem in pf.Items) + { + xml.WriteStartElement("item", Main2006SsNs); + xml.WriteAttributeOptional("n", pfItem.ItemUserCaption); + if (pfItem.ItemType != XLPivotItemType.Data) + { + var itemTypeAttr = GetItemTypeAttr(pfItem.ItemType); + xml.WriteAttribute("t", itemTypeAttr); + } + + xml.WriteAttributeDefault("h", pfItem.Hidden, false); + xml.WriteAttributeDefault("s", pfItem.ValueIsString, false); + xml.WriteAttributeDefault("sd", pfItem.ShowDetails, true); + xml.WriteAttributeDefault("f", pfItem.CalculatedMember, false); + xml.WriteAttributeDefault("m", pfItem.Missing, false); + xml.WriteAttributeDefault("c", pfItem.ApproximatelyHasChildren, false); + xml.WriteAttributeOptional("x", pfItem.ItemIndex); + xml.WriteAttributeDefault("d", pfItem.Details, false); + xml.WriteAttributeDefault("e", pfItem.DrillAcrossAttributes, true); + xml.WriteEndElement(); // item + } + + xml.WriteEndElement(); // items + } + + // TODO: autoSortScope, but not yet represented. + + if (pf.RepeatItemLabels) + { + xml.WriteStartElement("extLst"); + xml.WriteStartElement("ext"); + xml.WriteAttributeString("uri", "{2946ED86-A175-432a-8AC1-64E0C546D7DE}"); + xml.WriteStartElement("pivotField", X14Main2009SsNs); + xml.WriteAttributeDefault("fillDownLabels", pf.RepeatItemLabels, false); + xml.WriteEndElement(); // pivotField + xml.WriteEndElement(); // ext + xml.WriteEndElement(); // extLst + } + + xml.WriteEndElement(); + } + + xml.WriteEndElement(); // pivotFields + + WriteAxis(xml, pt.RowAxis, "rowFields", "rowItems"); + WriteAxis(xml, pt.ColumnAxis, "colFields", "colItems"); + + var filterFields = pt.Filters.Fields; + if (filterFields.Count > 0) + { + xml.WriteStartElement("pageFields", Main2006SsNs); + xml.WriteAttribute("count", filterFields.Count); + foreach (var filterField in filterFields) + { + xml.WriteStartElement("pageField", Main2006SsNs); + xml.WriteAttribute("fld", filterField.Field); + xml.WriteAttributeOptional("item", filterField.ItemIndex); + xml.WriteAttributeOptional("hier", filterField.HierarchyIndex); + xml.WriteAttributeOptional("name", filterField.HierarchyUniqueName); + xml.WriteAttributeOptional("cap", filterField.HierarchyDisplayName); + xml.WriteEndElement(); // pageField + } + + xml.WriteEndElement(); // pageFields + } + + if (pt.DataFields.Count > 0) + { + xml.WriteStartElement("dataFields", Main2006SsNs); + xml.WriteAttribute("count", pt.DataFields.Count); + foreach (var dataField in pt.DataFields) + { + xml.WriteStartElement("dataField", Main2006SsNs); + xml.WriteAttributeOptional("name", dataField.DataFieldName); + xml.WriteAttribute("fld", dataField.Field); + if (dataField.Subtotal != XLPivotSummary.Sum) + { + var subtotalAttr = dataField.Subtotal switch + { + XLPivotSummary.Sum => "sum", + XLPivotSummary.Count => "count", + XLPivotSummary.Average => "average", + XLPivotSummary.Minimum => "min", + XLPivotSummary.Maximum => "max", + XLPivotSummary.Product => "product", + XLPivotSummary.CountNumbers => "countNums", + XLPivotSummary.StandardDeviation => "stdDev", + XLPivotSummary.PopulationStandardDeviation => "stdDevp", + XLPivotSummary.Variance => "var", + XLPivotSummary.PopulationVariance => "varp", + _ => throw new UnreachableException(), + }; + xml.WriteAttribute("subtotal", subtotalAttr); + } + + if (dataField.ShowDataAsFormat != XLPivotCalculation.Normal) + { + var showDataAsAttr = dataField.ShowDataAsFormat switch + { + XLPivotCalculation.Normal => "normal", + XLPivotCalculation.DifferenceFrom => "difference", + XLPivotCalculation.PercentageOf => "percent", + XLPivotCalculation.PercentageDifferenceFrom => "percentDiff", + XLPivotCalculation.RunningTotal => "runTotal", + XLPivotCalculation.PercentageOfRow => "percentOfRow", + XLPivotCalculation.PercentageOfColumn => "percentOfCol", + XLPivotCalculation.PercentageOfTotal => "percentOfTotal", + XLPivotCalculation.Index => "index", + _ => throw new UnreachableException(), + }; + xml.WriteAttribute("showDataAs", showDataAsAttr); + } + + xml.WriteAttributeDefault("baseField", dataField.BaseField, -1); + xml.WriteAttributeDefault("baseItem", dataField.BaseItem, 1048832); + xml.WriteAttributeOptional("numFmtId", context.GetNumberFormat(dataField.NumberFormatValue)); + + xml.WriteEndElement(); // dataField + } + + xml.WriteEndElement(); // dataFields + } + + if (pt.Formats.Count > 0) + { + xml.WriteStartElement("formats", Main2006SsNs); + xml.WriteAttribute("count", pt.Formats.Count); + foreach (var format in pt.Formats) + { + xml.WriteStartElement("format", Main2006SsNs); + if (format.Action != XLPivotFormatAction.Formatting) + { + var actionAttr = format.Action switch + { + XLPivotFormatAction.Blank => "blank", + XLPivotFormatAction.Formatting => "formatting", + _ => throw new UnreachableException(), + }; + xml.WriteAttribute("action", actionAttr); + } + + // DxfId is optional. + if (format.DxfStyleValue != XLStyleValue.Default) + { + var dxfId = context.DifferentialFormats[format.DxfStyleValue]; + xml.WriteAttribute("dxfId", dxfId); + } + + var pivotArea = format.PivotArea; + WritePivotArea(xml, pivotArea); + xml.WriteEndElement(); // format + } + xml.WriteEndElement(); // formats + } + + // Pivot table CF only specifies what should be formatted in PT. The actual CF + // specification is in sheet:conditionalFormatting that with a flag Pivot="1" + // and matching priority. + if (pt.ConditionalFormats.Count > 0) + { + xml.WriteStartElement("conditionalFormats", Main2006SsNs); + xml.WriteAttribute("count", pt.ConditionalFormats.Count); + foreach (var conditionalFormat in pt.ConditionalFormats) + { + xml.WriteStartElement("conditionalFormat", Main2006SsNs); + if (conditionalFormat.Scope != XLPivotCfScope.SelectedCells) + { + var scopeAttr = conditionalFormat.Scope switch + { + XLPivotCfScope.SelectedCells => "selection", + XLPivotCfScope.DataFields => "data", + XLPivotCfScope.FieldIntersections => "field", + _ => throw new UnreachableException(), + }; + xml.WriteAttribute("scope", scopeAttr); + } + + if (conditionalFormat.Type != XLPivotCfRuleType.None) + { + var typeAttr = conditionalFormat.Type switch + { + XLPivotCfRuleType.All => "all", + XLPivotCfRuleType.Column => "column", + XLPivotCfRuleType.None => "none", + XLPivotCfRuleType.Row => "row", + _ => throw new UnreachableException(), + }; + xml.WriteAttribute("type", typeAttr); + } + + xml.WriteAttribute("priority", conditionalFormat.Format.Priority); + xml.WriteStartElement("pivotAreas", Main2006SsNs); + xml.WriteAttribute("count", conditionalFormat.Areas.Count); + foreach (var pivotArea in conditionalFormat.Areas) + WritePivotArea(xml, pivotArea); + + xml.WriteEndElement(); // pivotAreas + xml.WriteEndElement(); // conditionalFormat + } + + xml.WriteEndElement(); // conditionalFormats + } + + var hasDefaultTheme = + pt.Theme == XLPivotTableTheme.None && + pt.ShowRowHeaders == false && + pt.ShowColumnHeaders == false && + pt.ShowRowStripes == false && + pt.ShowColumnStripes == false && + pt.ShowLastColumn == false; + if (!hasDefaultTheme) + { + xml.WriteStartElement("pivotTableStyleInfo", Main2006SsNs); + if (pt.Theme != XLPivotTableTheme.None) + xml.WriteAttribute("name", pt.Theme.ToString()); + + xml.WriteAttributeDefault("showRowHeaders", pt.ShowRowHeaders, false); + xml.WriteAttributeDefault("showColHeaders", pt.ShowColumnHeaders, false); + xml.WriteAttributeDefault("showRowStripes", pt.ShowRowStripes, false); + xml.WriteAttributeDefault("showColStripes", pt.ShowColumnStripes, false); + xml.WriteAttributeDefault("showLastColumn", pt.ShowLastColumn, false); + xml.WriteEndElement(); // pivotTableStyleInfo + } + + // Because extensions are pretty large, always write them. + xml.WriteStartElement("extLst"); + + { + // See [MS-XLSX] 2.2.4.5 Pivot Table + xml.WriteStartElement("ext", Main2006SsNs); + xml.WriteAttributeString("xmlns", "x14", null, X14Main2009SsNs); + xml.WriteAttributeString("uri", "{962EF5D1-5CA2-4c93-8EF4-DBF5C05439D2}"); + xml.WriteStartElement("pivotTableDefinition", X14Main2009SsNs); + xml.WriteAttribute("enableEdit", pt.EnableCellEditing); + xml.WriteAttribute("hideValuesRow", !pt.ShowValuesRow); + xml.WriteEndElement(); // pivotTableDefinition + xml.WriteEndElement(); // ext + } + + xml.WriteEndElement(); // extList + + xml.WriteEndElement(); // pivotTableDefinition + + xml.Close(); + } + + private static void WriteAxis(XmlWriter xml, XLPivotTableAxis axis, string fieldsElement, string itemsElement) + { + if (axis.Fields.Count > 0) + { + xml.WriteStartElement(fieldsElement, Main2006SsNs); + xml.WriteAttribute("count", axis.Fields.Count); + foreach (var axisField in axis.Fields) + { + xml.WriteStartElement("field", Main2006SsNs); + xml.WriteAttribute("x", axisField.Value); + xml.WriteEndElement(); + } + + xml.WriteEndElement(); // rowFields + } + + if (axis.Items.Count > 0) + { + xml.WriteStartElement(itemsElement, Main2006SsNs); + xml.WriteAttribute("count", axis.Items.Count); + + IReadOnlyList previous = Array.Empty(); + foreach (var axisItem in axis.Items) + { + xml.WriteStartElement("i", Main2006SsNs); + if (axisItem.ItemType != XLPivotItemType.Data) + { + var itemTypeAttr = GetItemTypeAttr(axisItem.ItemType); + xml.WriteAttribute("t", itemTypeAttr); + } + + // 'r' attribute means repeat data from previous axis item. + var r = 0; + var maxPrefixLen = Math.Min(previous.Count, axisItem.FieldItem.Count); + while (r < maxPrefixLen && previous[r] == axisItem.FieldItem[r]) + r++; + + // It seems that Excel always has at least one element, not sure if necessary, + // but it makes xml comparisons far easier. This is common for non-data type items. + if (r > 0 && r == axisItem.FieldItem.Count) + r--; + + xml.WriteAttributeDefault("r", r, 0); + xml.WriteAttributeDefault("i", axisItem.DataItem, 0); // Data field index + + foreach (var fieldItem in axisItem.FieldItem.Skip(r)) + { + xml.WriteStartElement("x", Main2006SsNs); + xml.WriteAttributeDefault("v", fieldItem, 0); + xml.WriteEndElement(); // x + } + + xml.WriteEndElement(); // i + previous = axisItem.FieldItem; + } + + xml.WriteEndElement(); + } + } + + private static void WritePivotArea(XmlWriter xml, XLPivotArea pivotArea) + { + xml.WriteStartElement("pivotArea", Main2006SsNs); + xml.WriteAttributeOptional("field", pivotArea.Field?.Value); + if (pivotArea.Type != XLPivotAreaType.Normal) + { + var typeAttr = pivotArea.Type switch + { + XLPivotAreaType.None => "none", + XLPivotAreaType.Normal => "normal", + XLPivotAreaType.Data => "data", + XLPivotAreaType.All => "all", + XLPivotAreaType.Origin => "origin", + XLPivotAreaType.Button => "button", + XLPivotAreaType.TopRight => "topRight", + XLPivotAreaType.TopEnd => "topEnd", + _ => throw new UnreachableException(), + }; + xml.WriteAttribute("type", typeAttr); + } + + xml.WriteAttributeDefault("dataOnly", pivotArea.DataOnly, true); + xml.WriteAttributeDefault("labelOnly", pivotArea.LabelOnly, false); + xml.WriteAttributeDefault("grandRow", pivotArea.GrandRow, false); + xml.WriteAttributeDefault("grandCol", pivotArea.GrandCol, false); + xml.WriteAttributeDefault("cacheIndex", pivotArea.CacheIndex, false); + xml.WriteAttributeDefault("outline", pivotArea.Outline, true); + if (pivotArea.Offset is not null) + xml.WriteAttribute("offset", pivotArea.Offset.ToString()); + + xml.WriteAttributeDefault("collapsedLevelsAreSubtotals", pivotArea.CollapsedLevelsAreSubtotals, false); + if (pivotArea.Axis is not null) + xml.WriteAttribute("axis", GetAxisAttr(pivotArea.Axis.Value)); + + xml.WriteAttributeOptional("fieldPosition", pivotArea.FieldPosition); + + if (pivotArea.References.Count > 0) + { + xml.WriteStartElement("references", Main2006SsNs); + xml.WriteAttribute("count", pivotArea.References.Count); + foreach (var reference in pivotArea.References) + { + xml.WriteStartElement("reference", Main2006SsNs); + xml.WriteAttributeOptional("field", reference.Field); + xml.WriteAttribute("count", reference.FieldItems.Count); + xml.WriteAttributeDefault("selected", reference.Selected, true); + xml.WriteAttributeDefault("byPosition", reference.ByPosition, false); + xml.WriteAttributeDefault("relative", reference.Relative, false); + var subtotals = reference.Subtotals; + xml.WriteAttributeDefault("defaultSubtotal", subtotals.Contains(XLSubtotalFunction.Automatic), false); + xml.WriteAttributeDefault("sumSubtotal", subtotals.Contains(XLSubtotalFunction.Sum), false); + xml.WriteAttributeDefault("countASubtotal", subtotals.Contains(XLSubtotalFunction.Count), false); + xml.WriteAttributeDefault("avgSubtotal", subtotals.Contains(XLSubtotalFunction.Average), false); + xml.WriteAttributeDefault("maxSubtotal", subtotals.Contains(XLSubtotalFunction.Maximum), false); + xml.WriteAttributeDefault("minSubtotal", subtotals.Contains(XLSubtotalFunction.Minimum), false); + xml.WriteAttributeDefault("productSubtotal", subtotals.Contains(XLSubtotalFunction.Product), false); + xml.WriteAttributeDefault("countSubtotal", subtotals.Contains(XLSubtotalFunction.CountNumbers), false); + xml.WriteAttributeDefault("stdDevSubtotal", subtotals.Contains(XLSubtotalFunction.StandardDeviation), false); + xml.WriteAttributeDefault("stdDevPSubtotal", subtotals.Contains(XLSubtotalFunction.PopulationStandardDeviation), false); + xml.WriteAttributeDefault("varSubtotal", subtotals.Contains(XLSubtotalFunction.Variance), false); + xml.WriteAttributeDefault("varPSubtotal", subtotals.Contains(XLSubtotalFunction.PopulationVariance), false); + + foreach (var fieldItem in reference.FieldItems) + { + xml.WriteStartElement("x", Main2006SsNs); + xml.WriteAttribute("v", fieldItem); + xml.WriteEndElement(); // x + } + + xml.WriteEndElement(); // reference + } + + xml.WriteEndElement(); // references + } + + xml.WriteEndElement(); // pivotArea + } + + private static string GetItemTypeAttr(XLPivotItemType itemType) + { + var itemTypeAttr = itemType switch + { + XLPivotItemType.Avg => "avg", + XLPivotItemType.Blank => "blank", + XLPivotItemType.Count => "count", + XLPivotItemType.CountA => "countA", + XLPivotItemType.Data => "data", + XLPivotItemType.Default => "default", + XLPivotItemType.Grand => "grand", + XLPivotItemType.Max => "max", + XLPivotItemType.Min => "min", + XLPivotItemType.Product => "product", + XLPivotItemType.StdDev => "stdDev", + XLPivotItemType.StdDevP => "stdDevP", + XLPivotItemType.Sum => "sum", + XLPivotItemType.Var => "var", + XLPivotItemType.VarP => "varP", + _ => throw new UnreachableException(), + }; + return itemTypeAttr; + } + + private static string GetAxisAttr(XLPivotAxis axis) + { + return axis switch + { + XLPivotAxis.AxisRow => "axisRow", + XLPivotAxis.AxisCol => "axisCol", + XLPivotAxis.AxisPage => "axisPage", + XLPivotAxis.AxisValues => "axisValues", + _ => throw new UnreachableException(), + }; + } +} diff --git a/ClosedXML/Excel/IO/SharedStringTableWriter.cs b/ClosedXML/Excel/IO/SharedStringTableWriter.cs new file mode 100644 index 000000000..9c49ddf1c --- /dev/null +++ b/ClosedXML/Excel/IO/SharedStringTableWriter.cs @@ -0,0 +1,67 @@ +using System.IO; +using System.Xml; +using ClosedXML.Utils; +using ClosedXML.Extensions; +using DocumentFormat.OpenXml.Packaging; +using static ClosedXML.Excel.XLWorkbook; +using static ClosedXML.Excel.IO.OpenXmlConst; + +namespace ClosedXML.Excel.IO +{ + internal class SharedStringTableWriter + { + internal static void GenerateSharedStringTablePartContent(XLWorkbook workbook, SharedStringTablePart sharedStringTablePart, + SaveContext context) + { + // Call all table headers to make sure their names are filled + workbook.Worksheets.ForEach(w => w.Tables.ForEach(t => _ = ((XLTable)t).FieldNames.Count)); + + var settings = new XmlWriterSettings + { + CloseOutput = true, + Encoding = XLHelper.NoBomUTF8 + }; + var partStream = sharedStringTablePart.GetStream(FileMode.Create); + using var xml = XmlWriter.Create(partStream, settings); + + xml.WriteStartDocument(); + + // Due to streaming and XLWorkbook structure, we don't know count before strings are written. + // Attributes count and uniqueCount are optional thus are omitted. + xml.WriteStartElement("x", "sst", Main2006SsNs); + + var sst = workbook.SharedStringTable; + var map = sst.GetConsecutiveMap(); + context.SstMap = map; + for (var sharedStringId = 0; sharedStringId < map.Count; ++sharedStringId) + { + var continuousId = map[sharedStringId]; + if (continuousId < 0) + continue; + + var richText = sst.GetRichText(sharedStringId); + if (richText is not null) + { + xml.WriteStartElement("si", Main2006SsNs); + TextSerializer.WriteRichTextElements(xml, richText, context); + xml.WriteEndElement(); // si + } + else + { + xml.WriteStartElement("si", Main2006SsNs); + xml.WriteStartElement("t", Main2006SsNs); + var sharedString = sst[sharedStringId]; + if (!sharedString.Trim().Equals(sharedString)) + xml.WritePreserveSpaceAttr(); + + xml.WriteString(XmlEncoder.EncodeString(sharedString)); + xml.WriteEndElement(); // t + xml.WriteEndElement(); // si + } + } + + xml.WriteEndElement(); // SharedStringTable + xml.Close(); + } + } +} diff --git a/ClosedXML/Excel/IO/StylesReader.cs b/ClosedXML/Excel/IO/StylesReader.cs new file mode 100644 index 000000000..b9b9eb843 --- /dev/null +++ b/ClosedXML/Excel/IO/StylesReader.cs @@ -0,0 +1,143 @@ +using ClosedXML.Excel.Formatting; +using ClosedXML.IO; + +namespace ClosedXML.Excel.IO; + +internal partial class StylesReader +{ + private readonly XmlTreeReader _reader; + private readonly XLWorkbookStyles _styles; + private readonly string _ns = OpenXmlConst.Main2006SsNs; + + /// + /// A marker for xf> parsing. The cellStyleXfs and cellXfs both use same + /// element and even same name. This flag is used on the hook to differentiate them. + /// + private bool _insideCellXfs = false; + + public StylesReader(XmlTreeReader reader, XLWorkbookStyles styles) + { + _reader = reader; + _styles = styles; + } + + internal void Load() + { + _reader.Open("styleSheet", _ns); + ParseStylesheet("styleSheet"); + } + + private void ParseFont(string elementName) + { + // Font is mostly buggy specification. Excel basically chokes on anything but a sequence, + // but standard requires an unbound choice where elements can repeat. + XLFontFormat format = default; + while (!_reader.TryClose(elementName, _ns)) + { + if (_reader.TryReadXStringValElement("name", _ns, out var fontName)) + { + format = format with { Name = fontName }; + } + else if (_reader.TryReadIntValElement("charset", _ns, out var charset)) + { + format = format with { Charset = (XLFontCharSet?)charset }; + } + else if (_reader.TryReadIntValElement("family", _ns, out var family)) + { + format = format with { Family = (XLFontFamilyNumberingValues)family }; + } + else if (_reader.TryReadBoolElement("b", _ns, out var b)) + { + format = format with { Bold = b }; + } + else if (_reader.TryReadBoolElement("i", _ns, out var i)) + { + format = format with { Italic = i }; + } + else if (_reader.TryReadBoolElement("strike", _ns, out var strike)) + { + format = format with { Strikethrough = strike }; + } + else if (_reader.TryReadBoolElement("outline", _ns, out var outline)) + { + format = format with { Outline = outline }; + } + else if (_reader.TryReadBoolElement("shadow", _ns, out var shadow)) + { + format = format with { Shadow = shadow }; + } + else if (_reader.TryReadBoolElement("condense", _ns, out var condense)) + { + format = format with { Condense = condense }; + } + else if (_reader.TryReadBoolElement("extend", _ns, out var extend)) + { + format = format with { Extend = extend }; + } + else if (_reader.TryReadColor("color", _ns, out var color)) + { + format = format with { Color = color }; + } + else if (_reader.TryOpen("sz", _ns)) + { + var fontSizePt = _reader.GetDouble("val"); + _reader.Close("sz", _ns); + format = format with { Size = XLFontSize.FromPoints(fontSizePt) }; + } + else if (_reader.TryOpen("u", _ns)) + { + var underline = _reader.GetOptionalEnum("val") ?? XLFontUnderlineValues.Single; + _reader.Close("u", _ns); + format = format with { Underline = underline }; + } + else if (_reader.TryReadEnumValElement("vertAlign", _ns, out var vertAlign)) + { + format = format with { VerticalAlignment = vertAlign }; + } + else if (_reader.TryReadEnumValElement("scheme", _ns, out var scheme)) + { + format = format with { Scheme = scheme }; + } + else + { + throw PartStructureException.ExpectedChoiceElementNotFound(_reader); + } + } + + _styles.AddFontFormat(format); + } + + private void ParseCellXfs(string elementName) + { + _insideCellXfs = true; + _reader.Open("xf", _ns); + do + { + ParseXf("xf"); + } + while (_reader.TryOpen("xf", _ns)); + _reader.Close(elementName, _ns); + _insideCellXfs = false; + } + + partial void OnXfParsed(uint? numFmtId, uint? fontId, uint? fillId, uint? borderId, uint? xfId, bool quotePrefix, bool pivotButton, bool? applyNumberFormat, bool? applyFont, bool? applyFill, bool? applyBorder, bool? applyAlignment, bool? applyProtection) + { + // When xf is parsed, all number formats, fonts, fills and borders should already be read. + // Skip cell style xfs for now. + if (_insideCellXfs) + { + // We are in cellXfs + _styles.AddFormat(fontId); + } + } + + private XLColor ParseColor(string elementName) + { + return _reader.ParseColor(elementName, _ns); + } + + private void ParseExtensionList(string elementName) + { + _reader.Skip(elementName); + } +} diff --git a/ClosedXML/Excel/IO/StylesReader.g.cs b/ClosedXML/Excel/IO/StylesReader.g.cs new file mode 100644 index 000000000..24231f311 --- /dev/null +++ b/ClosedXML/Excel/IO/StylesReader.g.cs @@ -0,0 +1,493 @@ +#nullable enable + +using System.Collections.Generic; +using ClosedXML.IO; +using ClosedXML.Excel.Formatting; + +namespace ClosedXML.Excel.IO; + +internal partial class StylesReader +{ + private void ParseStylesheet(string elementName) + { + if (_reader.TryOpen("numFmts", _ns)) + { + ParseNumFmts("numFmts"); + } + if (_reader.TryOpen("fonts", _ns)) + { + ParseFonts("fonts"); + } + if (_reader.TryOpen("fills", _ns)) + { + ParseFills("fills"); + } + if (_reader.TryOpen("borders", _ns)) + { + ParseBorders("borders"); + } + if (_reader.TryOpen("cellStyleXfs", _ns)) + { + ParseCellStyleXfs("cellStyleXfs"); + } + if (_reader.TryOpen("cellXfs", _ns)) + { + ParseCellXfs("cellXfs"); + } + if (_reader.TryOpen("cellStyles", _ns)) + { + ParseCellStyles("cellStyles"); + } + if (_reader.TryOpen("dxfs", _ns)) + { + ParseDxfs("dxfs"); + } + if (_reader.TryOpen("tableStyles", _ns)) + { + ParseTableStyles("tableStyles"); + } + if (_reader.TryOpen("colors", _ns)) + { + ParseColors("colors"); + } + if (_reader.TryOpen("extLst", _ns)) + { + ParseExtensionList("extLst"); + } + _reader.Close(elementName, _ns); + OnStylesheetParsed(); + } + + partial void OnStylesheetParsed(); + + private void ParseNumFmts(string elementName) + { + var count = _reader.GetOptionalUInt("count"); + while (_reader.TryOpen("numFmt", _ns)) + { + ParseNumFmt("numFmt"); + } + _reader.Close(elementName, _ns); + OnNumFmtsParsed(count); + } + + partial void OnNumFmtsParsed(uint? count); + + private void ParseNumFmt(string elementName) + { + var numFmtId = _reader.GetUInt("numFmtId"); + var formatCode = _reader.GetXString("formatCode"); + _reader.Close(elementName, _ns); + OnNumFmtParsed(numFmtId, formatCode); + } + + partial void OnNumFmtParsed(uint numFmtId, string formatCode); + + private void ParseFonts(string elementName) + { + var count = _reader.GetOptionalUInt("count"); + while (_reader.TryOpen("font", _ns)) + { + ParseFont("font"); + } + _reader.Close(elementName, _ns); + OnFontsParsed(count); + } + + partial void OnFontsParsed(uint? count); + + private void ParseFills(string elementName) + { + var count = _reader.GetOptionalUInt("count"); + while (_reader.TryOpen("fill", _ns)) + { + ParseFill("fill"); + } + _reader.Close(elementName, _ns); + OnFillsParsed(count); + } + + partial void OnFillsParsed(uint? count); + + private void ParseFill(string elementName) + { + if (_reader.TryOpen("patternFill", _ns)) + { + ParsePatternFill("patternFill"); + } + else if (_reader.TryOpen("gradientFill", _ns)) + { + ParseGradientFill("gradientFill"); + } + _reader.Close(elementName, _ns); + OnFillParsed(); + } + + partial void OnFillParsed(); + + private void ParsePatternFill(string elementName) + { + var patternType = _reader.GetOptionalEnum("patternType"); + XLColor? fgColor = default; + if (_reader.TryOpen("fgColor", _ns)) + { + fgColor = ParseColor("fgColor"); + } + XLColor? bgColor = default; + if (_reader.TryOpen("bgColor", _ns)) + { + bgColor = ParseColor("bgColor"); + } + _reader.Close(elementName, _ns); + OnPatternFillParsed(fgColor, bgColor, patternType); + } + + partial void OnPatternFillParsed(XLColor? fgColor, XLColor? bgColor, XLFillPatternValues? patternType); + + private void ParseGradientFill(string elementName) + { + var type = _reader.GetOptionalEnum("type") ?? XLGradientType.Linear; + var degree = _reader.GetOptionalDouble("degree") ?? 0; + var left = _reader.GetOptionalDouble("left") ?? 0; + var right = _reader.GetOptionalDouble("right") ?? 0; + var top = _reader.GetOptionalDouble("top") ?? 0; + var bottom = _reader.GetOptionalDouble("bottom") ?? 0; + while (_reader.TryOpen("stop", _ns)) + { + ParseGradientStop("stop"); + } + _reader.Close(elementName, _ns); + OnGradientFillParsed(type, degree, left, right, top, bottom); + } + + partial void OnGradientFillParsed(XLGradientType type, double degree, double left, double right, double top, double bottom); + + private void ParseGradientStop(string elementName) + { + var position = _reader.GetDouble("position"); + _reader.Open("color", _ns); + var color = ParseColor("color"); + _reader.Close(elementName, _ns); + OnGradientStopParsed(color, position); + } + + partial void OnGradientStopParsed(XLColor color, double position); + + private void ParseBorders(string elementName) + { + var count = _reader.GetOptionalUInt("count"); + while (_reader.TryOpen("border", _ns)) + { + ParseBorder("border"); + } + _reader.Close(elementName, _ns); + OnBordersParsed(count); + } + + partial void OnBordersParsed(uint? count); + + private void ParseBorder(string elementName) + { + var diagonalUp = _reader.GetOptionalBool("diagonalUp"); + var diagonalDown = _reader.GetOptionalBool("diagonalDown"); + var outline = _reader.GetOptionalBool("outline") ?? true; + if (_reader.TryOpen("left", _ns)) + { + ParseBorderPr("left"); + } + if (_reader.TryOpen("right", _ns)) + { + ParseBorderPr("right"); + } + if (_reader.TryOpen("top", _ns)) + { + ParseBorderPr("top"); + } + if (_reader.TryOpen("bottom", _ns)) + { + ParseBorderPr("bottom"); + } + if (_reader.TryOpen("diagonal", _ns)) + { + ParseBorderPr("diagonal"); + } + if (_reader.TryOpen("vertical", _ns)) + { + ParseBorderPr("vertical"); + } + if (_reader.TryOpen("horizontal", _ns)) + { + ParseBorderPr("horizontal"); + } + _reader.Close(elementName, _ns); + OnBorderParsed(diagonalUp, diagonalDown, outline); + } + + partial void OnBorderParsed(bool? diagonalUp, bool? diagonalDown, bool outline); + + private void ParseBorderPr(string elementName) + { + var style = _reader.GetOptionalEnum("style") ?? XLBorderStyleValues.None; + XLColor? color = default; + if (_reader.TryOpen("color", _ns)) + { + color = ParseColor("color"); + } + _reader.Close(elementName, _ns); + OnBorderPrParsed(color, style); + } + + partial void OnBorderPrParsed(XLColor? color, XLBorderStyleValues style); + + private void ParseCellStyleXfs(string elementName) + { + var count = _reader.GetOptionalUInt("count"); + _reader.Open("xf", _ns); + do + { + ParseXf("xf"); + } + while (_reader.TryOpen("xf", _ns)); + _reader.Close(elementName, _ns); + OnCellStyleXfsParsed(count); + } + + partial void OnCellStyleXfsParsed(uint? count); + + private void ParseXf(string elementName) + { + var numFmtId = _reader.GetOptionalUInt("numFmtId"); + var fontId = _reader.GetOptionalUInt("fontId"); + var fillId = _reader.GetOptionalUInt("fillId"); + var borderId = _reader.GetOptionalUInt("borderId"); + var xfId = _reader.GetOptionalUInt("xfId"); + var quotePrefix = _reader.GetOptionalBool("quotePrefix") ?? false; + var pivotButton = _reader.GetOptionalBool("pivotButton") ?? false; + var applyNumberFormat = _reader.GetOptionalBool("applyNumberFormat"); + var applyFont = _reader.GetOptionalBool("applyFont"); + var applyFill = _reader.GetOptionalBool("applyFill"); + var applyBorder = _reader.GetOptionalBool("applyBorder"); + var applyAlignment = _reader.GetOptionalBool("applyAlignment"); + var applyProtection = _reader.GetOptionalBool("applyProtection"); + if (_reader.TryOpen("alignment", _ns)) + { + ParseCellAlignment("alignment"); + } + if (_reader.TryOpen("protection", _ns)) + { + ParseCellProtection("protection"); + } + if (_reader.TryOpen("extLst", _ns)) + { + ParseExtensionList("extLst"); + } + _reader.Close(elementName, _ns); + OnXfParsed(numFmtId, fontId, fillId, borderId, xfId, quotePrefix, pivotButton, applyNumberFormat, applyFont, applyFill, applyBorder, applyAlignment, applyProtection); + } + + partial void OnXfParsed(uint? numFmtId, uint? fontId, uint? fillId, uint? borderId, uint? xfId, bool quotePrefix, bool pivotButton, bool? applyNumberFormat, bool? applyFont, bool? applyFill, bool? applyBorder, bool? applyAlignment, bool? applyProtection); + + private void ParseCellAlignment(string elementName) + { + var horizontal = _reader.GetOptionalEnum("horizontal"); + var vertical = _reader.GetOptionalEnum("vertical") ?? XLAlignmentVerticalValues.Bottom; + var textRotation = _reader.GetOptionalUInt("textRotation"); + var wrapText = _reader.GetOptionalBool("wrapText"); + var indent = _reader.GetOptionalUInt("indent"); + var relativeIndent = _reader.GetOptionalInt("relativeIndent"); + var justifyLastLine = _reader.GetOptionalBool("justifyLastLine"); + var shrinkToFit = _reader.GetOptionalBool("shrinkToFit"); + var readingOrder = _reader.GetOptionalUInt("readingOrder"); + _reader.Close(elementName, _ns); + OnCellAlignmentParsed(horizontal, vertical, textRotation, wrapText, indent, relativeIndent, justifyLastLine, shrinkToFit, readingOrder); + } + + partial void OnCellAlignmentParsed(XLAlignmentHorizontalValues? horizontal, XLAlignmentVerticalValues vertical, uint? textRotation, bool? wrapText, uint? indent, int? relativeIndent, bool? justifyLastLine, bool? shrinkToFit, uint? readingOrder); + + private void ParseCellProtection(string elementName) + { + var locked = _reader.GetOptionalBool("locked"); + var hidden = _reader.GetOptionalBool("hidden"); + _reader.Close(elementName, _ns); + OnCellProtectionParsed(locked, hidden); + } + + partial void OnCellProtectionParsed(bool? locked, bool? hidden); + + private void ParseCellStyles(string elementName) + { + var count = _reader.GetOptionalUInt("count"); + _reader.Open("cellStyle", _ns); + do + { + ParseCellStyle("cellStyle"); + } + while (_reader.TryOpen("cellStyle", _ns)); + _reader.Close(elementName, _ns); + OnCellStylesParsed(count); + } + + partial void OnCellStylesParsed(uint? count); + + private void ParseCellStyle(string elementName) + { + var name = _reader.GetOptionalXString("name"); + var xfId = _reader.GetUInt("xfId"); + var builtinId = _reader.GetOptionalUInt("builtinId"); + var iLevel = _reader.GetOptionalUInt("iLevel"); + var hidden = _reader.GetOptionalBool("hidden"); + var customBuiltin = _reader.GetOptionalBool("customBuiltin"); + if (_reader.TryOpen("extLst", _ns)) + { + ParseExtensionList("extLst"); + } + _reader.Close(elementName, _ns); + OnCellStyleParsed(name, xfId, builtinId, iLevel, hidden, customBuiltin); + } + + partial void OnCellStyleParsed(string? name, uint xfId, uint? builtinId, uint? iLevel, bool? hidden, bool? customBuiltin); + + private void ParseDxfs(string elementName) + { + var count = _reader.GetOptionalUInt("count"); + while (_reader.TryOpen("dxf", _ns)) + { + ParseDxf("dxf"); + } + _reader.Close(elementName, _ns); + OnDxfsParsed(count); + } + + partial void OnDxfsParsed(uint? count); + + private void ParseDxf(string elementName) + { + if (_reader.TryOpen("font", _ns)) + { + ParseFont("font"); + } + if (_reader.TryOpen("numFmt", _ns)) + { + ParseNumFmt("numFmt"); + } + if (_reader.TryOpen("fill", _ns)) + { + ParseFill("fill"); + } + if (_reader.TryOpen("alignment", _ns)) + { + ParseCellAlignment("alignment"); + } + if (_reader.TryOpen("border", _ns)) + { + ParseBorder("border"); + } + if (_reader.TryOpen("protection", _ns)) + { + ParseCellProtection("protection"); + } + if (_reader.TryOpen("extLst", _ns)) + { + ParseExtensionList("extLst"); + } + _reader.Close(elementName, _ns); + OnDxfParsed(); + } + + partial void OnDxfParsed(); + + private void ParseTableStyles(string elementName) + { + var count = _reader.GetOptionalUInt("count"); + var defaultTableStyle = _reader.GetOptionalString("defaultTableStyle"); + var defaultPivotStyle = _reader.GetOptionalString("defaultPivotStyle"); + while (_reader.TryOpen("tableStyle", _ns)) + { + ParseTableStyle("tableStyle"); + } + _reader.Close(elementName, _ns); + OnTableStylesParsed(count, defaultTableStyle, defaultPivotStyle); + } + + partial void OnTableStylesParsed(uint? count, string? defaultTableStyle, string? defaultPivotStyle); + + private void ParseTableStyle(string elementName) + { + var name = _reader.GetString("name"); + var pivot = _reader.GetOptionalBool("pivot") ?? true; + var table = _reader.GetOptionalBool("table") ?? true; + var count = _reader.GetOptionalUInt("count"); + while (_reader.TryOpen("tableStyleElement", _ns)) + { + ParseTableStyleElement("tableStyleElement"); + } + _reader.Close(elementName, _ns); + OnTableStyleParsed(name, pivot, table, count); + } + + partial void OnTableStyleParsed(string name, bool pivot, bool table, uint? count); + + private void ParseTableStyleElement(string elementName) + { + var type = _reader.GetEnum("type"); + var size = _reader.GetOptionalUInt("size") ?? 1; + var dxfId = _reader.GetOptionalUInt("dxfId"); + _reader.Close(elementName, _ns); + OnTableStyleElementParsed(type, size, dxfId); + } + + partial void OnTableStyleElementParsed(XLTableStyleType type, uint size, uint? dxfId); + + private void ParseColors(string elementName) + { + if (_reader.TryOpen("indexedColors", _ns)) + { + ParseIndexedColors("indexedColors"); + } + if (_reader.TryOpen("mruColors", _ns)) + { + ParseMRUColors("mruColors"); + } + _reader.Close(elementName, _ns); + OnColorsParsed(); + } + + partial void OnColorsParsed(); + + private void ParseIndexedColors(string elementName) + { + _reader.Open("rgbColor", _ns); + do + { + ParseRgbColor("rgbColor"); + } + while (_reader.TryOpen("rgbColor", _ns)); + _reader.Close(elementName, _ns); + OnIndexedColorsParsed(); + } + + partial void OnIndexedColorsParsed(); + + private void ParseMRUColors(string elementName) + { + _reader.Open("color", _ns); + do + { + ParseColor("color"); + } + while (_reader.TryOpen("color", _ns)); + _reader.Close(elementName, _ns); + OnMRUColorsParsed(); + } + + partial void OnMRUColorsParsed(); + + private void ParseRgbColor(string elementName) + { + var rgb = _reader.GetOptionalUIntHex("rgb"); + _reader.Close(elementName, _ns); + OnRgbColorParsed(rgb); + } + + partial void OnRgbColorParsed(uint? rgb); +} diff --git a/ClosedXML/Excel/IO/TablePartWriter.cs b/ClosedXML/Excel/IO/TablePartWriter.cs new file mode 100644 index 000000000..8b34c3e50 --- /dev/null +++ b/ClosedXML/Excel/IO/TablePartWriter.cs @@ -0,0 +1,186 @@ +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using System; +using System.Linq; +using static ClosedXML.Excel.XLWorkbook; + +namespace ClosedXML.Excel.IO +{ + /// + /// A writer for table definition part. + /// + internal class TablePartWriter + { + internal static void SynchronizeTableParts(XLTables tables, WorksheetPart worksheetPart, SaveContext context) + { + // Remove table definition parts that are not a part of workbook + foreach (var tableDefinitionPart in worksheetPart.GetPartsOfType().ToList()) + { + var partId = worksheetPart.GetIdOfPart(tableDefinitionPart); + var xlWorkbookContainsTable = tables.Cast().Any(t => t.RelId == partId); + if (!xlWorkbookContainsTable) + { + worksheetPart.DeletePart(tableDefinitionPart); + } + } + + foreach (var xlTable in tables.Cast()) + { + if (String.IsNullOrEmpty(xlTable.RelId)) + { + xlTable.RelId = context.RelIdGenerator.GetNext(RelType.Workbook); + worksheetPart.AddNewPart(xlTable.RelId); + } + } + } + + internal static void GenerateTableParts(XLTables tables, WorksheetPart worksheetPart, SaveContext context) + { + foreach (var xlTable in tables.Cast()) + { + var relId = xlTable.RelId; + var tableDefinitionPart = (TableDefinitionPart)worksheetPart.GetPartById(relId); + GenerateTableDefinitionPartContent(tableDefinitionPart, xlTable, context); + } + } + + private static void GenerateTableDefinitionPartContent(TableDefinitionPart tableDefinitionPart, XLTable xlTable, SaveContext context) + { + context.TableId++; + var reference = xlTable.RangeAddress.FirstAddress + ":" + xlTable.RangeAddress.LastAddress; + var tableName = GetTableName(xlTable.Name, context); + var table = new Table + { + Id = context.TableId, + Name = tableName, + DisplayName = tableName, + Reference = reference + }; + + if (!xlTable.ShowHeaderRow) + table.HeaderRowCount = 0; + + if (xlTable.ShowTotalsRow) + table.TotalsRowCount = 1; + else + table.TotalsRowShown = false; + + var tableColumns = new TableColumns { Count = (UInt32)xlTable.ColumnCount() }; + + UInt32 columnId = 0; + foreach (var xlField in xlTable.Fields) + { + columnId++; + var fieldName = xlField.Name; + var tableColumn = new TableColumn + { + Id = columnId, + Name = fieldName.Replace("_x000a_", "_x005f_x000a_").Replace(Environment.NewLine, "_x000a_") + }; + + // https://github.com/ClosedXML/ClosedXML/issues/513 + if (xlField.IsConsistentStyle()) + { + var style = ((XLStyle)xlField.Column.Cells() + .Skip(xlTable.ShowHeaderRow ? 1 : 0) + .First() + .Style).Value; + + if (!DefaultStyleValue.Equals(style) && context.DifferentialFormats.TryGetValue(style, out Int32 id)) + tableColumn.DataFormatId = UInt32Value.FromUInt32(Convert.ToUInt32(id)); + } + else + tableColumn.DataFormatId = null; + + if (xlField.IsConsistentFormula()) + { + string formula = xlField.Column.Cells() + .Skip(xlTable.ShowHeaderRow ? 1 : 0) + .First() + .FormulaA1; + + while (formula.StartsWith("=") && formula.Length > 1) + formula = formula.Substring(1); + + if (!String.IsNullOrWhiteSpace(formula)) + { + tableColumn.CalculatedColumnFormula = new CalculatedColumnFormula + { + Text = formula + }; + } + } + else + tableColumn.CalculatedColumnFormula = null; + + if (xlTable.ShowTotalsRow) + { + if (xlField.TotalsRowFunction != XLTotalsRowFunction.None) + { + tableColumn.TotalsRowFunction = xlField.TotalsRowFunction.ToOpenXml(); + + if (xlField.TotalsRowFunction == XLTotalsRowFunction.Custom) + tableColumn.TotalsRowFormula = new TotalsRowFormula(xlField.TotalsRowFormulaA1); + } + + if (!String.IsNullOrWhiteSpace(xlField.TotalsRowLabel)) + tableColumn.TotalsRowLabel = xlField.TotalsRowLabel; + } + tableColumns.AppendChild(tableColumn); + } + + var tableStyleInfo1 = new TableStyleInfo + { + ShowFirstColumn = xlTable.EmphasizeFirstColumn, + ShowLastColumn = xlTable.EmphasizeLastColumn, + ShowRowStripes = xlTable.ShowRowStripes, + ShowColumnStripes = xlTable.ShowColumnStripes + }; + + if (xlTable.Theme != XLTableTheme.None) + tableStyleInfo1.Name = xlTable.Theme.Name; + + if (xlTable.ShowAutoFilter) + { + var autoFilter1 = new AutoFilter(); + if (xlTable.ShowTotalsRow) + { + xlTable.AutoFilter.Range = xlTable.Worksheet.Range( + xlTable.RangeAddress.FirstAddress.RowNumber, xlTable.RangeAddress.FirstAddress.ColumnNumber, + xlTable.RangeAddress.LastAddress.RowNumber - 1, xlTable.RangeAddress.LastAddress.ColumnNumber); + } + else + xlTable.AutoFilter.Range = xlTable.Worksheet.Range(xlTable.RangeAddress); + + WorksheetPartWriter.PopulateAutoFilter(xlTable.AutoFilter, autoFilter1); + + table.AppendChild(autoFilter1); + } + + table.AppendChild(tableColumns); + table.AppendChild(tableStyleInfo1); + + tableDefinitionPart.Table = table; + } + + private static string GetTableName(String originalTableName, SaveContext context) + { + var tableName = originalTableName.RemoveSpecialCharacters(); + var name = tableName; + if (context.TableNames.Contains(name)) + { + var i = 1; + name = tableName + i.ToInvariantString(); + while (context.TableNames.Contains(name)) + { + i++; + name = tableName + i.ToInvariantString(); + } + } + + context.TableNames.Add(name); + return name; + } + } +} diff --git a/ClosedXML/Excel/IO/TextSerializer.cs b/ClosedXML/Excel/IO/TextSerializer.cs new file mode 100644 index 000000000..f65540ea3 --- /dev/null +++ b/ClosedXML/Excel/IO/TextSerializer.cs @@ -0,0 +1,139 @@ +#nullable disable + +using System; +using System.Xml; +using ClosedXML.Extensions; +using static ClosedXML.Excel.XLWorkbook; +using static ClosedXML.Excel.IO.OpenXmlConst; + +namespace ClosedXML.Excel.IO +{ + internal class TextSerializer + { + internal static void WriteRichTextElements(XmlWriter w, XLImmutableRichText richText, SaveContext context) + { + foreach (var textRun in richText.Runs) + { + var text = richText.GetRunText(textRun); + if (text.Length > 0) + { + WriteRun(w, text, textRun.Font); + } + } + + if (richText.PhoneticsProperties is not null) + { + var phoneticsProps = richText.PhoneticsProperties.Value; + foreach (var p in richText.PhoneticRuns) + { + w.WriteStartElement("rPh", Main2006SsNs); + w.WriteAttribute("sb", p.StartIndex); + w.WriteAttribute("eb", p.EndIndex); + + w.WriteStartElement("t", Main2006SsNs); + if (p.Text.PreserveSpaces()) + w.WritePreserveSpaceAttr(); + + w.WriteString(p.Text); + w.WriteEndElement(); // t + w.WriteEndElement(); // rPh + } + + var font = phoneticsProps.Font; + if (!context.SharedFonts.TryGetValue(font, out FontInfo fi)) + { + fi = new FontInfo { Font = font }; + context.SharedFonts.Add(font, fi); + } + + w.WriteStartElement("phoneticPr", Main2006SsNs); + w.WriteAttribute("fontId", fi.FontId); + + if (phoneticsProps.Alignment != XLPhoneticAlignment.Left) + w.WriteAttributeString("alignment", phoneticsProps.Alignment.ToOpenXmlString()); + + if (phoneticsProps.Type != XLPhoneticType.FullWidthKatakana) + w.WriteAttributeString("type", phoneticsProps.Type.ToOpenXmlString()); + + w.WriteEndElement(); // phoneticPr + } + } + + internal static void WriteRun(XmlWriter w, XLImmutableRichText richText, XLImmutableRichText.RichTextRun run) + { + var runText = richText.GetRunText(run); + WriteRun(w, runText, run.Font); + } + + private static void WriteRun(XmlWriter w, string text, XLFontValue font) + { + w.WriteStartElement("r", Main2006SsNs); + w.WriteStartElement("rPr", Main2006SsNs); + + if (font.Bold) + w.WriteEmptyElement("b"); + + if (font.Italic) + w.WriteEmptyElement("i"); + + if (font.Strikethrough) + w.WriteEmptyElement("strike"); + + // Three attributes are not stored/written: + // * outline - doesn't do anything and likely only works in Word. + // * condense - legacy compatibility setting for macs + // * extend - legacy compatibility setting for pre-xlsx Excels + // None have sensible descriptions. + + if (font.Shadow) + w.WriteEmptyElement("shadow"); + + if (font.Underline != XLFontUnderlineValues.None) + WriteRunProperty(w, "u", font.Underline.ToOpenXmlString()); + + WriteRunProperty(w, @"vertAlign", font.VerticalAlignment.ToOpenXmlString()); + WriteRunProperty(w, "sz", font.FontSize); + w.WriteColor("color", font.FontColor); + WriteRunProperty(w, "rFont", font.FontName); + WriteRunProperty(w, "family", (Int32)font.FontFamilyNumbering); + + if (font.FontCharSet != XLFontCharSet.Default) + WriteRunProperty(w, "charset", (int)font.FontCharSet); + + if (font.FontScheme != XLFontScheme.None) + WriteRunProperty(w, "scheme", font.FontScheme.ToOpenXml()); + + w.WriteEndElement(); // rPr + + w.WriteStartElement("t", Main2006SsNs); + if (text.PreserveSpaces()) + w.WritePreserveSpaceAttr(); + + w.WriteString(text); + + w.WriteEndElement(); // t + w.WriteEndElement(); // r + } + + private static void WriteRunProperty(XmlWriter w, String elName, String val) + { + w.WriteStartElement(elName, Main2006SsNs); + w.WriteAttributeString("val", val); + w.WriteEndElement(); + } + + private static void WriteRunProperty(XmlWriter w, String elName, Int32 val) + { + w.WriteStartElement(elName, Main2006SsNs); + w.WriteAttribute("val", val); + w.WriteEndElement(); + } + + private static void WriteRunProperty(XmlWriter w, String elName, Double val) + { + w.WriteStartElement(elName, Main2006SsNs); + w.WriteAttribute("val", val); + w.WriteEndElement(); + } + } +} diff --git a/ClosedXML/Excel/IO/ThemePartWriter.cs b/ClosedXML/Excel/IO/ThemePartWriter.cs new file mode 100644 index 000000000..dbea87c10 --- /dev/null +++ b/ClosedXML/Excel/IO/ThemePartWriter.cs @@ -0,0 +1,618 @@ +using DocumentFormat.OpenXml.Drawing; +using DocumentFormat.OpenXml.Packaging; + +namespace ClosedXML.Excel.IO +{ + internal class ThemePartWriter + { + internal static void GenerateContent(ThemePart themePart, XLTheme theme) + { + var theme1 = new Theme { Name = "Office Theme" }; + theme1.AddNamespaceDeclaration("a", "http://schemas.openxmlformats.org/drawingml/2006/main"); + + var themeElements1 = new ThemeElements(); + + var colorScheme1 = new ColorScheme { Name = "Office" }; + + var dark1Color1 = new Dark1Color(); + var systemColor1 = new SystemColor + { + Val = SystemColorValues.WindowText, + LastColor = theme.Text1.Color.ToHex().Substring(2) + }; + + dark1Color1.AppendChild(systemColor1); + + var light1Color1 = new Light1Color(); + var systemColor2 = new SystemColor + { + Val = SystemColorValues.Window, + LastColor = theme.Background1.Color.ToHex().Substring(2) + }; + + light1Color1.AppendChild(systemColor2); + + var dark2Color1 = new Dark2Color(); + var rgbColorModelHex1 = new RgbColorModelHex { Val = theme.Text2.Color.ToHex().Substring(2) }; + + dark2Color1.AppendChild(rgbColorModelHex1); + + var light2Color1 = new Light2Color(); + var rgbColorModelHex2 = new RgbColorModelHex { Val = theme.Background2.Color.ToHex().Substring(2) }; + + light2Color1.AppendChild(rgbColorModelHex2); + + var accent1Color1 = new Accent1Color(); + var rgbColorModelHex3 = new RgbColorModelHex { Val = theme.Accent1.Color.ToHex().Substring(2) }; + + accent1Color1.AppendChild(rgbColorModelHex3); + + var accent2Color1 = new Accent2Color(); + var rgbColorModelHex4 = new RgbColorModelHex { Val = theme.Accent2.Color.ToHex().Substring(2) }; + + accent2Color1.AppendChild(rgbColorModelHex4); + + var accent3Color1 = new Accent3Color(); + var rgbColorModelHex5 = new RgbColorModelHex { Val = theme.Accent3.Color.ToHex().Substring(2) }; + + accent3Color1.AppendChild(rgbColorModelHex5); + + var accent4Color1 = new Accent4Color(); + var rgbColorModelHex6 = new RgbColorModelHex { Val = theme.Accent4.Color.ToHex().Substring(2) }; + + accent4Color1.AppendChild(rgbColorModelHex6); + + var accent5Color1 = new Accent5Color(); + var rgbColorModelHex7 = new RgbColorModelHex { Val = theme.Accent5.Color.ToHex().Substring(2) }; + + accent5Color1.AppendChild(rgbColorModelHex7); + + var accent6Color1 = new Accent6Color(); + var rgbColorModelHex8 = new RgbColorModelHex { Val = theme.Accent6.Color.ToHex().Substring(2) }; + + accent6Color1.AppendChild(rgbColorModelHex8); + + var hyperlink1 = new DocumentFormat.OpenXml.Drawing.Hyperlink(); + var rgbColorModelHex9 = new RgbColorModelHex { Val = theme.Hyperlink.Color.ToHex().Substring(2) }; + + hyperlink1.AppendChild(rgbColorModelHex9); + + var followedHyperlinkColor1 = new FollowedHyperlinkColor(); + var rgbColorModelHex10 = new RgbColorModelHex { Val = theme.FollowedHyperlink.Color.ToHex().Substring(2) }; + + followedHyperlinkColor1.AppendChild(rgbColorModelHex10); + + colorScheme1.AppendChild(dark1Color1); + colorScheme1.AppendChild(light1Color1); + colorScheme1.AppendChild(dark2Color1); + colorScheme1.AppendChild(light2Color1); + colorScheme1.AppendChild(accent1Color1); + colorScheme1.AppendChild(accent2Color1); + colorScheme1.AppendChild(accent3Color1); + colorScheme1.AppendChild(accent4Color1); + colorScheme1.AppendChild(accent5Color1); + colorScheme1.AppendChild(accent6Color1); + colorScheme1.AppendChild(hyperlink1); + colorScheme1.AppendChild(followedHyperlinkColor1); + + var fontScheme2 = new FontScheme { Name = "Office" }; + + var majorFont1 = new MajorFont(); + var latinFont1 = new LatinFont { Typeface = "Cambria" }; + var eastAsianFont1 = new EastAsianFont { Typeface = "" }; + var complexScriptFont1 = new ComplexScriptFont { Typeface = "" }; + var supplementalFont1 = new SupplementalFont { Script = "Jpan", Typeface = "MS Pゴシック" }; + var supplementalFont2 = new SupplementalFont { Script = "Hang", Typeface = "맑은 고딕" }; + var supplementalFont3 = new SupplementalFont { Script = "Hans", Typeface = "宋体" }; + var supplementalFont4 = new SupplementalFont { Script = "Hant", Typeface = "新細明體" }; + var supplementalFont5 = new SupplementalFont { Script = "Arab", Typeface = "Times New Roman" }; + var supplementalFont6 = new SupplementalFont { Script = "Hebr", Typeface = "Times New Roman" }; + var supplementalFont7 = new SupplementalFont { Script = "Thai", Typeface = "Tahoma" }; + var supplementalFont8 = new SupplementalFont { Script = "Ethi", Typeface = "Nyala" }; + var supplementalFont9 = new SupplementalFont { Script = "Beng", Typeface = "Vrinda" }; + var supplementalFont10 = new SupplementalFont { Script = "Gujr", Typeface = "Shruti" }; + var supplementalFont11 = new SupplementalFont { Script = "Khmr", Typeface = "MoolBoran" }; + var supplementalFont12 = new SupplementalFont { Script = "Knda", Typeface = "Tunga" }; + var supplementalFont13 = new SupplementalFont { Script = "Guru", Typeface = "Raavi" }; + var supplementalFont14 = new SupplementalFont { Script = "Cans", Typeface = "Euphemia" }; + var supplementalFont15 = new SupplementalFont { Script = "Cher", Typeface = "Plantagenet Cherokee" }; + var supplementalFont16 = new SupplementalFont { Script = "Yiii", Typeface = "Microsoft Yi Baiti" }; + var supplementalFont17 = new SupplementalFont { Script = "Tibt", Typeface = "Microsoft Himalaya" }; + var supplementalFont18 = new SupplementalFont { Script = "Thaa", Typeface = "MV Boli" }; + var supplementalFont19 = new SupplementalFont { Script = "Deva", Typeface = "Mangal" }; + var supplementalFont20 = new SupplementalFont { Script = "Telu", Typeface = "Gautami" }; + var supplementalFont21 = new SupplementalFont { Script = "Taml", Typeface = "Latha" }; + var supplementalFont22 = new SupplementalFont { Script = "Syrc", Typeface = "Estrangelo Edessa" }; + var supplementalFont23 = new SupplementalFont { Script = "Orya", Typeface = "Kalinga" }; + var supplementalFont24 = new SupplementalFont { Script = "Mlym", Typeface = "Kartika" }; + var supplementalFont25 = new SupplementalFont { Script = "Laoo", Typeface = "DokChampa" }; + var supplementalFont26 = new SupplementalFont { Script = "Sinh", Typeface = "Iskoola Pota" }; + var supplementalFont27 = new SupplementalFont { Script = "Mong", Typeface = "Mongolian Baiti" }; + var supplementalFont28 = new SupplementalFont { Script = "Viet", Typeface = "Times New Roman" }; + var supplementalFont29 = new SupplementalFont { Script = "Uigh", Typeface = "Microsoft Uighur" }; + + majorFont1.AppendChild(latinFont1); + majorFont1.AppendChild(eastAsianFont1); + majorFont1.AppendChild(complexScriptFont1); + majorFont1.AppendChild(supplementalFont1); + majorFont1.AppendChild(supplementalFont2); + majorFont1.AppendChild(supplementalFont3); + majorFont1.AppendChild(supplementalFont4); + majorFont1.AppendChild(supplementalFont5); + majorFont1.AppendChild(supplementalFont6); + majorFont1.AppendChild(supplementalFont7); + majorFont1.AppendChild(supplementalFont8); + majorFont1.AppendChild(supplementalFont9); + majorFont1.AppendChild(supplementalFont10); + majorFont1.AppendChild(supplementalFont11); + majorFont1.AppendChild(supplementalFont12); + majorFont1.AppendChild(supplementalFont13); + majorFont1.AppendChild(supplementalFont14); + majorFont1.AppendChild(supplementalFont15); + majorFont1.AppendChild(supplementalFont16); + majorFont1.AppendChild(supplementalFont17); + majorFont1.AppendChild(supplementalFont18); + majorFont1.AppendChild(supplementalFont19); + majorFont1.AppendChild(supplementalFont20); + majorFont1.AppendChild(supplementalFont21); + majorFont1.AppendChild(supplementalFont22); + majorFont1.AppendChild(supplementalFont23); + majorFont1.AppendChild(supplementalFont24); + majorFont1.AppendChild(supplementalFont25); + majorFont1.AppendChild(supplementalFont26); + majorFont1.AppendChild(supplementalFont27); + majorFont1.AppendChild(supplementalFont28); + majorFont1.AppendChild(supplementalFont29); + + var minorFont1 = new MinorFont(); + var latinFont2 = new LatinFont { Typeface = "Calibri" }; + var eastAsianFont2 = new EastAsianFont { Typeface = "" }; + var complexScriptFont2 = new ComplexScriptFont { Typeface = "" }; + var supplementalFont30 = new SupplementalFont { Script = "Jpan", Typeface = "MS Pゴシック" }; + var supplementalFont31 = new SupplementalFont { Script = "Hang", Typeface = "맑은 고딕" }; + var supplementalFont32 = new SupplementalFont { Script = "Hans", Typeface = "宋体" }; + var supplementalFont33 = new SupplementalFont { Script = "Hant", Typeface = "新細明體" }; + var supplementalFont34 = new SupplementalFont { Script = "Arab", Typeface = "Arial" }; + var supplementalFont35 = new SupplementalFont { Script = "Hebr", Typeface = "Arial" }; + var supplementalFont36 = new SupplementalFont { Script = "Thai", Typeface = "Tahoma" }; + var supplementalFont37 = new SupplementalFont { Script = "Ethi", Typeface = "Nyala" }; + var supplementalFont38 = new SupplementalFont { Script = "Beng", Typeface = "Vrinda" }; + var supplementalFont39 = new SupplementalFont { Script = "Gujr", Typeface = "Shruti" }; + var supplementalFont40 = new SupplementalFont { Script = "Khmr", Typeface = "DaunPenh" }; + var supplementalFont41 = new SupplementalFont { Script = "Knda", Typeface = "Tunga" }; + var supplementalFont42 = new SupplementalFont { Script = "Guru", Typeface = "Raavi" }; + var supplementalFont43 = new SupplementalFont { Script = "Cans", Typeface = "Euphemia" }; + var supplementalFont44 = new SupplementalFont { Script = "Cher", Typeface = "Plantagenet Cherokee" }; + var supplementalFont45 = new SupplementalFont { Script = "Yiii", Typeface = "Microsoft Yi Baiti" }; + var supplementalFont46 = new SupplementalFont { Script = "Tibt", Typeface = "Microsoft Himalaya" }; + var supplementalFont47 = new SupplementalFont { Script = "Thaa", Typeface = "MV Boli" }; + var supplementalFont48 = new SupplementalFont { Script = "Deva", Typeface = "Mangal" }; + var supplementalFont49 = new SupplementalFont { Script = "Telu", Typeface = "Gautami" }; + var supplementalFont50 = new SupplementalFont { Script = "Taml", Typeface = "Latha" }; + var supplementalFont51 = new SupplementalFont { Script = "Syrc", Typeface = "Estrangelo Edessa" }; + var supplementalFont52 = new SupplementalFont { Script = "Orya", Typeface = "Kalinga" }; + var supplementalFont53 = new SupplementalFont { Script = "Mlym", Typeface = "Kartika" }; + var supplementalFont54 = new SupplementalFont { Script = "Laoo", Typeface = "DokChampa" }; + var supplementalFont55 = new SupplementalFont { Script = "Sinh", Typeface = "Iskoola Pota" }; + var supplementalFont56 = new SupplementalFont { Script = "Mong", Typeface = "Mongolian Baiti" }; + var supplementalFont57 = new SupplementalFont { Script = "Viet", Typeface = "Arial" }; + var supplementalFont58 = new SupplementalFont { Script = "Uigh", Typeface = "Microsoft Uighur" }; + + minorFont1.AppendChild(latinFont2); + minorFont1.AppendChild(eastAsianFont2); + minorFont1.AppendChild(complexScriptFont2); + minorFont1.AppendChild(supplementalFont30); + minorFont1.AppendChild(supplementalFont31); + minorFont1.AppendChild(supplementalFont32); + minorFont1.AppendChild(supplementalFont33); + minorFont1.AppendChild(supplementalFont34); + minorFont1.AppendChild(supplementalFont35); + minorFont1.AppendChild(supplementalFont36); + minorFont1.AppendChild(supplementalFont37); + minorFont1.AppendChild(supplementalFont38); + minorFont1.AppendChild(supplementalFont39); + minorFont1.AppendChild(supplementalFont40); + minorFont1.AppendChild(supplementalFont41); + minorFont1.AppendChild(supplementalFont42); + minorFont1.AppendChild(supplementalFont43); + minorFont1.AppendChild(supplementalFont44); + minorFont1.AppendChild(supplementalFont45); + minorFont1.AppendChild(supplementalFont46); + minorFont1.AppendChild(supplementalFont47); + minorFont1.AppendChild(supplementalFont48); + minorFont1.AppendChild(supplementalFont49); + minorFont1.AppendChild(supplementalFont50); + minorFont1.AppendChild(supplementalFont51); + minorFont1.AppendChild(supplementalFont52); + minorFont1.AppendChild(supplementalFont53); + minorFont1.AppendChild(supplementalFont54); + minorFont1.AppendChild(supplementalFont55); + minorFont1.AppendChild(supplementalFont56); + minorFont1.AppendChild(supplementalFont57); + minorFont1.AppendChild(supplementalFont58); + + fontScheme2.AppendChild(majorFont1); + fontScheme2.AppendChild(minorFont1); + + var formatScheme1 = new FormatScheme { Name = "Office" }; + + var fillStyleList1 = new FillStyleList(); + + var solidFill1 = new SolidFill(); + var schemeColor1 = new SchemeColor { Val = SchemeColorValues.PhColor }; + + solidFill1.AppendChild(schemeColor1); + + var gradientFill1 = new GradientFill { RotateWithShape = true }; + + var gradientStopList1 = new GradientStopList(); + + var gradientStop1 = new GradientStop { Position = 0 }; + + var schemeColor2 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var tint1 = new Tint { Val = 50000 }; + var saturationModulation1 = new SaturationModulation { Val = 300000 }; + + schemeColor2.AppendChild(tint1); + schemeColor2.AppendChild(saturationModulation1); + + gradientStop1.AppendChild(schemeColor2); + + var gradientStop2 = new GradientStop { Position = 35000 }; + + var schemeColor3 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var tint2 = new Tint { Val = 37000 }; + var saturationModulation2 = new SaturationModulation { Val = 300000 }; + + schemeColor3.AppendChild(tint2); + schemeColor3.AppendChild(saturationModulation2); + + gradientStop2.AppendChild(schemeColor3); + + var gradientStop3 = new GradientStop { Position = 100000 }; + + var schemeColor4 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var tint3 = new Tint { Val = 15000 }; + var saturationModulation3 = new SaturationModulation { Val = 350000 }; + + schemeColor4.AppendChild(tint3); + schemeColor4.AppendChild(saturationModulation3); + + gradientStop3.AppendChild(schemeColor4); + + gradientStopList1.AppendChild(gradientStop1); + gradientStopList1.AppendChild(gradientStop2); + gradientStopList1.AppendChild(gradientStop3); + var linearGradientFill1 = new LinearGradientFill { Angle = 16200000, Scaled = true }; + + gradientFill1.AppendChild(gradientStopList1); + gradientFill1.AppendChild(linearGradientFill1); + + var gradientFill2 = new GradientFill { RotateWithShape = true }; + + var gradientStopList2 = new GradientStopList(); + + var gradientStop4 = new GradientStop { Position = 0 }; + + var schemeColor5 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var shade1 = new Shade { Val = 51000 }; + var saturationModulation4 = new SaturationModulation { Val = 130000 }; + + schemeColor5.AppendChild(shade1); + schemeColor5.AppendChild(saturationModulation4); + + gradientStop4.AppendChild(schemeColor5); + + var gradientStop5 = new GradientStop { Position = 80000 }; + + var schemeColor6 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var shade2 = new Shade { Val = 93000 }; + var saturationModulation5 = new SaturationModulation { Val = 130000 }; + + schemeColor6.AppendChild(shade2); + schemeColor6.AppendChild(saturationModulation5); + + gradientStop5.AppendChild(schemeColor6); + + var gradientStop6 = new GradientStop { Position = 100000 }; + + var schemeColor7 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var shade3 = new Shade { Val = 94000 }; + var saturationModulation6 = new SaturationModulation { Val = 135000 }; + + schemeColor7.AppendChild(shade3); + schemeColor7.AppendChild(saturationModulation6); + + gradientStop6.AppendChild(schemeColor7); + + gradientStopList2.AppendChild(gradientStop4); + gradientStopList2.AppendChild(gradientStop5); + gradientStopList2.AppendChild(gradientStop6); + var linearGradientFill2 = new LinearGradientFill { Angle = 16200000, Scaled = false }; + + gradientFill2.AppendChild(gradientStopList2); + gradientFill2.AppendChild(linearGradientFill2); + + fillStyleList1.AppendChild(solidFill1); + fillStyleList1.AppendChild(gradientFill1); + fillStyleList1.AppendChild(gradientFill2); + + var lineStyleList1 = new LineStyleList(); + + var outline1 = new Outline + { + Width = 9525, + CapType = LineCapValues.Flat, + CompoundLineType = CompoundLineValues.Single, + Alignment = PenAlignmentValues.Center + }; + + var solidFill2 = new SolidFill(); + + var schemeColor8 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var shade4 = new Shade { Val = 95000 }; + var saturationModulation7 = new SaturationModulation { Val = 105000 }; + + schemeColor8.AppendChild(shade4); + schemeColor8.AppendChild(saturationModulation7); + + solidFill2.AppendChild(schemeColor8); + var presetDash1 = new PresetDash { Val = PresetLineDashValues.Solid }; + + outline1.AppendChild(solidFill2); + outline1.AppendChild(presetDash1); + + var outline2 = new Outline + { + Width = 25400, + CapType = LineCapValues.Flat, + CompoundLineType = CompoundLineValues.Single, + Alignment = PenAlignmentValues.Center + }; + + var solidFill3 = new SolidFill(); + var schemeColor9 = new SchemeColor { Val = SchemeColorValues.PhColor }; + + solidFill3.AppendChild(schemeColor9); + var presetDash2 = new PresetDash { Val = PresetLineDashValues.Solid }; + + outline2.AppendChild(solidFill3); + outline2.AppendChild(presetDash2); + + var outline3 = new Outline + { + Width = 38100, + CapType = LineCapValues.Flat, + CompoundLineType = CompoundLineValues.Single, + Alignment = PenAlignmentValues.Center + }; + + var solidFill4 = new SolidFill(); + var schemeColor10 = new SchemeColor { Val = SchemeColorValues.PhColor }; + + solidFill4.AppendChild(schemeColor10); + var presetDash3 = new PresetDash { Val = PresetLineDashValues.Solid }; + + outline3.AppendChild(solidFill4); + outline3.AppendChild(presetDash3); + + lineStyleList1.AppendChild(outline1); + lineStyleList1.AppendChild(outline2); + lineStyleList1.AppendChild(outline3); + + var effectStyleList1 = new EffectStyleList(); + + var effectStyle1 = new EffectStyle(); + + var effectList1 = new EffectList(); + + var outerShadow1 = new OuterShadow + { + BlurRadius = 40000L, + Distance = 20000L, + Direction = 5400000, + RotateWithShape = false + }; + + var rgbColorModelHex11 = new RgbColorModelHex { Val = "000000" }; + var alpha1 = new Alpha { Val = 38000 }; + + rgbColorModelHex11.AppendChild(alpha1); + + outerShadow1.AppendChild(rgbColorModelHex11); + + effectList1.AppendChild(outerShadow1); + + effectStyle1.AppendChild(effectList1); + + var effectStyle2 = new EffectStyle(); + + var effectList2 = new EffectList(); + + var outerShadow2 = new OuterShadow + { + BlurRadius = 40000L, + Distance = 23000L, + Direction = 5400000, + RotateWithShape = false + }; + + var rgbColorModelHex12 = new RgbColorModelHex { Val = "000000" }; + var alpha2 = new Alpha { Val = 35000 }; + + rgbColorModelHex12.AppendChild(alpha2); + + outerShadow2.AppendChild(rgbColorModelHex12); + + effectList2.AppendChild(outerShadow2); + + effectStyle2.AppendChild(effectList2); + + var effectStyle3 = new EffectStyle(); + + var effectList3 = new EffectList(); + + var outerShadow3 = new OuterShadow + { + BlurRadius = 40000L, + Distance = 23000L, + Direction = 5400000, + RotateWithShape = false + }; + + var rgbColorModelHex13 = new RgbColorModelHex { Val = "000000" }; + var alpha3 = new Alpha { Val = 35000 }; + + rgbColorModelHex13.AppendChild(alpha3); + + outerShadow3.AppendChild(rgbColorModelHex13); + + effectList3.AppendChild(outerShadow3); + + var scene3DType1 = new Scene3DType(); + + var camera1 = new Camera { Preset = PresetCameraValues.OrthographicFront }; + var rotation1 = new Rotation { Latitude = 0, Longitude = 0, Revolution = 0 }; + + camera1.AppendChild(rotation1); + + var lightRig1 = new LightRig { Rig = LightRigValues.ThreePoints, Direction = LightRigDirectionValues.Top }; + var rotation2 = new Rotation { Latitude = 0, Longitude = 0, Revolution = 1200000 }; + + lightRig1.AppendChild(rotation2); + + scene3DType1.AppendChild(camera1); + scene3DType1.AppendChild(lightRig1); + + var shape3DType1 = new Shape3DType(); + var bevelTop1 = new BevelTop { Width = 63500L, Height = 25400L }; + + shape3DType1.AppendChild(bevelTop1); + + effectStyle3.AppendChild(effectList3); + effectStyle3.AppendChild(scene3DType1); + effectStyle3.AppendChild(shape3DType1); + + effectStyleList1.AppendChild(effectStyle1); + effectStyleList1.AppendChild(effectStyle2); + effectStyleList1.AppendChild(effectStyle3); + + var backgroundFillStyleList1 = new BackgroundFillStyleList(); + + var solidFill5 = new SolidFill(); + var schemeColor11 = new SchemeColor { Val = SchemeColorValues.PhColor }; + + solidFill5.AppendChild(schemeColor11); + + var gradientFill3 = new GradientFill { RotateWithShape = true }; + + var gradientStopList3 = new GradientStopList(); + + var gradientStop7 = new GradientStop { Position = 0 }; + + var schemeColor12 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var tint4 = new Tint { Val = 40000 }; + var saturationModulation8 = new SaturationModulation { Val = 350000 }; + + schemeColor12.AppendChild(tint4); + schemeColor12.AppendChild(saturationModulation8); + + gradientStop7.AppendChild(schemeColor12); + + var gradientStop8 = new GradientStop { Position = 40000 }; + + var schemeColor13 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var tint5 = new Tint { Val = 45000 }; + var shade5 = new Shade { Val = 99000 }; + var saturationModulation9 = new SaturationModulation { Val = 350000 }; + + schemeColor13.AppendChild(tint5); + schemeColor13.AppendChild(shade5); + schemeColor13.AppendChild(saturationModulation9); + + gradientStop8.AppendChild(schemeColor13); + + var gradientStop9 = new GradientStop { Position = 100000 }; + + var schemeColor14 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var shade6 = new Shade { Val = 20000 }; + var saturationModulation10 = new SaturationModulation { Val = 255000 }; + + schemeColor14.AppendChild(shade6); + schemeColor14.AppendChild(saturationModulation10); + + gradientStop9.AppendChild(schemeColor14); + + gradientStopList3.AppendChild(gradientStop7); + gradientStopList3.AppendChild(gradientStop8); + gradientStopList3.AppendChild(gradientStop9); + + var pathGradientFill1 = new PathGradientFill { Path = PathShadeValues.Circle }; + var fillToRectangle1 = new FillToRectangle { Left = 50000, Top = -80000, Right = 50000, Bottom = 180000 }; + + pathGradientFill1.AppendChild(fillToRectangle1); + + gradientFill3.AppendChild(gradientStopList3); + gradientFill3.AppendChild(pathGradientFill1); + + var gradientFill4 = new GradientFill { RotateWithShape = true }; + + var gradientStopList4 = new GradientStopList(); + + var gradientStop10 = new GradientStop { Position = 0 }; + + var schemeColor15 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var tint6 = new Tint { Val = 80000 }; + var saturationModulation11 = new SaturationModulation { Val = 300000 }; + + schemeColor15.AppendChild(tint6); + schemeColor15.AppendChild(saturationModulation11); + + gradientStop10.AppendChild(schemeColor15); + + var gradientStop11 = new GradientStop { Position = 100000 }; + + var schemeColor16 = new SchemeColor { Val = SchemeColorValues.PhColor }; + var shade7 = new Shade { Val = 30000 }; + var saturationModulation12 = new SaturationModulation { Val = 200000 }; + + schemeColor16.AppendChild(shade7); + schemeColor16.AppendChild(saturationModulation12); + + gradientStop11.AppendChild(schemeColor16); + + gradientStopList4.AppendChild(gradientStop10); + gradientStopList4.AppendChild(gradientStop11); + + var pathGradientFill2 = new PathGradientFill { Path = PathShadeValues.Circle }; + var fillToRectangle2 = new FillToRectangle { Left = 50000, Top = 50000, Right = 50000, Bottom = 50000 }; + + pathGradientFill2.AppendChild(fillToRectangle2); + + gradientFill4.AppendChild(gradientStopList4); + gradientFill4.AppendChild(pathGradientFill2); + + backgroundFillStyleList1.AppendChild(solidFill5); + backgroundFillStyleList1.AppendChild(gradientFill3); + backgroundFillStyleList1.AppendChild(gradientFill4); + + formatScheme1.AppendChild(fillStyleList1); + formatScheme1.AppendChild(lineStyleList1); + formatScheme1.AppendChild(effectStyleList1); + formatScheme1.AppendChild(backgroundFillStyleList1); + + themeElements1.AppendChild(colorScheme1); + themeElements1.AppendChild(fontScheme2); + themeElements1.AppendChild(formatScheme1); + var objectDefaults1 = new ObjectDefaults(); + var extraColorSchemeList1 = new ExtraColorSchemeList(); + + theme1.AppendChild(themeElements1); + theme1.AppendChild(objectDefaults1); + theme1.AppendChild(extraColorSchemeList1); + + themePart.Theme = theme1; + } + + } +} diff --git a/ClosedXML/Excel/IO/VmlDrawingPartWriter.cs b/ClosedXML/Excel/IO/VmlDrawingPartWriter.cs new file mode 100644 index 000000000..d5122a241 --- /dev/null +++ b/ClosedXML/Excel/IO/VmlDrawingPartWriter.cs @@ -0,0 +1,253 @@ +#nullable disable + +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Vml.Office; +using DocumentFormat.OpenXml.Vml.Spreadsheet; +using DocumentFormat.OpenXml; +using Anchor = DocumentFormat.OpenXml.Vml.Spreadsheet.Anchor; +using Locked = DocumentFormat.OpenXml.Vml.Spreadsheet.Locked; +using Vml = DocumentFormat.OpenXml.Vml; +using System; +using System.IO; +using System.Text; +using System.Xml; + +namespace ClosedXML.Excel.IO +{ + internal class VmlDrawingPartWriter + { + // Generates content of vmlDrawingPart1. + internal static bool GenerateContent(VmlDrawingPart vmlDrawingPart, XLWorksheet xlWorksheet) + { + using (var ms = new MemoryStream()) + using (var stream = vmlDrawingPart.GetStream(FileMode.OpenOrCreate)) + { + XLWorkbook.CopyStream(stream, ms); + stream.Position = 0; + var writer = new XmlTextWriter(stream, Encoding.UTF8); + + writer.WriteStartElement("xml"); + + // https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.vml.shapetype?view=openxml-2.8.1#remarks + // This element defines a shape template that can be used to create other shapes. + // Shapetype is identical to the shape element(§14.1.2.19) except it cannot reference another shapetype element. + // The type attribute shall not be used with shapetype. + // Attributes defined in the shape override any that appear in the shapetype positioning attributes + // (such as top, width, z-index, rotation, flip) are not passed to a shape from a shapetype. + // To use this element, create a shapetype with a specific id attribute. + // Then create a shape and reference the shapetype's id using the type attribute. + new Vml.Shapetype( + new Vml.Stroke { JoinStyle = Vml.StrokeJoinStyleValues.Miter }, + new Vml.Path { AllowGradientShape = true, ConnectionPointType = ConnectValues.Rectangle } + ) + { + Id = XLConstants.Comment.ShapeTypeId, + CoordinateSize = "21600,21600", + OptionalNumber = 202, + EdgePath = "m,l,21600r21600,l21600,xe", + } + .WriteTo(writer); + + var cellWithComments = xlWorksheet.Internals.CellsCollection.GetCells(c => c.HasComment); + + var hasAnyVmlElements = false; + + foreach (var c in cellWithComments) + { + GenerateCommentShape(c).WriteTo(writer); + hasAnyVmlElements |= true; + } + + if (ms.Length > 0) + { + ms.Position = 0; + var xdoc = XDocumentExtensions.Load(ms); + xdoc.Root.Elements().ForEach(e => writer.WriteRaw(e.ToString())); + hasAnyVmlElements |= xdoc.Root.HasElements; + } + + writer.WriteEndElement(); + writer.Flush(); + writer.Close(); + + return hasAnyVmlElements; + } + } + + // VML Shape for Comment + private static Vml.Shape GenerateCommentShape(XLCell c) + { + var rowNumber = c.Address.RowNumber; + var columnNumber = c.Address.ColumnNumber; + + var comment = c.GetComment(); + var shapeId = String.Concat("_x0000_s", comment.ShapeId); + // Unique per cell (workbook?), e.g.: "_x0000_s1026" + var anchor = GetAnchor(c); + var textBox = GetTextBox(comment.Style); + var fill = new Vml.Fill { Color2 = "#" + comment.Style.ColorsAndLines.FillColor.Color.ToHex().Substring(2) }; + if (comment.Style.ColorsAndLines.FillTransparency < 1) + fill.Opacity = + Math.Round(Convert.ToDouble(comment.Style.ColorsAndLines.FillTransparency), 2).ToInvariantString(); + var stroke = GetStroke(c); + var shape = new Vml.Shape( + fill, + stroke, + new Vml.Shadow { Color = "black", Obscured = true }, + new Vml.Path { ConnectionPointType = ConnectValues.None }, + textBox, + new ClientData( + new MoveWithCells(comment.Style.Properties.Positioning == XLDrawingAnchor.Absolute + ? "True" + : "False"), // Counterintuitive + new ResizeWithCells(comment.Style.Properties.Positioning == XLDrawingAnchor.MoveAndSizeWithCells + ? "False" + : "True"), // Counterintuitive + anchor, + new HorizontalTextAlignment(comment.Style.Alignment.Horizontal.ToString().ToCamel()), + new Vml.Spreadsheet.VerticalTextAlignment(comment.Style.Alignment.Vertical.ToString().ToCamel()), + new AutoFill("False"), + new CommentRowTarget { Text = (rowNumber - 1).ToInvariantString() }, + new CommentColumnTarget { Text = (columnNumber - 1).ToInvariantString() }, + new Locked(comment.Style.Protection.Locked ? "True" : "False"), + new LockText(comment.Style.Protection.LockText ? "True" : "False"), + new Visible(comment.Visible ? "True" : "False") + ) + { ObjectType = ObjectValues.Note } + ) + { + Id = shapeId, + Type = "#" + XLConstants.Comment.ShapeTypeId, + Style = GetCommentStyle(c), + FillColor = "#" + comment.Style.ColorsAndLines.FillColor.Color.ToHex().Substring(2), + StrokeColor = "#" + comment.Style.ColorsAndLines.LineColor.Color.ToHex().Substring(2), + StrokeWeight = String.Concat(comment.Style.ColorsAndLines.LineWeight.ToInvariantString(), "pt"), + InsetMode = comment.Style.Margins.Automatic ? InsetMarginValues.Auto : InsetMarginValues.Custom + }; + if (!String.IsNullOrWhiteSpace(comment.Style.Web.AlternateText)) + shape.Alternate = comment.Style.Web.AlternateText; + + return shape; + } + + private static Vml.Stroke GetStroke(XLCell c) + { + var lineDash = c.GetComment().Style.ColorsAndLines.LineDash; + var stroke = new Vml.Stroke + { + LineStyle = c.GetComment().Style.ColorsAndLines.LineStyle.ToOpenXml(), + DashStyle = + lineDash == XLDashStyle.RoundDot || lineDash == XLDashStyle.SquareDot + ? "shortDot" + : lineDash.ToString().ToCamel() + }; + if (lineDash == XLDashStyle.RoundDot) + stroke.EndCap = Vml.StrokeEndCapValues.Round; + if (c.GetComment().Style.ColorsAndLines.LineTransparency < 1) + stroke.Opacity = + Math.Round(Convert.ToDouble(c.GetComment().Style.ColorsAndLines.LineTransparency), 2).ToInvariantString(); + return stroke; + } + + private static Vml.TextBox GetTextBox(IXLDrawingStyle ds) + { + var sb = new StringBuilder(); + var a = ds.Alignment; + + if (a.Direction == XLDrawingTextDirection.Context) + sb.Append("mso-direction-alt:auto;"); + else if (a.Direction == XLDrawingTextDirection.RightToLeft) + sb.Append("direction:RTL;"); + + if (a.Orientation != XLDrawingTextOrientation.LeftToRight) + { + sb.Append("layout-flow:vertical;"); + if (a.Orientation == XLDrawingTextOrientation.BottomToTop) + sb.Append("mso-layout-flow-alt:bottom-to-top;"); + else if (a.Orientation == XLDrawingTextOrientation.Vertical) + sb.Append("mso-layout-flow-alt:top-to-bottom;"); + } + if (a.AutomaticSize) + sb.Append("mso-fit-shape-to-text:t;"); + + var tb = new Vml.TextBox(); + + if (sb.Length > 0) + tb.Style = sb.ToString(); + + var dm = ds.Margins; + if (!dm.Automatic) + tb.Inset = String.Concat( + dm.Left.ToInvariantString(), "in,", + dm.Top.ToInvariantString(), "in,", + dm.Right.ToInvariantString(), "in,", + dm.Bottom.ToInvariantString(), "in"); + + return tb; + } + + private static Anchor GetAnchor(XLCell cell) + { + var c = cell.GetComment(); + var cWidth = c.Style.Size.Width; + var fcNumber = c.Position.Column - 1; + var fcOffset = Convert.ToInt32(c.Position.ColumnOffset * 7.5); + var widthFromColumns = cell.Worksheet.Column(c.Position.Column).Width - c.Position.ColumnOffset; + var lastCell = cell.CellRight(c.Position.Column - cell.Address.ColumnNumber); + while (widthFromColumns <= cWidth) + { + lastCell = lastCell.CellRight(); + widthFromColumns += lastCell.WorksheetColumn().Width; + } + + var lcNumber = lastCell.WorksheetColumn().ColumnNumber() - 1; + var lcOffset = Convert.ToInt32((lastCell.WorksheetColumn().Width - (widthFromColumns - cWidth)) * 7.5); + + var cHeight = c.Style.Size.Height; //c.Style.Size.Height * 72.0; + var frNumber = c.Position.Row - 1; + var frOffset = Convert.ToInt32(c.Position.RowOffset); + var heightFromRows = cell.Worksheet.Row(c.Position.Row).Height - c.Position.RowOffset; + lastCell = cell.CellBelow(c.Position.Row - cell.Address.RowNumber); + while (heightFromRows <= cHeight) + { + lastCell = lastCell.CellBelow(); + heightFromRows += lastCell.WorksheetRow().Height; + } + + var lrNumber = lastCell.WorksheetRow().RowNumber() - 1; + var lrOffset = Convert.ToInt32(lastCell.WorksheetRow().Height - (heightFromRows - cHeight)); + return new Anchor + { + Text = string.Concat( + fcNumber, ", ", fcOffset, ", ", + frNumber, ", ", frOffset, ", ", + lcNumber, ", ", lcOffset, ", ", + lrNumber, ", ", lrOffset + ) + }; + } + + private static StringValue GetCommentStyle(XLCell cell) + { + var c = cell.GetComment(); + var sb = new StringBuilder("position:absolute; "); + + sb.Append("visibility:"); + sb.Append(c.Visible ? "visible" : "hidden"); + sb.Append(";"); + + sb.Append("width:"); + sb.Append(Math.Round(c.Style.Size.Width * 7.5, 2).ToInvariantString()); + sb.Append("pt;"); + sb.Append("height:"); + sb.Append(Math.Round(c.Style.Size.Height, 2).ToInvariantString()); + sb.Append("pt;"); + + sb.Append("z-index:"); + sb.Append(c.ZOrder.ToInvariantString()); + + return sb.ToString(); + } + + } +} diff --git a/ClosedXML/Excel/IO/WorkbookPartWriter.cs b/ClosedXML/Excel/IO/WorkbookPartWriter.cs new file mode 100644 index 000000000..f3a3414ab --- /dev/null +++ b/ClosedXML/Excel/IO/WorkbookPartWriter.cs @@ -0,0 +1,362 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using ClosedXML.Utils; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; + +namespace ClosedXML.Excel.IO +{ + internal class WorkbookPartWriter + { + internal static void GenerateContent(WorkbookPart workbookPart, XLWorkbook xlWorkbook, SaveOptions options, XLWorkbook.SaveContext context) + { + if (workbookPart.Workbook == null) + workbookPart.Workbook = new Workbook(); + + var workbook = workbookPart.Workbook; + if ( + !workbook.NamespaceDeclarations.Contains(new KeyValuePair("r", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"))) + { + workbook.AddNamespaceDeclaration("r", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); + } + + #region WorkbookProperties + + if (workbook.WorkbookProperties == null) + workbook.WorkbookProperties = new WorkbookProperties(); + + if (workbook.WorkbookProperties.CodeName == null) + workbook.WorkbookProperties.CodeName = "ThisWorkbook"; + + workbook.WorkbookProperties.Date1904 = OpenXmlHelper.GetBooleanValue(xlWorkbook.Use1904DateSystem, false); + + if (options.FilterPrivacy.HasValue) + workbook.WorkbookProperties.FilterPrivacy = OpenXmlHelper.GetBooleanValue(options.FilterPrivacy.Value, false); + + #endregion WorkbookProperties + + #region FileSharing + + if (workbook.FileSharing == null) + workbook.FileSharing = new FileSharing(); + + workbook.FileSharing.ReadOnlyRecommended = OpenXmlHelper.GetBooleanValue(xlWorkbook.FileSharing.ReadOnlyRecommended, false); + workbook.FileSharing.UserName = String.IsNullOrWhiteSpace(xlWorkbook.FileSharing.UserName) ? null : StringValue.FromString(xlWorkbook.FileSharing.UserName); + + if (!workbook.FileSharing.HasChildren && !workbook.FileSharing.HasAttributes) + workbook.FileSharing = null; + + #endregion FileSharing + + #region WorkbookProtection + + if (xlWorkbook.Protection.IsProtected) + { + if (workbook.WorkbookProtection == null) + workbook.WorkbookProtection = new WorkbookProtection(); + + var workbookProtection = workbook.WorkbookProtection; + + var protection = xlWorkbook.Protection; + + workbookProtection.WorkbookPassword = null; + workbookProtection.WorkbookAlgorithmName = null; + workbookProtection.WorkbookHashValue = null; + workbookProtection.WorkbookSpinCount = null; + workbookProtection.WorkbookSaltValue = null; + + if (protection.Algorithm == XLProtectionAlgorithm.Algorithm.SimpleHash) + { + if (!String.IsNullOrWhiteSpace(protection.PasswordHash)) + workbookProtection.WorkbookPassword = protection.PasswordHash; + } + else + { + workbookProtection.WorkbookAlgorithmName = DescribedEnumParser.ToDescription(protection.Algorithm); + workbookProtection.WorkbookHashValue = protection.PasswordHash; + workbookProtection.WorkbookSpinCount = protection.SpinCount; + workbookProtection.WorkbookSaltValue = protection.Base64EncodedSalt; + } + + workbookProtection.LockStructure = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLWorkbookProtectionElements.Structure), false); + workbookProtection.LockWindows = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLWorkbookProtectionElements.Windows), false); + } + else + { + workbook.WorkbookProtection = null; + } + + #endregion WorkbookProtection + + if (workbook.BookViews == null) + workbook.BookViews = new BookViews(); + + if (workbook.Sheets == null) + workbook.Sheets = new Sheets(); + + var worksheets = xlWorkbook.WorksheetsInternal; + workbook.Sheets.Elements().Where(s => worksheets.Deleted.Contains(s.Id)).ToList().ForEach( + s => s.Remove()); + + foreach (var sheet in workbook.Sheets.Elements()) + { + var sheetId = (Int32)sheet.SheetId.Value; + + if (xlWorkbook.WorksheetsInternal.All(w => w.SheetId != sheetId)) continue; + + var wks = xlWorkbook.WorksheetsInternal.Single(w => w.SheetId == sheetId); + wks.RelId = sheet.Id; + sheet.Name = wks.Name; + } + + foreach (var xlSheet in xlWorkbook.WorksheetsInternal.OrderBy(w => w.Position)) + { + string rId; + if (String.IsNullOrWhiteSpace(xlSheet.RelId)) + { + // Sheet isn't from loaded file and hasn't been saved yet. + rId = xlSheet.RelId = context.RelIdGenerator.GetNext(XLWorkbook.RelType.Workbook); + } + else + { + // Keep same r:id from previous file + rId = xlSheet.RelId; + } + + if (workbook.Sheets.Cast().All(s => s.Id != rId)) + { + var newSheet = new Sheet + { + Name = xlSheet.Name, + Id = rId, + SheetId = xlSheet.SheetId + }; + + workbook.Sheets.AppendChild(newSheet); + } + } + + var sheetElements = from sheet in workbook.Sheets.Elements() + join worksheet in ((IEnumerable)xlWorkbook.WorksheetsInternal) on sheet.Id.Value + equals worksheet.RelId + orderby worksheet.Position + select sheet; + + UInt32 firstSheetVisible = 0; + var activeTab = + (from us in xlWorkbook.UnsupportedSheets where us.IsActive select (UInt32)us.Position - 1).FirstOrDefault(); + var foundVisible = false; + + var totalSheets = sheetElements.Count() + xlWorkbook.UnsupportedSheets.Count; + for (var p = 1; p <= totalSheets; p++) + { + if (xlWorkbook.UnsupportedSheets.All(us => us.Position != p)) + { + var sheet = sheetElements.ElementAt(p - xlWorkbook.UnsupportedSheets.Count(us => us.Position <= p) - 1); + workbook.Sheets.RemoveChild(sheet); + workbook.Sheets.AppendChild(sheet); + var xlSheet = xlWorkbook.Worksheet(sheet.Name); + if (xlSheet.Visibility != XLWorksheetVisibility.Visible) + sheet.State = xlSheet.Visibility.ToOpenXml(); + else + sheet.State = null; + + if (foundVisible) continue; + + if (sheet.State == null || sheet.State == SheetStateValues.Visible) + foundVisible = true; + else + firstSheetVisible++; + } + else + { + var sheetId = xlWorkbook.UnsupportedSheets.First(us => us.Position == p).SheetId; + var sheet = workbook.Sheets.Elements().First(s => s.SheetId == sheetId); + workbook.Sheets.RemoveChild(sheet); + workbook.Sheets.AppendChild(sheet); + } + } + + var workbookView = workbook.BookViews.Elements().FirstOrDefault(); + + if (activeTab == 0) + { + UInt32? firstActiveTab = null; + UInt32? firstSelectedTab = null; + foreach (var ws in worksheets) + { + if (ws.TabActive) + { + firstActiveTab = (UInt32)(ws.Position - 1); + break; + } + + if (ws.TabSelected) + { + firstSelectedTab = (UInt32)(ws.Position - 1); + } + } + + activeTab = firstActiveTab + ?? firstSelectedTab + ?? firstSheetVisible; + } + + if (workbookView == null) + { + workbookView = new WorkbookView { ActiveTab = activeTab, FirstSheet = firstSheetVisible }; + workbook.BookViews.AppendChild(workbookView); + } + else + { + workbookView.ActiveTab = activeTab; + workbookView.FirstSheet = firstSheetVisible; + } + + var definedNames = new DefinedNames(); + foreach (var worksheet in xlWorkbook.WorksheetsInternal) + { + var wsSheetId = worksheet.SheetId; + UInt32 sheetId = 0; + foreach (var s in workbook.Sheets.Elements().TakeWhile(s => s.SheetId != wsSheetId)) + { + sheetId++; + } + + if (worksheet.PageSetup.PrintAreas.Any()) + { + var definedName = new DefinedName { Name = "_xlnm.Print_Area", LocalSheetId = sheetId }; + var worksheetName = worksheet.Name; + var definedNameText = worksheet.PageSetup.PrintAreas.Aggregate(String.Empty, + (current, printArea) => + current + + (worksheetName.EscapeSheetName() + "!" + + printArea.RangeAddress. + FirstAddress.ToStringFixed( + XLReferenceStyle.A1) + + ":" + + printArea.RangeAddress. + LastAddress.ToStringFixed( + XLReferenceStyle.A1) + + ",")); + definedName.Text = definedNameText.Substring(0, definedNameText.Length - 1); + definedNames.AppendChild(definedName); + } + + if (worksheet.AutoFilter.IsEnabled) + { + var definedName = new DefinedName + { + Name = "_xlnm._FilterDatabase", + LocalSheetId = sheetId, + Text = worksheet.Name.EscapeSheetName() + "!" + + worksheet.AutoFilter.Range.RangeAddress.FirstAddress.ToStringFixed( + XLReferenceStyle.A1) + + ":" + + worksheet.AutoFilter.Range.RangeAddress.LastAddress.ToStringFixed( + XLReferenceStyle.A1), + Hidden = BooleanValue.FromBoolean(true) + }; + definedNames.AppendChild(definedName); + } + + foreach (var xlDefinedName in worksheet.DefinedNames.Where(n => n.Name != "_xlnm._FilterDatabase")) + { + var definedName = new DefinedName + { + Name = xlDefinedName.Name, + LocalSheetId = sheetId, + Text = xlDefinedName.ToString() + }; + + if (!xlDefinedName.Visible) + definedName.Hidden = BooleanValue.FromBoolean(true); + + if (!String.IsNullOrWhiteSpace(xlDefinedName.Comment)) + definedName.Comment = xlDefinedName.Comment; + definedNames.AppendChild(definedName); + } + + var definedNameTextRow = String.Empty; + var definedNameTextColumn = String.Empty; + if (worksheet.PageSetup.FirstRowToRepeatAtTop > 0) + { + definedNameTextRow = worksheet.Name.EscapeSheetName() + "!" + worksheet.PageSetup.FirstRowToRepeatAtTop + + ":" + worksheet.PageSetup.LastRowToRepeatAtTop; + } + if (worksheet.PageSetup.FirstColumnToRepeatAtLeft > 0) + { + var minColumn = worksheet.PageSetup.FirstColumnToRepeatAtLeft; + var maxColumn = worksheet.PageSetup.LastColumnToRepeatAtLeft; + definedNameTextColumn = worksheet.Name.EscapeSheetName() + "!" + + XLHelper.GetColumnLetterFromNumber(minColumn) + + ":" + XLHelper.GetColumnLetterFromNumber(maxColumn); + } + + string titles; + if (definedNameTextColumn.Length > 0) + { + titles = definedNameTextColumn; + if (definedNameTextRow.Length > 0) + titles += "," + definedNameTextRow; + } + else + titles = definedNameTextRow; + + if (titles.Length <= 0) continue; + + var definedName2 = new DefinedName + { + Name = "_xlnm.Print_Titles", + LocalSheetId = sheetId, + Text = titles + }; + + definedNames.AppendChild(definedName2); + } + + foreach (var xlDefinedName in xlWorkbook.DefinedNamesInternal) + { + var definedName = new DefinedName + { + Name = xlDefinedName.Name, + Text = xlDefinedName.RefersTo + }; + + if (!xlDefinedName.Visible) + definedName.Hidden = BooleanValue.FromBoolean(true); + + if (!String.IsNullOrWhiteSpace(xlDefinedName.Comment)) + definedName.Comment = xlDefinedName.Comment; + definedNames.AppendChild(definedName); + } + + workbook.DefinedNames = definedNames; + + if (workbook.CalculationProperties == null) + workbook.CalculationProperties = new CalculationProperties { CalculationId = 125725U }; + + if (xlWorkbook.CalculateMode == XLCalculateMode.Default) + workbook.CalculationProperties.CalculationMode = null; + else + workbook.CalculationProperties.CalculationMode = xlWorkbook.CalculateMode.ToOpenXml(); + + if (xlWorkbook.ReferenceStyle == XLReferenceStyle.Default) + workbook.CalculationProperties.ReferenceMode = null; + else + workbook.CalculationProperties.ReferenceMode = xlWorkbook.ReferenceStyle.ToOpenXml(); + + if (xlWorkbook.CalculationOnSave) workbook.CalculationProperties.CalculationOnSave = xlWorkbook.CalculationOnSave; + if (xlWorkbook.ForceFullCalculation) workbook.CalculationProperties.ForceFullCalculation = xlWorkbook.ForceFullCalculation; + if (xlWorkbook.FullCalculationOnLoad) workbook.CalculationProperties.FullCalculationOnLoad = xlWorkbook.FullCalculationOnLoad; + if (xlWorkbook.FullPrecision) workbook.CalculationProperties.FullPrecision = xlWorkbook.FullPrecision; + } + + } +} diff --git a/ClosedXML/Excel/IO/WorkbookStylesPartWriter.cs b/ClosedXML/Excel/IO/WorkbookStylesPartWriter.cs new file mode 100644 index 000000000..6f1bf24d4 --- /dev/null +++ b/ClosedXML/Excel/IO/WorkbookStylesPartWriter.cs @@ -0,0 +1,931 @@ +#nullable disable + +using ClosedXML.Utils; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using DocumentFormat.OpenXml; +using System; +using System.Collections.Generic; +using System.Linq; +using static ClosedXML.Excel.XLWorkbook; + +namespace ClosedXML.Excel.IO +{ + internal class WorkbookStylesPartWriter + { + internal static void GenerateContent(WorkbookStylesPart workbookStylesPart, XLWorkbook workbook, SaveContext context) + { + var defaultStyle = DefaultStyleValue; + + if (!context.SharedFonts.ContainsKey(defaultStyle.Font)) + context.SharedFonts.Add(defaultStyle.Font, new FontInfo { FontId = 0, Font = defaultStyle.Font }); + + if (workbookStylesPart.Stylesheet == null) + workbookStylesPart.Stylesheet = new Stylesheet(); + + // Cell styles = Named styles + if (workbookStylesPart.Stylesheet.CellStyles == null) + workbookStylesPart.Stylesheet.CellStyles = new CellStyles(); + + // To determine the default workbook style, we look for the style with builtInId = 0 (I hope that is the correct approach) + UInt32 defaultFormatId; + if (workbookStylesPart.Stylesheet.CellStyles.Elements().Any(c => c.BuiltinId != null && c.BuiltinId.HasValue && c.BuiltinId.Value == 0)) + { + // Possible to have duplicate default cell styles - occurs when file gets saved under different cultures. + // We prefer the style that is named Normal + var normalCellStyles = workbookStylesPart.Stylesheet.CellStyles.Elements() + .Where(c => c.BuiltinId != null && c.BuiltinId.HasValue && c.BuiltinId.Value == 0) + .OrderBy(c => c.Name != null && c.Name.HasValue && c.Name.Value == "Normal"); + + defaultFormatId = normalCellStyles.Last().FormatId.Value; + } + else if (workbookStylesPart.Stylesheet.CellStyles.Elements().Any()) + defaultFormatId = workbookStylesPart.Stylesheet.CellStyles.Elements().Max(c => c.FormatId.Value) + 1; + else + defaultFormatId = 0; + + context.SharedStyles.Add(defaultStyle, + new StyleInfo + { + StyleId = defaultFormatId, + Style = defaultStyle, + FontId = 0, + FillId = 0, + BorderId = 0, + IncludeQuotePrefix = false, + NumberFormatId = 0 + //AlignmentId = 0 + }); + + UInt32 styleCount = 1; + UInt32 fontCount = 1; + UInt32 fillCount = 3; + UInt32 borderCount = 1; + var xlPivotTablesCustomFormats = new HashSet(); + var xlStyles = new HashSet(); + + foreach (var worksheet in workbook.WorksheetsInternal) + { + xlStyles.Add(worksheet.StyleValue); + foreach (var s in worksheet.Internals.ColumnsCollection.Select(c => c.Value.StyleValue)) + { + xlStyles.Add(s); + } + foreach (var s in worksheet.Internals.RowsCollection.Select(r => r.Value.StyleValue)) + { + xlStyles.Add(s); + } + + foreach (var c in worksheet.Internals.CellsCollection.GetCells()) + { + xlStyles.Add(c.StyleValue); + } + + var xlPivotTableCustomFormats = worksheet.PivotTables + .SelectMany(pt => pt.DataFields) + .Where(x => x.NumberFormatValue is not null && !string.IsNullOrEmpty(x.NumberFormatValue.Format)) + .Select(x => x.NumberFormatValue.Format); + xlPivotTablesCustomFormats.UnionWith(xlPivotTableCustomFormats); + } + + var alignments = xlStyles.Select(s => s.Alignment).Distinct().ToList(); + var borders = xlStyles.Select(s => s.Border).Distinct().ToList(); + var fonts = xlStyles.Select(s => s.Font).Distinct().ToList(); + var fills = xlStyles.Select(s => s.Fill).Distinct().ToList(); + var numberFormats = xlStyles.Select(s => s.NumberFormat).Distinct().ToList(); + var protections = xlStyles.Select(s => s.Protection).Distinct().ToList(); + + for (int i = 0; i < fonts.Count; i++) + { + if (!context.SharedFonts.ContainsKey(fonts[i])) + { + context.SharedFonts.Add(fonts[i], new FontInfo { FontId = (uint)fontCount++, Font = fonts[i] }); + } + } + + var sharedFills = fills.ToDictionary( + f => f, f => new FillInfo { FillId = fillCount++, Fill = f }); + + var sharedBorders = borders.ToDictionary( + b => b, b => new BorderInfo { BorderId = borderCount++, Border = b }); + + var customNumberFormats = numberFormats + .Where(nf => nf.NumberFormatId == -1) + .ToHashSet(); + + foreach (var pivotNumberFormat in xlPivotTablesCustomFormats) + { + var numberFormatKey = XLNumberFormatKey.ForFormat(pivotNumberFormat); + var numberFormat = XLNumberFormatValue.FromKey(ref numberFormatKey); + + customNumberFormats.Add(numberFormat); + } + + var allSharedNumberFormats = ResolveNumberFormats(workbookStylesPart, customNumberFormats, defaultFormatId); + foreach (var nf in allSharedNumberFormats) + { + context.SharedNumberFormats.Add(nf.Key, nf.Value); + } + + ResolveFonts(workbookStylesPart, context); + var allSharedFills = ResolveFills(workbookStylesPart, sharedFills); + var allSharedBorders = ResolveBorders(workbookStylesPart, sharedBorders); + + foreach (var xlStyle in xlStyles) + { + var numberFormatId = xlStyle.NumberFormat.NumberFormatId >= 0 + ? xlStyle.NumberFormat.NumberFormatId + : allSharedNumberFormats[xlStyle.NumberFormat].NumberFormatId; + + if (!context.SharedStyles.ContainsKey(xlStyle)) + context.SharedStyles.Add(xlStyle, + new StyleInfo + { + StyleId = styleCount++, + Style = xlStyle, + FontId = context.SharedFonts[xlStyle.Font].FontId, + FillId = allSharedFills[xlStyle.Fill].FillId, + BorderId = allSharedBorders[xlStyle.Border].BorderId, + NumberFormatId = numberFormatId, + IncludeQuotePrefix = xlStyle.IncludeQuotePrefix + }); + } + + ResolveCellStyleFormats(workbookStylesPart, context); + ResolveRest(workbookStylesPart, context); + + if (!workbookStylesPart.Stylesheet.CellStyles.Elements().Any(c => c.BuiltinId != null && c.BuiltinId.HasValue && c.BuiltinId.Value == 0U)) + workbookStylesPart.Stylesheet.CellStyles.AppendChild(new CellStyle { Name = "Normal", FormatId = defaultFormatId, BuiltinId = 0U }); + + workbookStylesPart.Stylesheet.CellStyles.Count = (UInt32)workbookStylesPart.Stylesheet.CellStyles.Count(); + + var newSharedStyles = new Dictionary(); + foreach (var ss in context.SharedStyles) + { + var styleId = -1; + foreach (CellFormat f in workbookStylesPart.Stylesheet.CellFormats) + { + styleId++; + if (CellFormatsAreEqual(f, ss.Value, compareAlignment: true)) + break; + } + if (styleId == -1) + styleId = 0; + var si = ss.Value; + si.StyleId = (UInt32)styleId; + newSharedStyles.Add(ss.Key, si); + } + context.SharedStyles.Clear(); + newSharedStyles.ForEach(kp => context.SharedStyles.Add(kp.Key, kp.Value)); + + AddDifferentialFormats(workbookStylesPart, workbook, context); + } + + /// + /// Populates the differential formats that are currently in the file to the SaveContext + /// + private static void AddDifferentialFormats(WorkbookStylesPart workbookStylesPart, XLWorkbook workbook, SaveContext context) + { + if (workbookStylesPart.Stylesheet.DifferentialFormats == null) + workbookStylesPart.Stylesheet.DifferentialFormats = new DifferentialFormats(); + + var differentialFormats = workbookStylesPart.Stylesheet.DifferentialFormats; + differentialFormats.RemoveAllChildren(); + FillDifferentialFormatsCollection(differentialFormats, context.DifferentialFormats); + + foreach (var ws in workbook.WorksheetsInternal) + { + foreach (var cf in ws.ConditionalFormats) + { + var styleValue = (cf.Style as XLStyle).Value; + if (!styleValue.Equals(DefaultStyleValue) && !context.DifferentialFormats.ContainsKey(styleValue)) + AddConditionalDifferentialFormat(workbookStylesPart.Stylesheet.DifferentialFormats, cf, context); + } + + foreach (var tf in ws.Tables.SelectMany(t => t.Fields)) + { + if (tf.IsConsistentStyle()) + { + var style = (tf.Column.Cells() + .Skip(tf.Table.ShowHeaderRow ? 1 : 0) + .First() + .Style as XLStyle).Value; + + if (!style.Equals(DefaultStyleValue) && !context.DifferentialFormats.ContainsKey(style)) + AddStyleAsDifferentialFormat(workbookStylesPart.Stylesheet.DifferentialFormats, style, context); + } + } + + foreach (var pt in ws.PivotTables) + { + // Add Dxf from old pivot table structures. + foreach (var styleFormat in pt.AllStyleFormats) + { + var xlStyle = (XLStyle)styleFormat.Style; + if (!xlStyle.Value.Equals(DefaultStyleValue) && !context.DifferentialFormats.ContainsKey(xlStyle.Value)) + AddStyleAsDifferentialFormat(workbookStylesPart.Stylesheet.DifferentialFormats, xlStyle.Value, context); + } + + // Add Dxf from new pivot table structures. + foreach (var xlPivotFormat in pt.Formats) + { + var xlStyleValue = xlPivotFormat.DxfStyleValue; + if (xlStyleValue != XLStyleValue.Default && + !context.DifferentialFormats.ContainsKey(xlStyleValue)) + { + AddStyleAsDifferentialFormat(workbookStylesPart.Stylesheet.DifferentialFormats, xlStyleValue, context); + } + } + + foreach (var xlConditionalStyle in pt.ConditionalFormats) + { + var xlStyle = ((XLStyle)xlConditionalStyle.Format.Style); + if (xlStyle.Value != XLStyleValue.Default && + !context.DifferentialFormats.ContainsKey(xlStyle.Value)) + { + AddStyleAsDifferentialFormat(workbookStylesPart.Stylesheet.DifferentialFormats, xlStyle.Value, context); + } + } + } + } + + differentialFormats.Count = (UInt32)differentialFormats.Count(); + if (differentialFormats.Count == 0) + workbookStylesPart.Stylesheet.DifferentialFormats = null; + } + + private static void FillDifferentialFormatsCollection(DifferentialFormats differentialFormats, + Dictionary dictionary) + { + dictionary.Clear(); + var id = 0; + + foreach (var df in differentialFormats.Elements()) + { + var emptyContainer = new XLStylizedEmpty(DefaultStyle); + + var style = new XLStyle(emptyContainer, DefaultStyle); + OpenXmlHelper.LoadFont(df.Font, emptyContainer.Style.Font); + OpenXmlHelper.LoadBorder(df.Border, emptyContainer.Style.Border); + OpenXmlHelper.LoadNumberFormat(df.NumberingFormat, emptyContainer.Style.NumberFormat); + OpenXmlHelper.LoadFill(df.Fill, emptyContainer.Style.Fill, differentialFillFormat: true); + + if (!dictionary.ContainsKey(emptyContainer.StyleValue)) + dictionary.Add(emptyContainer.StyleValue, id++); + } + } + + private static void AddConditionalDifferentialFormat(DifferentialFormats differentialFormats, IXLConditionalFormat cf, + SaveContext context) + { + var differentialFormat = new DifferentialFormat(); + var styleValue = (cf.Style as XLStyle).Value; + + var diffFont = GetNewFont(new FontInfo { Font = styleValue.Font }, false); + if (diffFont?.HasChildren ?? false) + differentialFormat.Append(diffFont); + + if (!String.IsNullOrWhiteSpace(cf.Style.NumberFormat.Format)) + { + var numberFormat = new NumberingFormat + { + NumberFormatId = (UInt32)(XLConstants.NumberOfBuiltInStyles + differentialFormats.Count()), + FormatCode = cf.Style.NumberFormat.Format + }; + differentialFormat.Append(numberFormat); + } + + var diffFill = GetNewFill(new FillInfo { Fill = styleValue.Fill }, differentialFillFormat: true, ignoreMod: false); + if (diffFill?.HasChildren ?? false) + differentialFormat.Append(diffFill); + + var diffBorder = GetNewBorder(new BorderInfo { Border = styleValue.Border }, false); + if (diffBorder?.HasChildren ?? false) + differentialFormat.Append(diffBorder); + + differentialFormats.Append(differentialFormat); + + context.DifferentialFormats.Add(styleValue, differentialFormats.Count() - 1); + } + + private static void AddStyleAsDifferentialFormat(DifferentialFormats differentialFormats, XLStyleValue style, + SaveContext context) + { + var differentialFormat = new DifferentialFormat(); + + var diffFont = GetNewFont(new FontInfo { Font = style.Font }, false); + if (diffFont?.HasChildren ?? false) + differentialFormat.Append(diffFont); + + if (!String.IsNullOrWhiteSpace(style.NumberFormat.Format) || style.NumberFormat.NumberFormatId != 0) + { + var numberFormat = new NumberingFormat(); + + if (style.NumberFormat.NumberFormatId == -1) + { + numberFormat.FormatCode = style.NumberFormat.Format; + numberFormat.NumberFormatId = (UInt32)(XLConstants.NumberOfBuiltInStyles + + differentialFormats + .Descendants() + .Count(df => df.NumberingFormat != null && df.NumberingFormat.NumberFormatId != null && df.NumberingFormat.NumberFormatId.Value >= XLConstants.NumberOfBuiltInStyles)); + } + else + { + numberFormat.NumberFormatId = (UInt32)(style.NumberFormat.NumberFormatId); + if (!string.IsNullOrEmpty(style.NumberFormat.Format)) + numberFormat.FormatCode = style.NumberFormat.Format; + else if (XLPredefinedFormat.FormatCodes.TryGetValue(style.NumberFormat.NumberFormatId, out string formatCode)) + numberFormat.FormatCode = formatCode; + } + + differentialFormat.Append(numberFormat); + } + + var diffFill = GetNewFill(new FillInfo { Fill = style.Fill }, differentialFillFormat: true, ignoreMod: false); + if (diffFill?.HasChildren ?? false) + differentialFormat.Append(diffFill); + + var diffBorder = GetNewBorder(new BorderInfo { Border = style.Border }, false); + if (diffBorder?.HasChildren ?? false) + differentialFormat.Append(diffBorder); + + differentialFormats.Append(differentialFormat); + + context.DifferentialFormats.Add(style, differentialFormats.Count() - 1); + } + + private static void ResolveRest(WorkbookStylesPart workbookStylesPart, SaveContext context) + { + if (workbookStylesPart.Stylesheet.CellFormats == null) + workbookStylesPart.Stylesheet.CellFormats = new CellFormats(); + + foreach (var styleInfo in context.SharedStyles.Values) + { + var info = styleInfo; + var foundOne = + workbookStylesPart.Stylesheet.CellFormats.Cast().Any(f => CellFormatsAreEqual(f, info, compareAlignment: true)); + + if (foundOne) continue; + + var cellFormat = GetCellFormat(styleInfo); + cellFormat.FormatId = 0; + var alignment = new Alignment + { + Horizontal = styleInfo.Style.Alignment.Horizontal.ToOpenXml(), + Vertical = styleInfo.Style.Alignment.Vertical.ToOpenXml(), + Indent = (UInt32)styleInfo.Style.Alignment.Indent, + ReadingOrder = (UInt32)styleInfo.Style.Alignment.ReadingOrder, + WrapText = styleInfo.Style.Alignment.WrapText, + TextRotation = (UInt32)GetOpenXmlTextRotation(styleInfo.Style.Alignment), + ShrinkToFit = styleInfo.Style.Alignment.ShrinkToFit, + RelativeIndent = styleInfo.Style.Alignment.RelativeIndent, + JustifyLastLine = styleInfo.Style.Alignment.JustifyLastLine + }; + cellFormat.AppendChild(alignment); + + if (cellFormat.ApplyProtection.Value) + cellFormat.AppendChild(GetProtection(styleInfo)); + + workbookStylesPart.Stylesheet.CellFormats.AppendChild(cellFormat); + } + workbookStylesPart.Stylesheet.CellFormats.Count = (UInt32)workbookStylesPart.Stylesheet.CellFormats.Count(); + + static int GetOpenXmlTextRotation(XLAlignmentValue alignment) + { + var textRotation = alignment.TextRotation; + return textRotation >= 0 + ? textRotation + : 90 - textRotation; + } + } + + private static void ResolveCellStyleFormats(WorkbookStylesPart workbookStylesPart, + SaveContext context) + { + if (workbookStylesPart.Stylesheet.CellStyleFormats == null) + workbookStylesPart.Stylesheet.CellStyleFormats = new CellStyleFormats(); + + foreach (var styleInfo in context.SharedStyles.Values) + { + var info = styleInfo; + var foundOne = + workbookStylesPart.Stylesheet.CellStyleFormats.Cast().Any( + f => CellFormatsAreEqual(f, info, compareAlignment: false)); + + if (foundOne) continue; + + var cellStyleFormat = GetCellFormat(styleInfo); + + if (cellStyleFormat.ApplyProtection.Value) + cellStyleFormat.AppendChild(GetProtection(styleInfo)); + + workbookStylesPart.Stylesheet.CellStyleFormats.AppendChild(cellStyleFormat); + } + workbookStylesPart.Stylesheet.CellStyleFormats.Count = + (UInt32)workbookStylesPart.Stylesheet.CellStyleFormats.Count(); + } + + private static bool ApplyFill(StyleInfo styleInfo) + { + return styleInfo.Style.Fill.PatternType.ToOpenXml() == PatternValues.None; + } + + private static bool ApplyBorder(StyleInfo styleInfo) + { + var opBorder = styleInfo.Style.Border; + return (opBorder.BottomBorder.ToOpenXml() != BorderStyleValues.None + || opBorder.DiagonalBorder.ToOpenXml() != BorderStyleValues.None + || opBorder.RightBorder.ToOpenXml() != BorderStyleValues.None + || opBorder.LeftBorder.ToOpenXml() != BorderStyleValues.None + || opBorder.TopBorder.ToOpenXml() != BorderStyleValues.None); + } + + private static bool ApplyProtection(StyleInfo styleInfo) + { + return styleInfo.Style.Protection != null; + } + + private static CellFormat GetCellFormat(StyleInfo styleInfo) + { + var cellFormat = new CellFormat + { + NumberFormatId = (UInt32)styleInfo.NumberFormatId, + FontId = styleInfo.FontId, + FillId = styleInfo.FillId, + BorderId = styleInfo.BorderId, + QuotePrefix = OpenXmlHelper.GetBooleanValue(styleInfo.IncludeQuotePrefix, false), + ApplyNumberFormat = true, + ApplyAlignment = true, + ApplyFill = ApplyFill(styleInfo), + ApplyBorder = ApplyBorder(styleInfo), + ApplyProtection = ApplyProtection(styleInfo) + }; + return cellFormat; + } + + private static Protection GetProtection(StyleInfo styleInfo) + { + return new Protection + { + Locked = styleInfo.Style.Protection.Locked, + Hidden = styleInfo.Style.Protection.Hidden + }; + } + + /// + /// Check if two style are equivalent. + /// + /// Style in the OpenXML format. + /// Style in the ClosedXML format. + /// Flag specifying whether or not compare the alignments of two styles. + /// Styles in x:cellStyleXfs section do not include alignment so we don't have to compare it in this case. + /// Styles in x:cellXfs section, on the opposite, do include alignments, and we must compare them. + /// + /// True if two formats are equivalent, false otherwise. + private static bool CellFormatsAreEqual(CellFormat f, StyleInfo styleInfo, bool compareAlignment) + { + return + f.BorderId != null && styleInfo.BorderId == f.BorderId + && f.FillId != null && styleInfo.FillId == f.FillId + && f.FontId != null && styleInfo.FontId == f.FontId + && f.NumberFormatId != null && styleInfo.NumberFormatId == f.NumberFormatId + && QuotePrefixesAreEqual(f.QuotePrefix, styleInfo.IncludeQuotePrefix) + && (f.ApplyFill == null && styleInfo.Style.Fill == XLFillValue.Default || + f.ApplyFill != null && f.ApplyFill == ApplyFill(styleInfo)) + && (f.ApplyBorder == null && styleInfo.Style.Border == XLBorderValue.Default || + f.ApplyBorder != null && f.ApplyBorder == ApplyBorder(styleInfo)) + && (!compareAlignment || AlignmentsAreEqual(f.Alignment, styleInfo.Style.Alignment)) + && ProtectionsAreEqual(f.Protection, styleInfo.Style.Protection) + ; + } + + private static bool ProtectionsAreEqual(Protection protection, XLProtectionValue xlProtection) + { + var p = XLProtectionValue.Default.Key; + if (protection is not null) + p = OpenXmlHelper.ProtectionToClosedXml(protection, p); + + return p.Equals(xlProtection.Key); + } + + private static bool QuotePrefixesAreEqual(BooleanValue quotePrefix, Boolean includeQuotePrefix) + { + return OpenXmlHelper.GetBooleanValueAsBool(quotePrefix, false) == includeQuotePrefix; + } + + private static bool AlignmentsAreEqual(Alignment alignment, XLAlignmentValue xlAlignment) + { + if (alignment is not null) + { + var a = OpenXmlHelper.AlignmentToClosedXml(alignment, XLAlignmentValue.Default.Key); + return a.Equals(xlAlignment.Key); + } + else + { + return XLStyle.Default.Value.Alignment.Equals(xlAlignment); + } + } + + private static Dictionary ResolveBorders(WorkbookStylesPart workbookStylesPart, + Dictionary sharedBorders) + { + if (workbookStylesPart.Stylesheet.Borders == null) + workbookStylesPart.Stylesheet.Borders = new Borders(); + + var allSharedBorders = new Dictionary(); + foreach (var borderInfo in sharedBorders.Values) + { + var borderId = 0; + var foundOne = false; + foreach (Border f in workbookStylesPart.Stylesheet.Borders) + { + if (BordersAreEqual(f, borderInfo.Border)) + { + foundOne = true; + break; + } + borderId++; + } + if (!foundOne) + { + var border = GetNewBorder(borderInfo); + workbookStylesPart.Stylesheet.Borders.AppendChild(border); + } + allSharedBorders.Add(borderInfo.Border, + new BorderInfo { Border = borderInfo.Border, BorderId = (UInt32)borderId }); + } + workbookStylesPart.Stylesheet.Borders.Count = (UInt32)workbookStylesPart.Stylesheet.Borders.Count(); + return allSharedBorders; + } + + private static Border GetNewBorder(BorderInfo borderInfo, Boolean ignoreMod = true) + { + var border = new Border(); + if (borderInfo.Border.DiagonalUp != XLBorderValue.Default.DiagonalUp || ignoreMod) + border.DiagonalUp = borderInfo.Border.DiagonalUp; + + if (borderInfo.Border.DiagonalDown != XLBorderValue.Default.DiagonalDown || ignoreMod) + border.DiagonalDown = borderInfo.Border.DiagonalDown; + + if (borderInfo.Border.LeftBorder != XLBorderValue.Default.LeftBorder || ignoreMod) + { + var leftBorder = new LeftBorder { Style = borderInfo.Border.LeftBorder.ToOpenXml() }; + if (borderInfo.Border.LeftBorderColor != XLBorderValue.Default.LeftBorderColor || ignoreMod) + { + var leftBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.LeftBorderColor); + leftBorder.AppendChild(leftBorderColor); + } + border.AppendChild(leftBorder); + } + + if (borderInfo.Border.RightBorder != XLBorderValue.Default.RightBorder || ignoreMod) + { + var rightBorder = new RightBorder { Style = borderInfo.Border.RightBorder.ToOpenXml() }; + if (borderInfo.Border.RightBorderColor != XLBorderValue.Default.RightBorderColor || ignoreMod) + { + var rightBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.RightBorderColor); + rightBorder.AppendChild(rightBorderColor); + } + border.AppendChild(rightBorder); + } + + if (borderInfo.Border.TopBorder != XLBorderValue.Default.TopBorder || ignoreMod) + { + var topBorder = new TopBorder { Style = borderInfo.Border.TopBorder.ToOpenXml() }; + if (borderInfo.Border.TopBorderColor != XLBorderValue.Default.TopBorderColor || ignoreMod) + { + var topBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.TopBorderColor); + topBorder.AppendChild(topBorderColor); + } + border.AppendChild(topBorder); + } + + if (borderInfo.Border.BottomBorder != XLBorderValue.Default.BottomBorder || ignoreMod) + { + var bottomBorder = new BottomBorder { Style = borderInfo.Border.BottomBorder.ToOpenXml() }; + if (borderInfo.Border.BottomBorderColor != XLBorderValue.Default.BottomBorderColor || ignoreMod) + { + var bottomBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.BottomBorderColor); + bottomBorder.AppendChild(bottomBorderColor); + } + border.AppendChild(bottomBorder); + } + + if (borderInfo.Border.DiagonalBorder != XLBorderValue.Default.DiagonalBorder || ignoreMod) + { + var DiagonalBorder = new DiagonalBorder { Style = borderInfo.Border.DiagonalBorder.ToOpenXml() }; + if (borderInfo.Border.DiagonalBorderColor != XLBorderValue.Default.DiagonalBorderColor || ignoreMod) + if (borderInfo.Border.DiagonalBorderColor != null) + { + var DiagonalBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.DiagonalBorderColor); + DiagonalBorder.AppendChild(DiagonalBorderColor); + } + border.AppendChild(DiagonalBorder); + } + + return border; + } + + private static bool BordersAreEqual(Border border, XLBorderValue xlBorder) + { + var convertedBorder = OpenXmlHelper.BorderToClosedXml( + border, + XLBorderValue.Default.Key); + return convertedBorder.Equals(xlBorder.Key); + } + + private static Dictionary ResolveFills(WorkbookStylesPart workbookStylesPart, + Dictionary sharedFills) + { + if (workbookStylesPart.Stylesheet.Fills == null) + workbookStylesPart.Stylesheet.Fills = new Fills(); + + var fills = workbookStylesPart.Stylesheet.Fills; + + // Pattern idx 0 and idx 1 are hardcoded to Excel with values None (0) and Gray125. Excel will ignore + // values from the file. Every file has have these values inside to keep the first available idx at 2. + ResolveFillWithPattern(fills, 0, PatternValues.None); + ResolveFillWithPattern(fills, 1, PatternValues.Gray125); + + var allSharedFills = new Dictionary(); + foreach (var fillInfo in sharedFills.Values) + { + var fillId = 0; + var foundOne = false; + foreach (Fill f in fills) + { + if (FillsAreEqual(f, fillInfo.Fill, fromDifferentialFormat: false)) + { + foundOne = true; + break; + } + fillId++; + } + if (!foundOne) + { + var fill = GetNewFill(fillInfo, differentialFillFormat: false); + fills.AppendChild(fill); + } + allSharedFills.Add(fillInfo.Fill, new FillInfo { Fill = fillInfo.Fill, FillId = (UInt32)fillId }); + } + + fills.Count = (UInt32)fills.Count(); + return allSharedFills; + } + + private static void ResolveFillWithPattern(Fills fills, Int32 index, PatternValues patternValues) + { + var fill = (Fill)fills.ElementAtOrDefault(index); + if (fill is null) + { + fills.InsertAt(new Fill { PatternFill = new PatternFill { PatternType = patternValues } }, index); + return; + } + + var fillHasExpectedValue = + fill.PatternFill?.PatternType?.Value == patternValues && + fill.PatternFill.ForegroundColor is null && + fill.PatternFill.BackgroundColor is null; + + if (fillHasExpectedValue) + return; + + fill.PatternFill = new PatternFill { PatternType = patternValues }; + } + + private static Fill GetNewFill(FillInfo fillInfo, Boolean differentialFillFormat, Boolean ignoreMod = true) + { + var fill = new Fill(); + + var patternFill = new PatternFill(); + + patternFill.PatternType = fillInfo.Fill.PatternType.ToOpenXml(); + + BackgroundColor backgroundColor; + ForegroundColor foregroundColor; + + switch (fillInfo.Fill.PatternType) + { + case XLFillPatternValues.None: + break; + + case XLFillPatternValues.Solid: + + if (differentialFillFormat) + { + patternFill.AppendChild(new ForegroundColor { Auto = true }); + backgroundColor = new BackgroundColor().FromClosedXMLColor(fillInfo.Fill.BackgroundColor, true); + if (backgroundColor.HasAttributes) + patternFill.AppendChild(backgroundColor); + } + else + { + // ClosedXML Background color to be populated into OpenXML fgColor + foregroundColor = new ForegroundColor().FromClosedXMLColor(fillInfo.Fill.BackgroundColor); + if (foregroundColor.HasAttributes) + patternFill.AppendChild(foregroundColor); + } + break; + + default: + + foregroundColor = new ForegroundColor().FromClosedXMLColor(fillInfo.Fill.PatternColor); + if (foregroundColor.HasAttributes) + patternFill.AppendChild(foregroundColor); + + backgroundColor = new BackgroundColor().FromClosedXMLColor(fillInfo.Fill.BackgroundColor); + if (backgroundColor.HasAttributes) + patternFill.AppendChild(backgroundColor); + + break; + } + + if (patternFill.HasChildren) + fill.AppendChild(patternFill); + + return fill; + } + + private static bool FillsAreEqual(Fill f, XLFillValue xlFill, Boolean fromDifferentialFormat) + { + var nF = new XLFill(null); + + OpenXmlHelper.LoadFill(f, nF, fromDifferentialFormat); + + return nF.Key.Equals(xlFill.Key); + } + + private static void ResolveFonts(WorkbookStylesPart workbookStylesPart, SaveContext context) + { + if (workbookStylesPart.Stylesheet.Fonts == null) + workbookStylesPart.Stylesheet.Fonts = new Fonts(); + + var newFonts = new Dictionary(); + foreach (var fontInfo in context.SharedFonts.Values) + { + var fontId = 0; + var foundOne = false; + foreach (Font f in workbookStylesPart.Stylesheet.Fonts) + { + if (FontsAreEqual(f, fontInfo.Font)) + { + foundOne = true; + break; + } + fontId++; + } + if (!foundOne) + { + var font = GetNewFont(fontInfo); + workbookStylesPart.Stylesheet.Fonts.AppendChild(font); + } + newFonts.Add(fontInfo.Font, new FontInfo { Font = fontInfo.Font, FontId = (UInt32)fontId }); + } + context.SharedFonts.Clear(); + foreach (var kp in newFonts) + context.SharedFonts.Add(kp.Key, kp.Value); + + workbookStylesPart.Stylesheet.Fonts.Count = (UInt32)workbookStylesPart.Stylesheet.Fonts.Count(); + } + + private static Font GetNewFont(FontInfo fontInfo, Boolean ignoreMod = true) + { + var font = new Font(); + var bold = (fontInfo.Font.Bold != XLFontValue.Default.Bold || ignoreMod) && fontInfo.Font.Bold ? new Bold() : null; + var italic = (fontInfo.Font.Italic != XLFontValue.Default.Italic || ignoreMod) && fontInfo.Font.Italic ? new Italic() : null; + var underline = (fontInfo.Font.Underline != XLFontValue.Default.Underline || ignoreMod) && + fontInfo.Font.Underline != XLFontUnderlineValues.None + ? new Underline { Val = fontInfo.Font.Underline.ToOpenXml() } + : null; + var strike = (fontInfo.Font.Strikethrough != XLFontValue.Default.Strikethrough || ignoreMod) && fontInfo.Font.Strikethrough + ? new Strike() + : null; + var verticalAlignment = fontInfo.Font.VerticalAlignment != XLFontValue.Default.VerticalAlignment || ignoreMod + ? new VerticalTextAlignment { Val = fontInfo.Font.VerticalAlignment.ToOpenXml() } + : null; + + var shadow = (fontInfo.Font.Shadow != XLFontValue.Default.Shadow || ignoreMod) && fontInfo.Font.Shadow ? new Shadow() : null; + var fontSize = fontInfo.Font.FontSize != XLFontValue.Default.FontSize || ignoreMod + ? new FontSize { Val = fontInfo.Font.FontSize } + : null; + var color = fontInfo.Font.FontColor != XLFontValue.Default.FontColor || ignoreMod ? new Color().FromClosedXMLColor(fontInfo.Font.FontColor) : null; + + var fontName = fontInfo.Font.FontName != XLFontValue.Default.FontName || ignoreMod + ? new FontName { Val = fontInfo.Font.FontName } + : null; + var fontFamilyNumbering = fontInfo.Font.FontFamilyNumbering != XLFontValue.Default.FontFamilyNumbering || ignoreMod + ? new FontFamilyNumbering { Val = (Int32)fontInfo.Font.FontFamilyNumbering } + : null; + + var fontCharSet = (fontInfo.Font.FontCharSet != XLFontValue.Default.FontCharSet || ignoreMod) && fontInfo.Font.FontCharSet != XLFontCharSet.Default + ? new FontCharSet { Val = (Int32)fontInfo.Font.FontCharSet } + : null; + + var fontScheme = (fontInfo.Font.FontScheme != XLFontValue.Default.FontScheme || ignoreMod) && fontInfo.Font.FontScheme != XLFontScheme.None + ? new DocumentFormat.OpenXml.Spreadsheet.FontScheme { Val = fontInfo.Font.FontScheme.ToOpenXmlEnum() } + : null; + + if (bold != null) + font.AppendChild(bold); + if (italic != null) + font.AppendChild(italic); + if (underline != null) + font.AppendChild(underline); + if (strike != null) + font.AppendChild(strike); + if (verticalAlignment != null) + font.AppendChild(verticalAlignment); + if (shadow != null) + font.AppendChild(shadow); + if (fontSize != null) + font.AppendChild(fontSize); + if (color != null) + font.AppendChild(color); + if (fontName != null) + font.AppendChild(fontName); + if (fontFamilyNumbering != null) + font.AppendChild(fontFamilyNumbering); + if (fontCharSet != null) + font.AppendChild(fontCharSet); + if (fontScheme != null) + font.AppendChild(fontScheme); + + return font; + } + + private static bool FontsAreEqual(Font font, XLFontValue xlFont) + { + var convertedFont = OpenXmlHelper.FontToClosedXml( + font, + XLFontValue.Default.Key); + return convertedFont.Equals(xlFont.Key); + } + + private static Dictionary ResolveNumberFormats( + WorkbookStylesPart workbookStylesPart, + HashSet customNumberFormats, + UInt32 defaultFormatId) + { + if (workbookStylesPart.Stylesheet.NumberingFormats == null) + { + workbookStylesPart.Stylesheet.NumberingFormats = new NumberingFormats(); + workbookStylesPart.Stylesheet.NumberingFormats.AppendChild(new NumberingFormat() + { + NumberFormatId = 0, + FormatCode = "" + }); + } + + var allSharedNumberFormats = new Dictionary(); + var partNumberingFormats = workbookStylesPart.Stylesheet.NumberingFormats; + + // number format ids in the part can have holes in the sequence and first id can be greater than last built-in style id. + // In some cases, there are also existing number formats with id below last built-in style id. + var availableNumberFormatId = partNumberingFormats.Any() + ? Math.Max(partNumberingFormats.Cast().Max(nf => nf.NumberFormatId!.Value) + 1, XLConstants.NumberOfBuiltInStyles) + : XLConstants.NumberOfBuiltInStyles; // 0-based + + // Merge custom formats used in the workbook that are not already present in the part to the part and assign ids + foreach (var customNumberFormat in customNumberFormats.Where(nf => nf.NumberFormatId != defaultFormatId)) + { + NumberingFormat partNumberFormat = null; + foreach (var nf in workbookStylesPart.Stylesheet.NumberingFormats.Cast()) + { + if (CustomNumberFormatsAreEqual(nf, customNumberFormat)) + { + partNumberFormat = nf; + break; + } + } + if (partNumberFormat is null) + { + partNumberFormat = new NumberingFormat + { + NumberFormatId = (UInt32)availableNumberFormatId++, + FormatCode = customNumberFormat.Format + }; + workbookStylesPart.Stylesheet.NumberingFormats.AppendChild(partNumberFormat); + } + allSharedNumberFormats.Add(customNumberFormat, + new NumberFormatInfo + { + NumberFormat = customNumberFormat, + NumberFormatId = (Int32)partNumberFormat.NumberFormatId!.Value + }); + } + workbookStylesPart.Stylesheet.NumberingFormats.Count = + (UInt32)workbookStylesPart.Stylesheet.NumberingFormats.Count(); + return allSharedNumberFormats; + } + + private static bool CustomNumberFormatsAreEqual(NumberingFormat nf, XLNumberFormatValue xlNumberFormat) + { + if (nf.FormatCode != null && !String.IsNullOrWhiteSpace(nf.FormatCode.Value)) + return string.Equals(xlNumberFormat?.Format, nf.FormatCode.Value); + + return false; + } + + + } +} diff --git a/ClosedXML/Excel/IO/WorksheetPartReader.cs b/ClosedXML/Excel/IO/WorksheetPartReader.cs new file mode 100644 index 000000000..cf5597bcf --- /dev/null +++ b/ClosedXML/Excel/IO/WorksheetPartReader.cs @@ -0,0 +1,1257 @@ +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using DocumentFormat.OpenXml; +using System.Collections.Generic; +using System; +using System.Globalization; +using System.Linq; +using ClosedXML.Utils; +using ClosedXML.Extensions; +using ClosedXML.IO; +using X14 = DocumentFormat.OpenXml.Office2010.Excel; +using System.Diagnostics; +using ClosedXML.Parser; +using static ClosedXML.Excel.XLPredefinedFormat.DateTime; + +namespace ClosedXML.Excel.IO; + +#nullable disable + +internal class WorksheetPartReader +{ + private static readonly string[] DateCellFormats = + { + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff", // Format accepted by OpenXML SDK + "yyyy-MM-ddTHH:mm", "yyyy-MM-dd" // Formats accepted by Excel. + }; + + private readonly Dictionary _sharedFormulasR1C1 = new(); + private Int32 _lastRow; + private Int32 _lastColumnNumber; + + internal void LoadWorksheet(XLWorksheet ws, Stylesheet s, Fills fills, Borders borders, NumberingFormats numberingFormats, WorksheetPart worksheetPart, SharedStringItem[] sharedStrings, Dictionary differentialFormats, LoadContext context) + { + ApplyStyle(ws, 0, s, fills, borders, numberingFormats, ws.Workbook.Styles); + + var styleList = new Dictionary();// {{0, ws.Style}}; + PageSetupProperties pageSetupProperties = null; + + _lastRow = 0; + + using (var reader = new OpenXmlPartReader(worksheetPart)) + { + Type[] ignoredElements = new Type[] + { + typeof(CustomSheetViews) // Custom sheet views contain its own auto filter data, and more, which should be ignored for now + }; + + while (reader.Read()) + { + while (ignoredElements.Contains(reader.ElementType)) + reader.ReadNextSibling(); + + if (reader.ElementType == typeof(SheetFormatProperties)) + { + var sheetFormatProperties = (SheetFormatProperties)reader.LoadCurrentElement(); + if (sheetFormatProperties != null) + { + if (sheetFormatProperties.DefaultRowHeight != null) + ws.RowHeight = sheetFormatProperties.DefaultRowHeight; + + ws.RowHeightChanged = (sheetFormatProperties.CustomHeight != null && + sheetFormatProperties.CustomHeight.Value); + + if (sheetFormatProperties.DefaultColumnWidth != null) + ws.ColumnWidth = XLHelper.ConvertWidthToNoC(sheetFormatProperties.DefaultColumnWidth.Value, ws.Style.Font, ws.Workbook); + else if (sheetFormatProperties.BaseColumnWidth != null) + ws.ColumnWidth = XLHelper.CalculateColumnWidth(sheetFormatProperties.BaseColumnWidth.Value, ws.Style.Font, ws.Workbook); + } + } + else if (reader.ElementType == typeof(SheetViews)) + LoadSheetViews((SheetViews)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(MergeCells)) + { + var mergedCells = (MergeCells)reader.LoadCurrentElement(); + if (mergedCells != null) + { + foreach (MergeCell mergeCell in mergedCells.Elements()) + ws.Range(mergeCell.Reference).Merge(false); + } + } + else if (reader.ElementType == typeof(Columns)) + LoadColumns(s, numberingFormats, fills, borders, ws, + (Columns)reader.LoadCurrentElement()); + else if (reader.ElementType == typeof(Row)) + { + LoadRow(s, numberingFormats, fills, borders, ws, sharedStrings, styleList, reader); + } + else if (reader.ElementType == typeof(AutoFilter)) + AutoFilterReader.LoadAutoFilter((AutoFilter)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(SheetProtection)) + LoadSheetProtection((SheetProtection)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(DataValidations)) + LoadDataValidations((DataValidations)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(ConditionalFormatting)) + LoadConditionalFormatting((ConditionalFormatting)reader.LoadCurrentElement(), ws, differentialFormats, context); + else if (reader.ElementType == typeof(Hyperlinks)) + LoadHyperlinks((Hyperlinks)reader.LoadCurrentElement(), worksheetPart, ws); + else if (reader.ElementType == typeof(PrintOptions)) + LoadPrintOptions((PrintOptions)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(PageMargins)) + LoadPageMargins((PageMargins)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(PageSetup)) + LoadPageSetup((PageSetup)reader.LoadCurrentElement(), ws, pageSetupProperties); + else if (reader.ElementType == typeof(HeaderFooter)) + LoadHeaderFooter((HeaderFooter)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(SheetProperties)) + LoadSheetProperties((SheetProperties)reader.LoadCurrentElement(), ws, out pageSetupProperties); + else if (reader.ElementType == typeof(RowBreaks)) + LoadRowBreaks((RowBreaks)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(ColumnBreaks)) + LoadColumnBreaks((ColumnBreaks)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(WorksheetExtensionList)) + LoadExtensions((WorksheetExtensionList)reader.LoadCurrentElement(), ws); + else if (reader.ElementType == typeof(LegacyDrawing)) + ws.LegacyDrawingId = (reader.LoadCurrentElement() as LegacyDrawing).Id.Value; + } + reader.Close(); + } + } + + private static void LoadSheetProperties(SheetProperties sheetProperty, XLWorksheet ws, out PageSetupProperties pageSetupProperties) + { + pageSetupProperties = null; + if (sheetProperty == null) return; + + if (sheetProperty.TabColor != null) + ws.TabColor = sheetProperty.TabColor.ToClosedXMLColor(); + + if (sheetProperty.OutlineProperties != null) + { + if (sheetProperty.OutlineProperties.SummaryBelow != null) + { + ws.Outline.SummaryVLocation = sheetProperty.OutlineProperties.SummaryBelow + ? XLOutlineSummaryVLocation.Bottom + : XLOutlineSummaryVLocation.Top; + } + + if (sheetProperty.OutlineProperties.SummaryRight != null) + { + ws.Outline.SummaryHLocation = sheetProperty.OutlineProperties.SummaryRight + ? XLOutlineSummaryHLocation.Right + : XLOutlineSummaryHLocation.Left; + } + } + + if (sheetProperty.PageSetupProperties != null) + pageSetupProperties = sheetProperty.PageSetupProperties; + } + + private static void LoadColumns(Stylesheet s, NumberingFormats numberingFormats, Fills fills, Borders borders, + XLWorksheet ws, Columns columns) + { + if (columns == null) return; + + var wsDefaultColumn = + columns.Elements().FirstOrDefault(c => c.Max == XLHelper.MaxColumnNumber); + + if (wsDefaultColumn != null && wsDefaultColumn.Width != null) + ws.ColumnWidth = wsDefaultColumn.Width - XLConstants.ColumnWidthOffset; + + Int32 styleIndexDefault = wsDefaultColumn != null && wsDefaultColumn.Style != null + ? Int32.Parse(wsDefaultColumn.Style.InnerText) + : -1; + if (styleIndexDefault >= 0) + ApplyStyle(ws, styleIndexDefault, s, fills, borders, numberingFormats, ws.Workbook.Styles); + + foreach (Column col in columns.Elements()) + { + //IXLStylized toApply; + if (col.Max == XLHelper.MaxColumnNumber) continue; + + var xlColumns = (XLColumns)ws.Columns(col.Min, col.Max); + if (col.Width != null) + { + Double width = col.Width - XLConstants.ColumnWidthOffset; + //if (width < 0) width = 0; + xlColumns.Width = width; + } + else + xlColumns.Width = ws.ColumnWidth; + + if (col.Hidden != null && col.Hidden) + xlColumns.Hide(); + + if (col.Collapsed != null && col.Collapsed) + xlColumns.CollapseOnly(); + + if (col.OutlineLevel != null) + { + var outlineLevel = col.OutlineLevel; + xlColumns.ForEach(c => c.OutlineLevel = outlineLevel); + } + + Int32 styleIndex = col.Style != null ? Int32.Parse(col.Style.InnerText) : -1; + if (styleIndex >= 0) + { + ApplyStyle(xlColumns, styleIndex, s, fills, borders, numberingFormats, ws.Workbook.Styles); + } + else + { + xlColumns.Style = ws.Style; + } + } + } + + + private void LoadRow(Stylesheet s, NumberingFormats numberingFormats, Fills fills, Borders borders, + XLWorksheet ws, SharedStringItem[] sharedStrings, + Dictionary styleList, + OpenXmlPartReader reader) + { + Debug.Assert(reader.LocalName == "row"); + + var attributes = reader.Attributes; + var rowIndexAttr = attributes.GetAttribute("r"); + var rowIndex = string.IsNullOrEmpty(rowIndexAttr) ? ++_lastRow : int.Parse(rowIndexAttr); + + var xlRow = ws.Row(rowIndex, false); + + var height = attributes.GetDoubleAttribute("ht"); + if (height is not null) + { + xlRow.Height = height.Value; + } + else + { + xlRow.Loading = true; + xlRow.Height = ws.RowHeight; + xlRow.Loading = false; + } + + var dyDescent = attributes.GetDoubleAttribute("dyDescent", OpenXmlConst.X14Ac2009SsNs); + if (dyDescent is not null) + xlRow.DyDescent = dyDescent.Value; + + var hidden = attributes.GetBoolAttribute("hidden", false); + if (hidden) + xlRow.Hide(); + + var collapsed = attributes.GetBoolAttribute("collapsed", false); + if (collapsed) + xlRow.Collapsed = true; + + var outlineLevel = attributes.GetIntAttribute("outlineLevel"); + if (outlineLevel is not null && outlineLevel.Value > 0) + xlRow.OutlineLevel = outlineLevel.Value; + + var showPhonetic = attributes.GetBoolAttribute("ph", false); + if (showPhonetic) + xlRow.ShowPhonetic = true; + + var customFormat = attributes.GetBoolAttribute("customFormat", false); + if (customFormat) + { + var styleIndex = attributes.GetIntAttribute("s"); + if (styleIndex is not null) + { + ApplyStyle(xlRow, styleIndex.Value, s, fills, borders, numberingFormats, ws.Workbook.Styles); + } + else + { + xlRow.Style = ws.Style; + } + } + + _lastColumnNumber = 0; + + // Move from the start element of 'row' forward. We can get cell, extList or end of row. + reader.MoveAhead(); + + while (reader.IsStartElement("c")) + { + LoadCell(sharedStrings, s, numberingFormats, fills, borders, ws, styleList, + reader, rowIndex); + + // Move from end element of 'cell' either to next cell, extList start or end of row. + reader.MoveAhead(); + } + + // In theory, row can also contain extList, just skip them. + while (reader.IsStartElement("extLst")) + reader.Skip(); + } + + private void LoadCell(SharedStringItem[] sharedStrings, Stylesheet s, NumberingFormats numberingFormats, + Fills fills, Borders borders, + XLWorksheet ws, Dictionary styleList, OpenXmlPartReader reader, Int32 rowIndex) + { + Debug.Assert(reader.LocalName == "c" && reader.IsStartElement); + + var attributes = reader.Attributes; + + var styleIndex = attributes.GetIntAttribute("s") ?? 0; + + var cellAddress = attributes.GetCellRefAttribute("r") ?? new XLSheetPoint(rowIndex, _lastColumnNumber + 1); + _lastColumnNumber = cellAddress.Column; + + var dataType = attributes.GetAttribute("t") switch + { + "b" => CellValues.Boolean, + "n" => CellValues.Number, + "e" => CellValues.Error, + "s" => CellValues.SharedString, + "str" => CellValues.String, + "inlineStr" => CellValues.InlineString, + "d" => CellValues.Date, + null => CellValues.Number, + _ => throw new FormatException($"Unknown cell type.") + }; + + var xlCell = ws.Cell(cellAddress.Row, cellAddress.Column); + + if (styleList.TryGetValue(styleIndex, out IXLStyle style)) + { + xlCell.InnerStyle = style; + } + else + { + ApplyStyle(xlCell, styleIndex, s, fills, borders, numberingFormats, ws.Workbook.Styles); + } + + var showPhonetic = attributes.GetBoolAttribute("ph", false); + if (showPhonetic) + xlCell.ShowPhonetic = true; + + var cellMetaIndex = attributes.GetUintAttribute("cm"); + if (cellMetaIndex is not null) + xlCell.CellMetaIndex = cellMetaIndex.Value; + + var valueMetaIndex = attributes.GetUintAttribute("vm"); + if (valueMetaIndex is not null) + xlCell.ValueMetaIndex = valueMetaIndex.Value; + + // Move from cell start element onwards. + reader.MoveAhead(); + + var cellHasFormula = reader.IsStartElement("f"); + XLCellFormula formula = null; + if (cellHasFormula) + { + formula = SetCellFormula(ws, cellAddress, reader); + + // Move from end of 'f' element. + reader.MoveAhead(); + } + + // Unified code to load value. Value can be empty and only type specified (e.g. when formula doesn't save values) + // String type is only for formulas, while shared string/inline string/date is only for pure cell values. + var cellHasValue = reader.IsStartElement("v"); + if (cellHasValue) + { + SetCellValue(dataType, reader.GetText(), xlCell, sharedStrings); + + // Skips all nodes of the 'v' element (has no child nodes) and moves to the first element after. + reader.Skip(); + } + else + { + // A string cell must contain at least empty string. + if (dataType.Equals(CellValues.SharedString) || dataType.Equals(CellValues.String)) + xlCell.SetOnlyValue(string.Empty); + } + + // If the cell doesn't contain value, we should invalidate it, otherwise rely on the stored value. + // The value is likely more reliable. It should be set when cellFormula.CalculateCell is set or + // when value is missing. Formula can be null in some cases, e.g. slave cells of array formula. + if (formula is not null && !cellHasValue) + { + formula.IsDirty = true; + } + + // Inline text is dealt separately, because it is in a separate element. + var cellHasInlineString = reader.IsStartElement("is"); + if (cellHasInlineString) + { + if (dataType == CellValues.InlineString) + { + xlCell.ShareString = false; + var inlineString = (RstType)reader.LoadCurrentElement(); + if (inlineString is not null) + { + if (inlineString.Text is not null) + xlCell.SetOnlyValue(inlineString.Text.Text.FixNewLines()); + else + SetCellText(xlCell, inlineString); + } + else + { + xlCell.SetOnlyValue(String.Empty); + } + + // Move from end 'is' element to the end of a 'c' element. + reader.MoveAhead(); + } + else + { + // Move to the first node after end of 'is' element, which should be end of cell. + reader.Skip(); + } + } + + if (ws.Workbook.Use1904DateSystem && xlCell.DataType == XLDataType.DateTime) + { + // Internally ClosedXML stores cells as standard 1900-based style + // so if a workbook is in 1904-format, we do that adjustment here and when saving. + xlCell.SetOnlyValue(xlCell.GetDateTime().AddDays(1462)); + } + + if (!styleList.ContainsKey(styleIndex)) + styleList.Add(styleIndex, xlCell.Style); + } + + private XLCellFormula SetCellFormula(XLWorksheet ws, XLSheetPoint cellAddress, OpenXmlPartReader reader) + { + var attributes = reader.Attributes; + var formulaSlice = ws.Internals.CellsCollection.FormulaSlice; + var valueSlice = ws.Internals.CellsCollection.ValueSlice; + + // bx attribute of cell formula is not ever used, per MS-OI29500 2.1.620 + var formulaText = reader.GetText(); + var formulaType = attributes.GetAttribute("t") switch + { + "normal" => CellFormulaValues.Normal, + "array" => CellFormulaValues.Array, + "dataTable" => CellFormulaValues.DataTable, + "shared" => CellFormulaValues.Shared, + null => CellFormulaValues.Normal, + _ => throw new NotSupportedException("Unknown formula type.") + }; + + // Always set shareString flag to `false`, because the text result of + // formula is stored directly in the sheet, not shared string table. + XLCellFormula formula = null; + if (formulaType == CellFormulaValues.Normal) + { + formula = XLCellFormula.NormalA1(formulaText); + formulaSlice.Set(cellAddress, formula); + valueSlice.SetShareString(cellAddress, false); + } + else if (formulaType == CellFormulaValues.Array && attributes.GetRefAttribute("ref") is { } arrayArea) // Child cells of an array may have array type, but not ref, that is reserved for master cell + { + var aca = attributes.GetBoolAttribute("aca", false); + + // Because cells are read from top-to-bottom, from left-to-right, none of child cells have + // a formula yet. Also, Excel doesn't allow change of array data, only through parent formula. + formula = XLCellFormula.Array(formulaText, arrayArea, aca); + formulaSlice.SetArray(arrayArea, formula); + + for (var col = arrayArea.FirstPoint.Column; col <= arrayArea.LastPoint.Column; ++col) + { + for (var row = arrayArea.FirstPoint.Row; row <= arrayArea.LastPoint.Row; ++row) + { + valueSlice.SetShareString(cellAddress, false); + } + } + } + else if (formulaType == CellFormulaValues.Shared && attributes.GetUintAttribute("si") is { } sharedIndex) + { + // Shared formulas are rather limited in use and parsing, even by Excel + // https://stackoverflow.com/questions/54654993. Therefore we accept them, + // but don't output them. Shared formula is created, when user in Excel + // takes a supported formula and drags it to more cells. + if (!_sharedFormulasR1C1.TryGetValue(sharedIndex, out var sharedR1C1Formula)) + { + // Spec: The first formula in a group of shared formulas is saved + // in the f element. This is considered the 'master' formula cell. + formula = XLCellFormula.NormalA1(formulaText); + formulaSlice.Set(cellAddress, formula); + + // The key reason why Excel hates shared formulas is likely relative addressing and the messy situation it creates + var formulaR1C1 = FormulaConverter.ToR1C1(formulaText, cellAddress.Row, cellAddress.Column); + _sharedFormulasR1C1.Add(sharedIndex, formulaR1C1); + } + else + { + // Spec: The formula expression for a cell that is specified to be part of a shared formula + // (and is not the master) shall be ignored, and the master formula shall override. + var sharedFormulaA1 = FormulaConverter.ToA1(sharedR1C1Formula, cellAddress.Row, cellAddress.Column); + formula = XLCellFormula.NormalA1(sharedFormulaA1); + formulaSlice.Set(cellAddress, formula); + } + + valueSlice.SetShareString(cellAddress, false); + } + else if (formulaType == CellFormulaValues.DataTable && attributes.GetRefAttribute("ref") is { } dataTableArea) + { + var is2D = attributes.GetBoolAttribute("dt2D", false); + var input1Deleted = attributes.GetBoolAttribute("del1", false); + var input1 = attributes.GetCellRefAttribute("r1") ?? throw PartStructureException.MissingAttribute("r1"); + if (is2D) + { + // Input 2 is only used for 2D tables + var input2Deleted = attributes.GetBoolAttribute("del2", false); + var input2 = attributes.GetCellRefAttribute("r2") ?? throw PartStructureException.MissingAttribute("r2"); + formula = XLCellFormula.DataTable2D(dataTableArea, input1, input1Deleted, input2, input2Deleted); + formulaSlice.Set(cellAddress, formula); + } + else + { + var isRowDataTable = attributes.GetBoolAttribute("dtr", false); + formula = XLCellFormula.DataTable1D(dataTableArea, input1, input1Deleted, isRowDataTable); + formulaSlice.Set(cellAddress, formula); + } + + valueSlice.SetShareString(cellAddress, false); + } + + // Go from start of 'f' element to the end of 'f' element. + reader.MoveAhead(); + + return formula; + } + + private void SetCellValue(CellValues dataType, string cellValue, XLCell xlCell, SharedStringItem[] sharedStrings) + { + if (dataType == CellValues.Number) + { + // XLCell is by default blank, so no need to set it. + if (cellValue is not null && double.TryParse(cellValue, XLHelper.NumberStyle, XLHelper.ParseCulture, out var number)) + { + var numberDataType = GetNumberDataType(xlCell.StyleValue.NumberFormat); + var cellNumber = numberDataType switch + { + XLDataType.DateTime => XLCellValue.FromSerialDateTime(number), + XLDataType.TimeSpan => XLCellValue.FromSerialTimeSpan(number), + _ => number // Normal number + }; + xlCell.SetOnlyValue(cellNumber); + } + } + else if (dataType == CellValues.SharedString) + { + if (cellValue is not null + && Int32.TryParse(cellValue, XLHelper.NumberStyle, XLHelper.ParseCulture, out Int32 sharedStringId) + && sharedStringId >= 0 && sharedStringId < sharedStrings.Length) + { + var sharedString = sharedStrings[sharedStringId]; + + SetCellText(xlCell, sharedString); + } + else + xlCell.SetOnlyValue(String.Empty); + } + else if (dataType == CellValues.String) // A plain string that is a result of a formula calculation + { + xlCell.SetOnlyValue(cellValue ?? String.Empty); + } + else if (dataType == CellValues.Boolean) + { + if (cellValue is not null) + { + var isTrue = string.Equals(cellValue, "1", StringComparison.Ordinal) || + string.Equals(cellValue, "TRUE", StringComparison.OrdinalIgnoreCase); + xlCell.SetOnlyValue(isTrue); + } + } + else if (dataType == CellValues.Error) + { + if (cellValue is not null && XLErrorParser.TryParseError(cellValue, out var error)) + xlCell.SetOnlyValue(error); + } + else if (dataType == CellValues.Date) + { + // Technically, cell can contain date as ISO8601 string, but not rarely used due + // to inconsistencies between ISO and serial date time representation. + if (cellValue is not null) + { + var date = DateTime.ParseExact(cellValue, DateCellFormats, + XLHelper.ParseCulture, + DateTimeStyles.AllowLeadingWhite | DateTimeStyles.AllowTrailingWhite); + xlCell.SetOnlyValue(date); + } + } + } + + /// + /// Parses the cell value for normal or rich text + /// Input element should either be a shared string or inline string + /// + /// The cell. + /// The element (either a shared string or inline string) + private void SetCellText(XLCell xlCell, RstType element) + { + var runs = element.Elements(); + var hasRuns = false; + foreach (Run run in runs) + { + hasRuns = true; + var runProperties = run.RunProperties; + String text = run.Text.InnerText.FixNewLines(); + + if (runProperties == null) + xlCell.GetRichText().AddText(text, xlCell.Style.Font); + else + { + var rt = xlCell.GetRichText().AddText(text); + var fontScheme = runProperties.Elements().FirstOrDefault(); + if (fontScheme != null && fontScheme.Val is not null) + rt.SetFontScheme(fontScheme.Val.Value.ToClosedXml()); + + OpenXmlHelper.LoadFont(runProperties, rt); + } + } + + if (!hasRuns) + xlCell.SetOnlyValue(XStringConvert.Decode(element.Text?.InnerText) ?? string.Empty); + + // Load phonetic properties + var phoneticProperties = element.Elements(); + var pp = phoneticProperties.FirstOrDefault(); + if (pp != null) + { + if (pp.Alignment != null) + xlCell.GetRichText().Phonetics.Alignment = pp.Alignment.Value.ToClosedXml(); + if (pp.Type != null) + xlCell.GetRichText().Phonetics.Type = pp.Type.Value.ToClosedXml(); + + OpenXmlHelper.LoadFont(pp, xlCell.GetRichText().Phonetics); + } + + // Load phonetic runs + var phoneticRuns = element.Elements(); + foreach (PhoneticRun pr in phoneticRuns) + { + xlCell.GetRichText().Phonetics.Add(pr.Text.InnerText.FixNewLines(), (Int32)pr.BaseTextStartIndex.Value, + (Int32)pr.EndingBaseIndex.Value); + } + } + + private static XLDataType GetNumberDataType(XLNumberFormatValue numberFormat) + { + var numberFormatId = (XLPredefinedFormat.DateTime)numberFormat.NumberFormatId; + var isTimeOnlyFormat = numberFormatId is + Hour12MinutesAmPm or + Hour12MinutesSecondsAmPm or + Hour24Minutes or + Hour24MinutesSeconds or + MinutesSeconds or + Hour12MinutesSeconds or + MinutesSecondsMillis1; + + if (isTimeOnlyFormat) + return XLDataType.TimeSpan; + + var isDateTimeFormat = numberFormatId is + DayMonthYear4WithSlashes or + DayMonthAbbrYear2WithDashes or + DayMonthAbbrWithDash or + MonthDayYear4WithDashesHour24Minutes; + + if (isDateTimeFormat) + return XLDataType.DateTime; + + if (!String.IsNullOrWhiteSpace(numberFormat.Format)) + { + var dataType = GetDataTypeFromFormat(numberFormat.Format); + return dataType ?? XLDataType.Number; + } + + return XLDataType.Number; + } + + private static XLDataType? GetDataTypeFromFormat(String format) + { + int length = format.Length; + String f = format.ToLower(); + for (Int32 i = 0; i < length; i++) + { + Char c = f[i]; + if (c == '"') + i = f.IndexOf('"', i + 1); + else if (c == '[') + { + // #1742 We need to skip locale prefixes in DateTime formats [...] + i = f.IndexOf(']', i + 1); + if (i == -1) + return null; + } + else if (c == '0' || c == '#' || c == '?') + return XLDataType.Number; + else if (c == 'y' || c == 'd') + return XLDataType.DateTime; + else if (c == 'h' || c == 's') + return XLDataType.TimeSpan; + else if (c == 'm') + { + // Excel treats "m" immediately after "hh" or "h" or immediately before "ss" or "s" as minutes, otherwise as a month value + // We can ignore the "hh" or "h" prefixes as these would have been detected by the preceding condition above. + // So we just need to make sure any 'm' is followed immediately by "ss" or "s" (excluding placeholders) to detect a timespan value + for (Int32 j = i + 1; j < length; j++) + { + if (f[j] == 'm') + continue; + else if (f[j] == 's') + return XLDataType.TimeSpan; + else if ((f[j] >= 'a' && f[j] <= 'z') || (f[j] >= '0' && f[j] <= '9')) + return XLDataType.DateTime; + } + return XLDataType.DateTime; + } + } + return null; + } + + private static void LoadSheetViews(SheetViews sheetViews, XLWorksheet ws) + { + if (sheetViews == null) return; + + var sheetView = sheetViews.Elements().FirstOrDefault(); + + if (sheetView == null) return; + + if (sheetView.RightToLeft != null) ws.RightToLeft = sheetView.RightToLeft.Value; + if (sheetView.ShowFormulas != null) ws.ShowFormulas = sheetView.ShowFormulas.Value; + if (sheetView.ShowGridLines != null) ws.ShowGridLines = sheetView.ShowGridLines.Value; + if (sheetView.ShowOutlineSymbols != null) + ws.ShowOutlineSymbols = sheetView.ShowOutlineSymbols.Value; + if (sheetView.ShowRowColHeaders != null) ws.ShowRowColHeaders = sheetView.ShowRowColHeaders.Value; + if (sheetView.ShowRuler != null) ws.ShowRuler = sheetView.ShowRuler.Value; + if (sheetView.ShowWhiteSpace != null) ws.ShowWhiteSpace = sheetView.ShowWhiteSpace.Value; + if (sheetView.ShowZeros != null) ws.ShowZeros = sheetView.ShowZeros.Value; + if (sheetView.TabSelected != null) ws.TabSelected = sheetView.TabSelected.Value; + + var selection = sheetView.Elements().FirstOrDefault(); + if (selection != null) + { + if (selection.SequenceOfReferences != null) + ws.Ranges(selection.SequenceOfReferences.InnerText.Replace(" ", ",")).Select(); + + if (selection.ActiveCell != null) + ws.Cell(selection.ActiveCell).SetActive(); + } + + if (sheetView.ZoomScale != null) + ws.SheetView.ZoomScale = (int)UInt32Value.ToUInt32(sheetView.ZoomScale); + if (sheetView.ZoomScaleNormal != null) + ws.SheetView.ZoomScaleNormal = (int)UInt32Value.ToUInt32(sheetView.ZoomScaleNormal); + if (sheetView.ZoomScalePageLayoutView != null) + ws.SheetView.ZoomScalePageLayoutView = (int)UInt32Value.ToUInt32(sheetView.ZoomScalePageLayoutView); + if (sheetView.ZoomScaleSheetLayoutView != null) + ws.SheetView.ZoomScaleSheetLayoutView = (int)UInt32Value.ToUInt32(sheetView.ZoomScaleSheetLayoutView); + + var pane = sheetView.Elements().FirstOrDefault(); + if (new[] { PaneStateValues.Frozen, PaneStateValues.FrozenSplit }.Contains(pane?.State?.Value ?? PaneStateValues.Split)) + { + if (pane.HorizontalSplit != null) + ws.SheetView.SplitColumn = (Int32)pane.HorizontalSplit.Value; + if (pane.VerticalSplit != null) + ws.SheetView.SplitRow = (Int32)pane.VerticalSplit.Value; + } + + if (XLHelper.IsValidA1Address(sheetView.TopLeftCell)) + ws.SheetView.TopLeftCellAddress = ws.Cell(sheetView.TopLeftCell.Value).Address; + } + + private static void LoadSheetProtection(SheetProtection sp, XLWorksheet ws) + { + if (sp == null) return; + + ws.Protection.IsProtected = OpenXmlHelper.GetBooleanValueAsBool(sp.Sheet, false); + + var algorithmName = sp.AlgorithmName?.Value ?? string.Empty; + if (String.IsNullOrEmpty(algorithmName)) + { + ws.Protection.PasswordHash = sp.Password?.Value ?? string.Empty; + ws.Protection.Base64EncodedSalt = string.Empty; + } + else if (DescribedEnumParser.IsValidDescription(algorithmName)) + { + ws.Protection.Algorithm = DescribedEnumParser.FromDescription(algorithmName); + ws.Protection.PasswordHash = sp.HashValue?.Value ?? string.Empty; + ws.Protection.SpinCount = sp.SpinCount?.Value ?? 0; + ws.Protection.Base64EncodedSalt = sp.SaltValue?.Value ?? string.Empty; + } + + ws.Protection.AllowElement(XLSheetProtectionElements.FormatCells, !OpenXmlHelper.GetBooleanValueAsBool(sp.FormatCells, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.FormatColumns, !OpenXmlHelper.GetBooleanValueAsBool(sp.FormatColumns, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.FormatRows, !OpenXmlHelper.GetBooleanValueAsBool(sp.FormatRows, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.InsertColumns, !OpenXmlHelper.GetBooleanValueAsBool(sp.InsertColumns, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.InsertHyperlinks, !OpenXmlHelper.GetBooleanValueAsBool(sp.InsertHyperlinks, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.InsertRows, !OpenXmlHelper.GetBooleanValueAsBool(sp.InsertRows, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.DeleteColumns, !OpenXmlHelper.GetBooleanValueAsBool(sp.DeleteColumns, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.DeleteRows, !OpenXmlHelper.GetBooleanValueAsBool(sp.DeleteRows, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.AutoFilter, !OpenXmlHelper.GetBooleanValueAsBool(sp.AutoFilter, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.PivotTables, !OpenXmlHelper.GetBooleanValueAsBool(sp.PivotTables, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.Sort, !OpenXmlHelper.GetBooleanValueAsBool(sp.Sort, true)); + ws.Protection.AllowElement(XLSheetProtectionElements.EditScenarios, !OpenXmlHelper.GetBooleanValueAsBool(sp.Scenarios, true)); + + ws.Protection.AllowElement(XLSheetProtectionElements.EditObjects, !OpenXmlHelper.GetBooleanValueAsBool(sp.Objects, false)); + ws.Protection.AllowElement(XLSheetProtectionElements.SelectLockedCells, !OpenXmlHelper.GetBooleanValueAsBool(sp.SelectLockedCells, false)); + ws.Protection.AllowElement(XLSheetProtectionElements.SelectUnlockedCells, !OpenXmlHelper.GetBooleanValueAsBool(sp.SelectUnlockedCells, false)); + } + + /// + /// Loads the conditional formatting. + /// + // https://msdn.microsoft.com/en-us/library/documentformat.openxml.spreadsheet.conditionalformattingrule%28v=office.15%29.aspx?f=255&MSPPError=-2147217396 + private static void LoadConditionalFormatting(ConditionalFormatting conditionalFormatting, XLWorksheet ws, + Dictionary differentialFormats, LoadContext context) + { + if (conditionalFormatting == null) return; + + foreach (var fr in conditionalFormatting.Elements()) + { + var ranges = conditionalFormatting.SequenceOfReferences.Items + .Select(sor => ws.Range(sor.Value)); + var conditionalFormat = new XLConditionalFormat(ranges); + + conditionalFormat.StopIfTrue = OpenXmlHelper.GetBooleanValueAsBool(fr.StopIfTrue, false); + + if (fr.FormatId != null) + { + OpenXmlHelper.LoadFont(differentialFormats[(Int32)fr.FormatId.Value].Font, conditionalFormat.Style.Font); + OpenXmlHelper.LoadFill(differentialFormats[(Int32)fr.FormatId.Value].Fill, conditionalFormat.Style.Fill, + differentialFillFormat: true); + OpenXmlHelper.LoadBorder(differentialFormats[(Int32)fr.FormatId.Value].Border, conditionalFormat.Style.Border); + OpenXmlHelper.LoadNumberFormat(differentialFormats[(Int32)fr.FormatId.Value].NumberingFormat, + conditionalFormat.Style.NumberFormat); + } + + // The conditional formatting type is compulsory. If it doesn't exist, skip the entire rule. + if (fr.Type == null) continue; + conditionalFormat.ConditionalFormatType = fr.Type.Value.ToClosedXml(); + conditionalFormat.Priority = fr.Priority?.Value ?? Int32.MaxValue; + + // Although formulas are directly used only by CellIs and Expression type, other + // format types also write them for evaluation to the workbook, e.g. rule to + // IsBlank writes `LEN(TRIM(A2))=0` or ContainsText writes `NOT(ISERROR(SEARCH("hello",A2)))`. + if (conditionalFormat.ConditionalFormatType == XLConditionalFormatType.CellIs) + { + conditionalFormat.Operator = fr.Operator.Value.ToClosedXml(); + + // The XML schema allows up to three tags, but at most two are used. + // Some producers emit empty tags that should be ignored and extra + // non-empty formulas should also be ignored (Excel behavior). + var nonEmptyFormulas = fr.Elements() + .Where(static f => !String.IsNullOrEmpty(f.Text)) + .Select(f => GetFormula(f.Text)) + .ToList(); + if (conditionalFormat.Operator is XLCFOperator.Between or XLCFOperator.NotBetween) + { + var formulas = nonEmptyFormulas.Take(2).ToList(); + if (formulas.Count != 2) + throw PartStructureException.IncorrectElementsCount(); + + conditionalFormat.Values.Add(formulas[0]); + conditionalFormat.Values.Add(formulas[1]); + } + else + { + // Other XLCFOperators expect one argument. + var operatorArg = nonEmptyFormulas.FirstOrDefault(); + if (operatorArg is null) + throw PartStructureException.IncorrectElementsCount(); + + conditionalFormat.Values.Add(operatorArg); + } + } + else if (conditionalFormat.ConditionalFormatType == XLConditionalFormatType.Expression) + { + var formula = fr.Elements() + .Where(static f => !String.IsNullOrEmpty(f.Text)) + .FirstOrDefault(); + + if (formula is null) + throw PartStructureException.IncorrectElementsCount(); + + conditionalFormat.Values.Add(GetFormula(formula.Text)); + } + + if (!String.IsNullOrWhiteSpace(fr.Text)) + conditionalFormat.Values.Add(GetFormula(fr.Text.Value)); + + if (conditionalFormat.ConditionalFormatType == XLConditionalFormatType.Top10) + { + if (fr.Percent != null) + conditionalFormat.Percent = fr.Percent.Value; + if (fr.Bottom != null) + conditionalFormat.Bottom = fr.Bottom.Value; + if (fr.Rank != null) + conditionalFormat.Values.Add(GetFormula(fr.Rank.Value.ToString())); + } + else if (conditionalFormat.ConditionalFormatType == XLConditionalFormatType.TimePeriod) + { + if (fr.TimePeriod != null) + conditionalFormat.TimePeriod = fr.TimePeriod.Value.ToClosedXml(); + else + conditionalFormat.TimePeriod = XLTimePeriod.Yesterday; + } + + if (fr.Elements().Any()) + { + var colorScale = fr.Elements().First(); + ExtractConditionalFormatValueObjects(conditionalFormat, colorScale); + } + else if (fr.Elements().Any()) + { + var dataBar = fr.Elements().First(); + if (dataBar.ShowValue != null) + conditionalFormat.ShowBarOnly = !dataBar.ShowValue.Value; + + var id = fr.Descendants().FirstOrDefault(); + if (id != null && id.Text != null && !String.IsNullOrWhiteSpace(id.Text)) + conditionalFormat.Id = new Guid(id.Text.Substring(1, id.Text.Length - 2)); + + ExtractConditionalFormatValueObjects(conditionalFormat, dataBar); + } + else if (fr.Elements().Any()) + { + var iconSet = fr.Elements().First(); + if (iconSet.ShowValue != null) + conditionalFormat.ShowIconOnly = !iconSet.ShowValue.Value; + if (iconSet.Reverse != null) + conditionalFormat.ReverseIconOrder = iconSet.Reverse.Value; + + if (iconSet.IconSetValue != null) + conditionalFormat.IconSetStyle = iconSet.IconSetValue.Value.ToClosedXml(); + else + conditionalFormat.IconSetStyle = XLIconSetStyle.ThreeTrafficLights1; + + ExtractConditionalFormatValueObjects(conditionalFormat, iconSet); + } + + var isPivotTableFormatting = conditionalFormatting.Pivot?.Value ?? false; + if (isPivotTableFormatting) + context.AddPivotTableCf(ws.Name, conditionalFormat); + else + ws.ConditionalFormats.Add(conditionalFormat); + } + } + + private static XLFormula GetFormula(String value) + { + var formula = new XLFormula(); + formula._value = value; + formula.IsFormula = !(value[0] == '"' && value.EndsWith("\"")); + return formula; + } + + private static void ExtractConditionalFormatValueObjects(XLConditionalFormat conditionalFormat, OpenXmlElement element) + { + foreach (var c in element.Elements()) + { + if (c.Type != null) + conditionalFormat.ContentTypes.Add(c.Type.Value.ToClosedXml()); + if (c.Val != null) + conditionalFormat.Values.Add(new XLFormula { Value = c.Val.Value }); + else + conditionalFormat.Values.Add(null); + + if (c.GreaterThanOrEqual != null) + conditionalFormat.IconSetOperators.Add(c.GreaterThanOrEqual.Value ? XLCFIconSetOperator.EqualOrGreaterThan : XLCFIconSetOperator.GreaterThan); + else + conditionalFormat.IconSetOperators.Add(XLCFIconSetOperator.EqualOrGreaterThan); + } + foreach (var c in element.Elements()) + { + conditionalFormat.Colors.Add(c.ToClosedXMLColor()); + } + } + + private static void LoadDataValidations(DataValidations dataValidations, XLWorksheet ws) + { + if (dataValidations == null) return; + + foreach (DataValidation dvs in dataValidations.Elements()) + { + String txt = dvs.SequenceOfReferences.InnerText; + if (String.IsNullOrWhiteSpace(txt)) continue; + foreach (var rangeAddress in txt.Split(' ')) + { + var dvt = new XLDataValidation(ws.Range(rangeAddress)); + ws.DataValidations.Add(dvt, skipIntersectionsCheck: true); + if (dvs.AllowBlank != null) dvt.IgnoreBlanks = dvs.AllowBlank; + if (dvs.ShowDropDown != null) dvt.InCellDropdown = !dvs.ShowDropDown.Value; + if (dvs.ShowErrorMessage != null) dvt.ShowErrorMessage = dvs.ShowErrorMessage; + if (dvs.ShowInputMessage != null) dvt.ShowInputMessage = dvs.ShowInputMessage; + if (dvs.PromptTitle != null) dvt.InputTitle = dvs.PromptTitle; + if (dvs.Prompt != null) dvt.InputMessage = dvs.Prompt; + if (dvs.ErrorTitle != null) dvt.ErrorTitle = dvs.ErrorTitle; + if (dvs.Error != null) dvt.ErrorMessage = dvs.Error; + if (dvs.ErrorStyle != null) dvt.ErrorStyle = dvs.ErrorStyle.Value.ToClosedXml(); + if (dvs.Type != null) dvt.AllowedValues = dvs.Type.Value.ToClosedXml(); + if (dvs.Operator != null) dvt.Operator = dvs.Operator.Value.ToClosedXml(); + if (dvs.Formula1 != null) dvt.MinValue = dvs.Formula1.Text; + if (dvs.Formula2 != null) dvt.MaxValue = dvs.Formula2.Text; + } + } + } + + private static void LoadHyperlinks(Hyperlinks hyperlinks, WorksheetPart worksheetPart, XLWorksheet ws) + { + var hyperlinkDictionary = new Dictionary(); + if (worksheetPart.HyperlinkRelationships != null) + hyperlinkDictionary = worksheetPart.HyperlinkRelationships.ToDictionary(hr => hr.Id, hr => hr.Uri); + + if (hyperlinks == null) return; + + foreach (Hyperlink hl in hyperlinks.Elements()) + { + if (hl.Reference.Value.Equals("#REF")) continue; + String tooltip = hl.Tooltip != null ? hl.Tooltip.Value : String.Empty; + var xlRange = ws.Range(hl.Reference.Value); + foreach (XLCell xlCell in xlRange.Cells()) + { + if (hl.Id != null) + xlCell.SetCellHyperlink(new XLHyperlink(hyperlinkDictionary[hl.Id], tooltip)); + else if (hl.Location != null) + xlCell.SetCellHyperlink(new XLHyperlink(hl.Location.Value, tooltip)); + else + xlCell.SetCellHyperlink(new XLHyperlink(hl.Reference.Value, tooltip)); + } + } + } + + private static void LoadPrintOptions(PrintOptions printOptions, XLWorksheet ws) + { + if (printOptions == null) return; + + if (printOptions.GridLines != null) + ws.PageSetup.ShowGridlines = printOptions.GridLines; + if (printOptions.HorizontalCentered != null) + ws.PageSetup.CenterHorizontally = printOptions.HorizontalCentered; + if (printOptions.VerticalCentered != null) + ws.PageSetup.CenterVertically = printOptions.VerticalCentered; + if (printOptions.Headings != null) + ws.PageSetup.ShowRowAndColumnHeadings = printOptions.Headings; + } + + private static void LoadPageMargins(PageMargins pageMargins, XLWorksheet ws) + { + if (pageMargins == null) return; + + if (pageMargins.Bottom != null) + ws.PageSetup.Margins.Bottom = pageMargins.Bottom; + if (pageMargins.Footer != null) + ws.PageSetup.Margins.Footer = pageMargins.Footer; + if (pageMargins.Header != null) + ws.PageSetup.Margins.Header = pageMargins.Header; + if (pageMargins.Left != null) + ws.PageSetup.Margins.Left = pageMargins.Left; + if (pageMargins.Right != null) + ws.PageSetup.Margins.Right = pageMargins.Right; + if (pageMargins.Top != null) + ws.PageSetup.Margins.Top = pageMargins.Top; + } + + private static void LoadPageSetup(PageSetup pageSetup, XLWorksheet ws, PageSetupProperties pageSetupProperties) + { + if (pageSetup == null) return; + + if (pageSetup.PaperSize != null) + ws.PageSetup.PaperSize = (XLPaperSize)Int32.Parse(pageSetup.PaperSize.InnerText); + if (pageSetup.Scale != null) + ws.PageSetup.Scale = Int32.Parse(pageSetup.Scale.InnerText); + if (pageSetupProperties != null && pageSetupProperties.FitToPage != null && pageSetupProperties.FitToPage.Value) + { + if (pageSetup.FitToWidth == null) + ws.PageSetup.PagesWide = 1; + else + ws.PageSetup.PagesWide = Int32.Parse(pageSetup.FitToWidth.InnerText); + + if (pageSetup.FitToHeight == null) + ws.PageSetup.PagesTall = 1; + else + ws.PageSetup.PagesTall = Int32.Parse(pageSetup.FitToHeight.InnerText); + } + if (pageSetup.PageOrder != null) + ws.PageSetup.PageOrder = pageSetup.PageOrder.Value.ToClosedXml(); + if (pageSetup.Orientation != null) + ws.PageSetup.PageOrientation = pageSetup.Orientation.Value.ToClosedXml(); + if (pageSetup.BlackAndWhite != null) + ws.PageSetup.BlackAndWhite = pageSetup.BlackAndWhite; + if (pageSetup.Draft != null) + ws.PageSetup.DraftQuality = pageSetup.Draft; + if (pageSetup.CellComments != null) + ws.PageSetup.ShowComments = pageSetup.CellComments.Value.ToClosedXml(); + if (pageSetup.Errors != null) + ws.PageSetup.PrintErrorValue = pageSetup.Errors.Value.ToClosedXml(); + if (pageSetup.HorizontalDpi != null) ws.PageSetup.HorizontalDpi = (Int32)pageSetup.HorizontalDpi.Value; + if (pageSetup.VerticalDpi != null) ws.PageSetup.VerticalDpi = (Int32)pageSetup.VerticalDpi.Value; + if (pageSetup.FirstPageNumber?.HasValue ?? false) + ws.PageSetup.FirstPageNumber = (int)pageSetup.FirstPageNumber.Value; + } + + private static void LoadHeaderFooter(HeaderFooter headerFooter, XLWorksheet ws) + { + if (headerFooter == null) return; + + if (headerFooter.AlignWithMargins != null) + ws.PageSetup.AlignHFWithMargins = headerFooter.AlignWithMargins; + if (headerFooter.ScaleWithDoc != null) + ws.PageSetup.ScaleHFWithDocument = headerFooter.ScaleWithDoc; + + if (headerFooter.DifferentFirst != null) + ws.PageSetup.DifferentFirstPageOnHF = headerFooter.DifferentFirst; + if (headerFooter.DifferentOddEven != null) + ws.PageSetup.DifferentOddEvenPagesOnHF = headerFooter.DifferentOddEven; + + // Footers + var xlFooter = (XLHeaderFooter)ws.PageSetup.Footer; + var evenFooter = headerFooter.EvenFooter; + if (evenFooter != null) + xlFooter.SetInnerText(XLHFOccurrence.EvenPages, evenFooter.Text); + var oddFooter = headerFooter.OddFooter; + if (oddFooter != null) + xlFooter.SetInnerText(XLHFOccurrence.OddPages, oddFooter.Text); + var firstFooter = headerFooter.FirstFooter; + if (firstFooter != null) + xlFooter.SetInnerText(XLHFOccurrence.FirstPage, firstFooter.Text); + // Headers + var xlHeader = (XLHeaderFooter)ws.PageSetup.Header; + var evenHeader = headerFooter.EvenHeader; + if (evenHeader != null) + xlHeader.SetInnerText(XLHFOccurrence.EvenPages, evenHeader.Text); + var oddHeader = headerFooter.OddHeader; + if (oddHeader != null) + xlHeader.SetInnerText(XLHFOccurrence.OddPages, oddHeader.Text); + var firstHeader = headerFooter.FirstHeader; + if (firstHeader != null) + xlHeader.SetInnerText(XLHFOccurrence.FirstPage, firstHeader.Text); + + ((XLHeaderFooter)ws.PageSetup.Header).SetAsInitial(); + ((XLHeaderFooter)ws.PageSetup.Footer).SetAsInitial(); + } + + private static void LoadRowBreaks(RowBreaks rowBreaks, XLWorksheet ws) + { + if (rowBreaks == null) return; + + foreach (Break rowBreak in rowBreaks.Elements()) + ws.PageSetup.RowBreaks.Add(Int32.Parse(rowBreak.Id.InnerText)); + } + + private static void LoadColumnBreaks(ColumnBreaks columnBreaks, XLWorksheet ws) + { + if (columnBreaks == null) return; + + foreach (Break columnBreak in columnBreaks.Elements().Where(columnBreak => columnBreak.Id != null)) + { + ws.PageSetup.ColumnBreaks.Add(Int32.Parse(columnBreak.Id.InnerText)); + } + } + + private static void LoadExtensions(WorksheetExtensionList extensions, XLWorksheet ws) + { + if (extensions == null) + { + return; + } + + foreach (var dvs in extensions + .Descendants() + .SelectMany(dataValidations => dataValidations.Descendants())) + { + String txt = dvs.ReferenceSequence.InnerText; + if (String.IsNullOrWhiteSpace(txt)) continue; + foreach (var rangeAddress in txt.Split(' ')) + { + var dvt = new XLDataValidation(ws.Range(rangeAddress)); + ws.DataValidations.Add(dvt, skipIntersectionsCheck: true); + if (dvs.AllowBlank != null) dvt.IgnoreBlanks = dvs.AllowBlank; + if (dvs.ShowDropDown != null) dvt.InCellDropdown = !dvs.ShowDropDown.Value; + if (dvs.ShowErrorMessage != null) dvt.ShowErrorMessage = dvs.ShowErrorMessage; + if (dvs.ShowInputMessage != null) dvt.ShowInputMessage = dvs.ShowInputMessage; + if (dvs.PromptTitle != null) dvt.InputTitle = dvs.PromptTitle; + if (dvs.Prompt != null) dvt.InputMessage = dvs.Prompt; + if (dvs.ErrorTitle != null) dvt.ErrorTitle = dvs.ErrorTitle; + if (dvs.Error != null) dvt.ErrorMessage = dvs.Error; + if (dvs.ErrorStyle != null) dvt.ErrorStyle = dvs.ErrorStyle.Value.ToClosedXml(); + if (dvs.Type != null) dvt.AllowedValues = dvs.Type.Value.ToClosedXml(); + if (dvs.Operator != null) dvt.Operator = dvs.Operator.Value.ToClosedXml(); + if (dvs.DataValidationForumla1 != null) dvt.MinValue = dvs.DataValidationForumla1.InnerText; + if (dvs.DataValidationForumla2 != null) dvt.MaxValue = dvs.DataValidationForumla2.InnerText; + } + } + + foreach (var conditionalFormattingRule in extensions + .Descendants() + .Where(cf => + cf.Type != null + && cf.Type.HasValue + && cf.Type.Value == ConditionalFormatValues.DataBar)) + { + var xlConditionalFormat = ws.ConditionalFormats + .Cast() + .SingleOrDefault(cf => cf.Id.WrapInBraces() == conditionalFormattingRule.Id); + if (xlConditionalFormat != null) + { + var negativeFillColor = conditionalFormattingRule.Descendants().SingleOrDefault(); + xlConditionalFormat.Colors.Add(negativeFillColor.ToClosedXMLColor()); + } + } + + foreach (var slg in extensions + .Descendants() + .SelectMany(sparklineGroups => sparklineGroups.Descendants())) + { + var xlSparklineGroup = (ws.SparklineGroups as XLSparklineGroups).Add(); + + if (slg.Formula != null) + xlSparklineGroup.DateRange = ws.Workbook.Range(slg.Formula.Text); + + var xlSparklineStyle = xlSparklineGroup.Style; + if (slg.FirstMarkerColor != null) xlSparklineStyle.FirstMarkerColor = slg.FirstMarkerColor.ToClosedXMLColor(); + if (slg.LastMarkerColor != null) xlSparklineStyle.LastMarkerColor = slg.LastMarkerColor.ToClosedXMLColor(); + if (slg.HighMarkerColor != null) xlSparklineStyle.HighMarkerColor = slg.HighMarkerColor.ToClosedXMLColor(); + if (slg.LowMarkerColor != null) xlSparklineStyle.LowMarkerColor = slg.LowMarkerColor.ToClosedXMLColor(); + if (slg.SeriesColor != null) xlSparklineStyle.SeriesColor = slg.SeriesColor.ToClosedXMLColor(); + if (slg.NegativeColor != null) xlSparklineStyle.NegativeColor = slg.NegativeColor.ToClosedXMLColor(); + if (slg.MarkersColor != null) xlSparklineStyle.MarkersColor = slg.MarkersColor.ToClosedXMLColor(); + xlSparklineGroup.Style = xlSparklineStyle; + + if (slg.DisplayHidden != null) xlSparklineGroup.DisplayHidden = slg.DisplayHidden; + if (slg.LineWeight != null) xlSparklineGroup.LineWeight = slg.LineWeight; + if (slg.Type != null) xlSparklineGroup.Type = slg.Type.Value.ToClosedXml(); + if (slg.DisplayEmptyCellsAs != null) xlSparklineGroup.DisplayEmptyCellsAs = slg.DisplayEmptyCellsAs.Value.ToClosedXml(); + + xlSparklineGroup.ShowMarkers = XLSparklineMarkers.None; + if (OpenXmlHelper.GetBooleanValueAsBool(slg.Markers, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.Markers; + if (OpenXmlHelper.GetBooleanValueAsBool(slg.High, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.HighPoint; + if (OpenXmlHelper.GetBooleanValueAsBool(slg.Low, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.LowPoint; + if (OpenXmlHelper.GetBooleanValueAsBool(slg.First, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.FirstPoint; + if (OpenXmlHelper.GetBooleanValueAsBool(slg.Last, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.LastPoint; + if (OpenXmlHelper.GetBooleanValueAsBool(slg.Negative, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.NegativePoints; + + if (slg.AxisColor != null) xlSparklineGroup.HorizontalAxis.Color = XLColor.FromHtml(slg.AxisColor.Rgb.Value); + if (slg.DisplayXAxis != null) xlSparklineGroup.HorizontalAxis.IsVisible = slg.DisplayXAxis; + if (slg.RightToLeft != null) xlSparklineGroup.HorizontalAxis.RightToLeft = slg.RightToLeft; + + if (slg.ManualMax != null) xlSparklineGroup.VerticalAxis.ManualMax = slg.ManualMax; + if (slg.ManualMin != null) xlSparklineGroup.VerticalAxis.ManualMin = slg.ManualMin; + if (slg.MinAxisType != null) xlSparklineGroup.VerticalAxis.MinAxisType = slg.MinAxisType.Value.ToClosedXml(); + if (slg.MaxAxisType != null) xlSparklineGroup.VerticalAxis.MaxAxisType = slg.MaxAxisType.Value.ToClosedXml(); + + slg.Descendants().SelectMany(sls => sls.Descendants()) + .ForEach(sl => xlSparklineGroup.Add(sl.ReferenceSequence?.Text, sl.Formula?.Text)); + } + } + + private static void ApplyStyle(IXLStylized xlStylized, Int32 styleIndex, Stylesheet s, Fills fills, Borders borders, + NumberingFormats numberingFormats, XLWorkbookStyles styles) + { + var xlStyleKey = XLStyle.Default.Key; + XLWorkbook.LoadStyle(ref xlStyleKey, styleIndex, s, fills, borders, numberingFormats, styles); + + // When loading columns we must propagate style to each column but not deeper. In other cases we do not propagate at all. + if (xlStylized is IXLColumns columns) + { + columns.Cast().ForEach(col => col.InnerStyle = new XLStyle(col, xlStyleKey)); + } + else + { + xlStylized.InnerStyle = new XLStyle(xlStylized, xlStyleKey); + } + } +} diff --git a/ClosedXML/Excel/IO/WorksheetPartWriter.cs b/ClosedXML/Excel/IO/WorksheetPartWriter.cs new file mode 100644 index 000000000..8a9405f6e --- /dev/null +++ b/ClosedXML/Excel/IO/WorksheetPartWriter.cs @@ -0,0 +1,2382 @@ +#nullable disable + +using ClosedXML.Excel.ContentManagers; +using ClosedXML.Excel.Exceptions; +using ClosedXML.Extensions; +using ClosedXML.Utils; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Drawing; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Break = DocumentFormat.OpenXml.Spreadsheet.Break; +using Column = DocumentFormat.OpenXml.Spreadsheet.Column; +using Columns = DocumentFormat.OpenXml.Spreadsheet.Columns; +using Drawing = DocumentFormat.OpenXml.Spreadsheet.Drawing; +using Hyperlink = DocumentFormat.OpenXml.Spreadsheet.Hyperlink; +using OfficeExcel = DocumentFormat.OpenXml.Office.Excel; +using X14 = DocumentFormat.OpenXml.Office2010.Excel; +using Xdr = DocumentFormat.OpenXml.Drawing.Spreadsheet; +using System.Reflection; +using System.Xml; +using static ClosedXML.Excel.XLWorkbook; +using static ClosedXML.Excel.IO.OpenXmlConst; + +namespace ClosedXML.Excel.IO +{ + internal class WorksheetPartWriter + { + internal static void GenerateWorksheetPartContent( + bool partIsEmpty, + WorksheetPart worksheetPart, + XLWorksheet xlWorksheet, + SaveOptions options, + SaveContext context) + { + var worksheetDom = GetWorksheetDom(partIsEmpty, worksheetPart, xlWorksheet, options, context); + StreamToPart(worksheetDom, worksheetPart, xlWorksheet, context, options); + } + + private static Worksheet GetWorksheetDom( + bool partIsEmpty, + WorksheetPart worksheetPart, + XLWorksheet xlWorksheet, + SaveOptions options, + SaveContext context) + { + if (options.ConsolidateConditionalFormatRanges) + { + xlWorksheet.ConditionalFormats.Consolidate(); + } + + #region Worksheet + + Worksheet worksheet; + if (!partIsEmpty) + { + // Accessing the worksheet through worksheetPart.Worksheet creates an attached DOM + // worksheet that is tracked and later saved automatically to the part. + // Using the reader, we get a detached DOM. + // The OpenXmlReader.Create method only reads xml declaration, but doesn't read content. + using var reader = OpenXmlReader.Create(worksheetPart); + if (!reader.Read()) + { + throw new ArgumentException("Worksheet part should contain worksheet xml, but is empty."); + } + + worksheet = (Worksheet)reader.LoadCurrentElement(); + } + else + { + worksheet = new Worksheet(); + } + + if (worksheet.NamespaceDeclarations.All(ns => ns.Value != RelationshipsNs)) + worksheet.AddNamespaceDeclaration("r", RelationshipsNs); + + // We store the x14ac:dyDescent attribute (if set by a xlRow) in a row element. It's an optional attribute and it + // needs a declared namespace. To avoid writing namespace to each element during streaming, write it to + // every sheet part ahead of time. The namespace has to be marked as ignorable, because OpenXML SDK validator will + // refuse to validate it because it's an optional extension (see ISO29500 part 3). + if (worksheet.NamespaceDeclarations.All(ns => ns.Value != X14Ac2009SsNs)) + { + worksheet.AddNamespaceDeclaration("x14ac", X14Ac2009SsNs); + worksheet.SetAttribute(new OpenXmlAttribute("mc", "Ignorable", MarkupCompatibilityNs, "x14ac")); + } + + #endregion Worksheet + + var cm = new XLWorksheetContentManager(worksheet); + + #region SheetProperties + + if (worksheet.SheetProperties == null) + worksheet.SheetProperties = new SheetProperties(); + + worksheet.SheetProperties.TabColor = xlWorksheet.TabColor.HasValue + ? new TabColor().FromClosedXMLColor(xlWorksheet.TabColor) + : null; + + cm.SetElement(XLWorksheetContents.SheetProperties, worksheet.SheetProperties); + + if (worksheet.SheetProperties.OutlineProperties == null) + worksheet.SheetProperties.OutlineProperties = new OutlineProperties(); + + worksheet.SheetProperties.OutlineProperties.SummaryBelow = + (xlWorksheet.Outline.SummaryVLocation == + XLOutlineSummaryVLocation.Bottom); + worksheet.SheetProperties.OutlineProperties.SummaryRight = + (xlWorksheet.Outline.SummaryHLocation == + XLOutlineSummaryHLocation.Right); + + if (worksheet.SheetProperties.PageSetupProperties == null + && (xlWorksheet.PageSetup.PagesTall > 0 || xlWorksheet.PageSetup.PagesWide > 0)) + worksheet.SheetProperties.PageSetupProperties = new PageSetupProperties { FitToPage = true }; + + #endregion SheetProperties + + // Empty worksheets have dimension A1 (not A1:A1) + var sheetDimensionReference = "A1"; + if (!xlWorksheet.Internals.CellsCollection.IsEmpty) + { + var maxColumn = xlWorksheet.Internals.CellsCollection.MaxColumnUsed; + var maxRow = xlWorksheet.Internals.CellsCollection.MaxRowUsed; + sheetDimensionReference = "A1:" + XLHelper.GetColumnLetterFromNumber(maxColumn) + + maxRow.ToInvariantString(); + } + + #region SheetViews + + if (worksheet.SheetDimension == null) + worksheet.SheetDimension = new SheetDimension { Reference = sheetDimensionReference }; + + cm.SetElement(XLWorksheetContents.SheetDimension, worksheet.SheetDimension); + + if (worksheet.SheetViews == null) + worksheet.SheetViews = new SheetViews(); + + cm.SetElement(XLWorksheetContents.SheetViews, worksheet.SheetViews); + + var sheetView = (SheetView)worksheet.SheetViews.FirstOrDefault(); + if (sheetView == null) + { + sheetView = new SheetView { WorkbookViewId = 0U }; + worksheet.SheetViews.AppendChild(sheetView); + } + + var svcm = new XLSheetViewContentManager(sheetView); + + if (xlWorksheet.TabSelected) + sheetView.TabSelected = true; + else + sheetView.TabSelected = null; + + if (xlWorksheet.RightToLeft) + sheetView.RightToLeft = true; + else + sheetView.RightToLeft = null; + + if (xlWorksheet.ShowFormulas) + sheetView.ShowFormulas = true; + else + sheetView.ShowFormulas = null; + + if (xlWorksheet.ShowGridLines) + sheetView.ShowGridLines = null; + else + sheetView.ShowGridLines = false; + + if (xlWorksheet.ShowOutlineSymbols) + sheetView.ShowOutlineSymbols = null; + else + sheetView.ShowOutlineSymbols = false; + + if (xlWorksheet.ShowRowColHeaders) + sheetView.ShowRowColHeaders = null; + else + sheetView.ShowRowColHeaders = false; + + if (xlWorksheet.ShowRuler) + sheetView.ShowRuler = null; + else + sheetView.ShowRuler = false; + + if (xlWorksheet.ShowWhiteSpace) + sheetView.ShowWhiteSpace = null; + else + sheetView.ShowWhiteSpace = false; + + if (xlWorksheet.ShowZeros) + sheetView.ShowZeros = null; + else + sheetView.ShowZeros = false; + + if (xlWorksheet.RightToLeft) + sheetView.RightToLeft = true; + else + sheetView.RightToLeft = null; + + if (xlWorksheet.SheetView.View == XLSheetViewOptions.Normal) + sheetView.View = null; + else + sheetView.View = xlWorksheet.SheetView.View.ToOpenXml(); + + var pane = sheetView.Elements().FirstOrDefault(); + if (pane == null) + { + pane = new Pane(); + sheetView.InsertAt(pane, 0); + } + + svcm.SetElement(XLSheetViewContents.Pane, pane); + + pane.State = PaneStateValues.FrozenSplit; + int hSplit = xlWorksheet.SheetView.SplitColumn; + int ySplit = xlWorksheet.SheetView.SplitRow; + + pane.HorizontalSplit = hSplit; + pane.VerticalSplit = ySplit; + + // When panes are frozen, which part should move. + PaneValues split; + if (ySplit == 0 && hSplit == 0) + split = PaneValues.TopLeft; + else if (ySplit == 0 && hSplit != 0) + split = PaneValues.TopRight; + else if (ySplit != 0 && hSplit == 0) + split = PaneValues.BottomLeft; + else if (ySplit != 0 && hSplit != 0) + split = PaneValues.BottomRight; + + pane.ActivePane = split; + + pane.TopLeftCell = XLHelper.GetColumnLetterFromNumber(xlWorksheet.SheetView.SplitColumn + 1) + + (xlWorksheet.SheetView.SplitRow + 1); + + if (hSplit == 0 && ySplit == 0) + { + // We don't have a pane. Just a regular sheet. + pane = null; + sheetView.RemoveAllChildren(); + svcm.SetElement(XLSheetViewContents.Pane, null); + } + + // Do sheet view. Whether it's for a regular sheet or for the bottom-right pane + if (!xlWorksheet.SheetView.TopLeftCellAddress.IsValid + || xlWorksheet.SheetView.TopLeftCellAddress == new XLAddress(1, 1, fixedRow: false, fixedColumn: false)) + sheetView.TopLeftCell = null; + else + sheetView.TopLeftCell = xlWorksheet.SheetView.TopLeftCellAddress.ToString(); + + if (xlWorksheet.SelectedRanges.Any() || xlWorksheet.ActiveCell is not null) + { + sheetView.RemoveAllChildren(); + svcm.SetElement(XLSheetViewContents.Selection, null); + + var firstSelection = xlWorksheet.SelectedRanges.FirstOrDefault(); + + Action populateSelection = (Selection selection) => + { + if (xlWorksheet.ActiveCell is not null) + selection.ActiveCell = xlWorksheet.ActiveCell.Value.ToString(); + else if (firstSelection != null) + selection.ActiveCell = firstSelection.RangeAddress.FirstAddress.ToStringRelative(false); + + var seqRef = new List { selection.ActiveCell.Value }; + seqRef.AddRange(xlWorksheet.SelectedRanges + .Select(range => + { + if (range.RangeAddress.FirstAddress.Equals(range.RangeAddress.LastAddress)) + return range.RangeAddress.FirstAddress.ToStringRelative(false); + else + return range.RangeAddress.ToStringRelative(false); + }) + ); + + selection.SequenceOfReferences = new ListValue { InnerText = String.Join(" ", seqRef.Distinct().ToArray()) }; + + sheetView.InsertAfter(selection, svcm.GetPreviousElementFor(XLSheetViewContents.Selection)); + svcm.SetElement(XLSheetViewContents.Selection, selection); + }; + + // If a pane exists, we need to set the active pane too + // Yes, this might lead to 2 Selection elements! + if (pane != null) + { + populateSelection(new Selection() + { + Pane = pane.ActivePane + }); + } + populateSelection(new Selection()); + } + + if (xlWorksheet.SheetView.ZoomScale == 100) + sheetView.ZoomScale = null; + else + sheetView.ZoomScale = (UInt32)Math.Max(10, Math.Min(400, xlWorksheet.SheetView.ZoomScale)); + + if (xlWorksheet.SheetView.ZoomScaleNormal == 100) + sheetView.ZoomScaleNormal = null; + else + sheetView.ZoomScaleNormal = (UInt32)Math.Max(10, Math.Min(400, xlWorksheet.SheetView.ZoomScaleNormal)); + + if (xlWorksheet.SheetView.ZoomScalePageLayoutView == 100) + sheetView.ZoomScalePageLayoutView = null; + else + sheetView.ZoomScalePageLayoutView = (UInt32)Math.Max(10, Math.Min(400, xlWorksheet.SheetView.ZoomScalePageLayoutView)); + + if (xlWorksheet.SheetView.ZoomScaleSheetLayoutView == 100) + sheetView.ZoomScaleSheetLayoutView = null; + else + sheetView.ZoomScaleSheetLayoutView = (UInt32)Math.Max(10, Math.Min(400, xlWorksheet.SheetView.ZoomScaleSheetLayoutView)); + + #endregion SheetViews + + var maxOutlineColumn = 0; + if (xlWorksheet.ColumnCount() > 0) + maxOutlineColumn = xlWorksheet.GetMaxColumnOutline(); + + var maxOutlineRow = 0; + if (xlWorksheet.RowCount() > 0) + maxOutlineRow = xlWorksheet.GetMaxRowOutline(); + + #region SheetFormatProperties + + if (worksheet.SheetFormatProperties == null) + worksheet.SheetFormatProperties = new SheetFormatProperties(); + + cm.SetElement(XLWorksheetContents.SheetFormatProperties, + worksheet.SheetFormatProperties); + + worksheet.SheetFormatProperties.DefaultRowHeight = xlWorksheet.RowHeight.SaveRound(); + + if (xlWorksheet.RowHeightChanged) + worksheet.SheetFormatProperties.CustomHeight = true; + else + worksheet.SheetFormatProperties.CustomHeight = null; + + var worksheetColumnWidth = GetColumnWidth(xlWorksheet.ColumnWidth).SaveRound(); + if (xlWorksheet.ColumnWidthChanged) + worksheet.SheetFormatProperties.DefaultColumnWidth = worksheetColumnWidth; + else + worksheet.SheetFormatProperties.DefaultColumnWidth = null; + + if (maxOutlineColumn > 0) + worksheet.SheetFormatProperties.OutlineLevelColumn = (byte)maxOutlineColumn; + else + worksheet.SheetFormatProperties.OutlineLevelColumn = null; + + if (maxOutlineRow > 0) + worksheet.SheetFormatProperties.OutlineLevelRow = (byte)maxOutlineRow; + else + worksheet.SheetFormatProperties.OutlineLevelRow = null; + + #endregion SheetFormatProperties + + #region Columns + + var worksheetStyleId = context.SharedStyles[xlWorksheet.StyleValue].StyleId; + if (xlWorksheet.Internals.CellsCollection.IsEmpty && + xlWorksheet.Internals.ColumnsCollection.Count == 0 + && worksheetStyleId == 0) + worksheet.RemoveAllChildren(); + else + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.Columns); + worksheet.InsertAfter(new Columns(), previousElement); + } + + var columns = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.Columns, columns); + + var sheetColumnsByMin = columns.Elements().ToDictionary(c => c.Min.Value, c => c); + //Dictionary sheetColumnsByMax = columns.Elements().ToDictionary(c => c.Max.Value, c => c); + + Int32 minInColumnsCollection; + Int32 maxInColumnsCollection; + if (xlWorksheet.Internals.ColumnsCollection.Count > 0) + { + minInColumnsCollection = xlWorksheet.Internals.ColumnsCollection.Keys.Min(); + maxInColumnsCollection = xlWorksheet.Internals.ColumnsCollection.Keys.Max(); + } + else + { + minInColumnsCollection = 1; + maxInColumnsCollection = 0; + } + + if (minInColumnsCollection > 1) + { + UInt32Value min = 1; + UInt32Value max = (UInt32)(minInColumnsCollection - 1); + + for (var co = min; co <= max; co++) + { + var column = new Column + { + Min = co, + Max = co, + Style = worksheetStyleId, + Width = worksheetColumnWidth, + CustomWidth = true + }; + + UpdateColumn(column, columns, sheetColumnsByMin); //, sheetColumnsByMax); + } + } + + for (var co = minInColumnsCollection; co <= maxInColumnsCollection; co++) + { + UInt32 styleId; + Double columnWidth; + var isHidden = false; + var collapsed = false; + var outlineLevel = 0; + if (xlWorksheet.Internals.ColumnsCollection.TryGetValue(co, out XLColumn col)) + { + styleId = context.SharedStyles[col.StyleValue].StyleId; + columnWidth = GetColumnWidth(col.Width).SaveRound(); + isHidden = col.IsHidden; + collapsed = col.Collapsed; + outlineLevel = col.OutlineLevel; + } + else + { + styleId = context.SharedStyles[xlWorksheet.StyleValue].StyleId; + columnWidth = worksheetColumnWidth; + } + + var column = new Column + { + Min = (UInt32)co, + Max = (UInt32)co, + Style = styleId, + Width = columnWidth, + CustomWidth = true + }; + + if (isHidden) + column.Hidden = true; + if (collapsed) + column.Collapsed = true; + if (outlineLevel > 0) + column.OutlineLevel = (byte)outlineLevel; + + UpdateColumn(column, columns, sheetColumnsByMin); //, sheetColumnsByMax); + } + + var collection = maxInColumnsCollection; + foreach ( + var col in + columns.Elements().Where(c => c.Min > (UInt32)(collection)).OrderBy( + c => c.Min.Value)) + { + col.Style = worksheetStyleId; + col.Width = worksheetColumnWidth; + col.CustomWidth = true; + + if ((Int32)col.Max.Value > maxInColumnsCollection) + maxInColumnsCollection = (Int32)col.Max.Value; + } + + if (maxInColumnsCollection < XLHelper.MaxColumnNumber && worksheetStyleId != 0) + { + var column = new Column + { + Min = (UInt32)(maxInColumnsCollection + 1), + Max = (UInt32)(XLHelper.MaxColumnNumber), + Style = worksheetStyleId, + Width = worksheetColumnWidth, + CustomWidth = true + }; + columns.AppendChild(column); + } + + CollapseColumns(columns, sheetColumnsByMin); + + if (!columns.Any()) + { + worksheet.RemoveAllChildren(); + cm.SetElement(XLWorksheetContents.Columns, null); + } + } + + #endregion Columns + + #region SheetData + + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.SheetData); + worksheet.InsertAfter(new SheetData(), previousElement); + } + + var sheetData = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.SheetData, sheetData); + + // Sheet data is not updated in the Worksheet DOM here, because it is later being streamed directly to the file + // without an intermediate DOM representation. This is done to save memory, which is especially problematic + // for large sheets. + + #endregion SheetData + + #region SheetProtection + + if (xlWorksheet.Protection.IsProtected) + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.SheetProtection); + worksheet.InsertAfter(new SheetProtection(), previousElement); + } + + var sheetProtection = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.SheetProtection, sheetProtection); + + var protection = xlWorksheet.Protection; + sheetProtection.Sheet = OpenXmlHelper.GetBooleanValue(protection.IsProtected, false); + + sheetProtection.Password = null; + sheetProtection.AlgorithmName = null; + sheetProtection.HashValue = null; + sheetProtection.SpinCount = null; + sheetProtection.SaltValue = null; + + if (protection.Algorithm == XLProtectionAlgorithm.Algorithm.SimpleHash) + { + if (!String.IsNullOrWhiteSpace(protection.PasswordHash)) + sheetProtection.Password = protection.PasswordHash; + } + else + { + sheetProtection.AlgorithmName = DescribedEnumParser.ToDescription(protection.Algorithm); + sheetProtection.HashValue = protection.PasswordHash; + sheetProtection.SpinCount = protection.SpinCount; + sheetProtection.SaltValue = protection.Base64EncodedSalt; + } + + // default value of "1" + sheetProtection.FormatCells = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.FormatCells), true); + sheetProtection.FormatColumns = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.FormatColumns), true); + sheetProtection.FormatRows = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.FormatRows), true); + sheetProtection.InsertColumns = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.InsertColumns), true); + sheetProtection.InsertRows = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.InsertRows), true); + sheetProtection.InsertHyperlinks = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.InsertHyperlinks), true); + sheetProtection.DeleteColumns = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.DeleteColumns), true); + sheetProtection.DeleteRows = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.DeleteRows), true); + sheetProtection.Sort = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.Sort), true); + sheetProtection.AutoFilter = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.AutoFilter), true); + sheetProtection.PivotTables = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.PivotTables), true); + sheetProtection.Scenarios = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.EditScenarios), true); + + // default value of "0" + sheetProtection.Objects = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.EditObjects), false); + sheetProtection.SelectLockedCells = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.SelectLockedCells), false); + sheetProtection.SelectUnlockedCells = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.SelectUnlockedCells), false); + } + else + { + worksheet.RemoveAllChildren(); + cm.SetElement(XLWorksheetContents.SheetProtection, null); + } + + #endregion SheetProtection + + #region AutoFilter + + worksheet.RemoveAllChildren(); + if (xlWorksheet.AutoFilter.IsEnabled) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.AutoFilter); + worksheet.InsertAfter(new AutoFilter(), previousElement); + + var autoFilter = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.AutoFilter, autoFilter); + + PopulateAutoFilter(xlWorksheet.AutoFilter, autoFilter); + } + else + { + cm.SetElement(XLWorksheetContents.AutoFilter, null); + } + + #endregion AutoFilter + + #region MergeCells + + if ((xlWorksheet).Internals.MergedRanges.Any()) + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.MergeCells); + worksheet.InsertAfter(new MergeCells(), previousElement); + } + + var mergeCells = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.MergeCells, mergeCells); + mergeCells.RemoveAllChildren(); + + foreach (var mergeCell in (xlWorksheet).Internals.MergedRanges.Select( + m => m.RangeAddress.FirstAddress.ToString() + ":" + m.RangeAddress.LastAddress.ToString()).Select( + merged => new MergeCell { Reference = merged })) + mergeCells.AppendChild(mergeCell); + + mergeCells.Count = (UInt32)mergeCells.Count(); + } + else + { + worksheet.RemoveAllChildren(); + cm.SetElement(XLWorksheetContents.MergeCells, null); + } + + #endregion MergeCells + + #region Conditional Formatting + + var xlSheetPivotCfs = xlWorksheet.PivotTables + .SelectMany(pt => pt.ConditionalFormats.Select(cf => cf.Format)) + .ToHashSet(); + + // Elements in sheet.ConditionalFormats were sorted according to priority during load, + // but new ones have priority 0. CFs are also interleaved with sheet CF. To deal with + // these situations, set correct unique priority (also required for pivot CF). + var xlConditionalFormats = xlWorksheet.ConditionalFormats.Cast() + .Concat(xlSheetPivotCfs) + .OrderBy(x => x.Priority) + .ToList(); + for (var i = 0; i < xlConditionalFormats.Count; ++i) + xlConditionalFormats[i].Priority = i + 1; + + if (!xlConditionalFormats.Any()) + { + worksheet.RemoveAllChildren(); + cm.SetElement(XLWorksheetContents.ConditionalFormatting, null); + } + else + { + worksheet.RemoveAllChildren(); + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.ConditionalFormatting); + + foreach (var cfGroup in xlConditionalFormats + .GroupBy( + c => new + { + SeqRefs = string.Join(" ", c.Ranges.Select(r => r.RangeAddress.ToStringRelative(false))), + IsPivot = xlSheetPivotCfs.Contains(c), + }, + c => c, + (key, g) => new { key.SeqRefs, key.IsPivot, CfList = g.ToList() } + ) + ) + { + var conditionalFormatting = new ConditionalFormatting + { + SequenceOfReferences = + new ListValue { InnerText = cfGroup.SeqRefs }, + Pivot = cfGroup.IsPivot ? true : null, + }; + foreach (var cf in cfGroup.CfList) + { + var xlCf = XLCFConverters.Convert(cf, cf.Priority, context); + conditionalFormatting.Append(xlCf); + } + worksheet.InsertAfter(conditionalFormatting, previousElement); + previousElement = conditionalFormatting; + cm.SetElement(XLWorksheetContents.ConditionalFormatting, conditionalFormatting); + } + } + + var exlst = xlWorksheet.ConditionalFormats.Where(c => c.ConditionalFormatType == XLConditionalFormatType.DataBar).ToArray(); + if (exlst.Any()) + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.WorksheetExtensionList); + worksheet.InsertAfter(new WorksheetExtensionList(), previousElement); + } + + WorksheetExtensionList worksheetExtensionList = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.WorksheetExtensionList, worksheetExtensionList); + + var conditionalFormattings = worksheetExtensionList.Descendants().SingleOrDefault(); + if (conditionalFormattings == null || !conditionalFormattings.Any()) + { + WorksheetExtension worksheetExtension1 = new WorksheetExtension { Uri = "{78C0D931-6437-407d-A8EE-F0AAD7539E65}" }; + worksheetExtension1.AddNamespaceDeclaration("x14", X14Main2009SsNs); + worksheetExtensionList.Append(worksheetExtension1); + + conditionalFormattings = new DocumentFormat.OpenXml.Office2010.Excel.ConditionalFormattings(); + worksheetExtension1.Append(conditionalFormattings); + } + + foreach (var cfGroup in exlst + .GroupBy( + c => string.Join(" ", c.Ranges.Select(r => r.RangeAddress.ToStringRelative(false))), + c => c, + (key, g) => new { RangeId = key, CfList = g.ToList() } + ) + ) + { + foreach (var xlConditionalFormat in cfGroup.CfList.Cast()) + { + var conditionalFormattingRule = conditionalFormattings.Descendants() + .SingleOrDefault(r => r.Id == xlConditionalFormat.Id.WrapInBraces()); + if (conditionalFormattingRule != null) + { + var conditionalFormat = conditionalFormattingRule.Ancestors().SingleOrDefault(); + conditionalFormattings.RemoveChild(conditionalFormat); + } + + var conditionalFormatting = new DocumentFormat.OpenXml.Office2010.Excel.ConditionalFormatting(); + conditionalFormatting.AddNamespaceDeclaration("xm", XmMain2006); + conditionalFormatting.Append(XLCFConvertersExtension.Convert(xlConditionalFormat, context)); + var referenceSequence = new DocumentFormat.OpenXml.Office.Excel.ReferenceSequence { Text = cfGroup.RangeId }; + conditionalFormatting.Append(referenceSequence); + + conditionalFormattings.Append(conditionalFormatting); + } + } + } + + #endregion Conditional Formatting + + #region Sparklines + + const string sparklineGroupsExtensionUri = "{05C60535-1F16-4fd2-B633-F4F36F0B64E0}"; + + if (!xlWorksheet.SparklineGroups.Any()) + { + var worksheetExtensionList = worksheet.Elements().FirstOrDefault(); + var worksheetExtension = worksheetExtensionList?.Elements() + .FirstOrDefault(ext => string.Equals(ext.Uri, sparklineGroupsExtensionUri, StringComparison.InvariantCultureIgnoreCase)); + + worksheetExtension?.RemoveAllChildren(); + + if (worksheetExtensionList != null) + { + if (worksheetExtension != null && !worksheetExtension.HasChildren) + { + worksheetExtensionList.RemoveChild(worksheetExtension); + } + + if (!worksheetExtensionList.HasChildren) + { + worksheet.RemoveChild(worksheetExtensionList); + cm.SetElement(XLWorksheetContents.WorksheetExtensionList, null); + } + } + } + else + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.WorksheetExtensionList); + worksheet.InsertAfter(new WorksheetExtensionList(), previousElement); + } + + var worksheetExtensionList = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.WorksheetExtensionList, worksheetExtensionList); + + var sparklineGroups = worksheetExtensionList.Descendants().SingleOrDefault(); + + if (sparklineGroups == null || !sparklineGroups.Any()) + { + var worksheetExtension1 = new WorksheetExtension() { Uri = sparklineGroupsExtensionUri }; + worksheetExtension1.AddNamespaceDeclaration("x14", X14Main2009SsNs); + worksheetExtensionList.Append(worksheetExtension1); + + sparklineGroups = new X14.SparklineGroups(); + sparklineGroups.AddNamespaceDeclaration("xm", XmMain2006); + worksheetExtension1.Append(sparklineGroups); + } + else + { + sparklineGroups.RemoveAllChildren(); + } + + foreach (var xlSparklineGroup in xlWorksheet.SparklineGroups) + { + // Do not create an empty Sparkline group + if (!xlSparklineGroup.Any()) + continue; + + var sparklineGroup = new X14.SparklineGroup(); + sparklineGroup.SetAttribute(new OpenXmlAttribute("xr2", "uid", "http://schemas.microsoft.com/office/spreadsheetml/2015/revision2", "{A98FF5F8-AE60-43B5-8001-AD89004F45D3}")); + + sparklineGroup.FirstMarkerColor = new X14.FirstMarkerColor().FromClosedXMLColor(xlSparklineGroup.Style.FirstMarkerColor); + sparklineGroup.LastMarkerColor = new X14.LastMarkerColor().FromClosedXMLColor(xlSparklineGroup.Style.LastMarkerColor); + sparklineGroup.HighMarkerColor = new X14.HighMarkerColor().FromClosedXMLColor(xlSparklineGroup.Style.HighMarkerColor); + sparklineGroup.LowMarkerColor = new X14.LowMarkerColor().FromClosedXMLColor(xlSparklineGroup.Style.LowMarkerColor); + sparklineGroup.SeriesColor = new X14.SeriesColor().FromClosedXMLColor(xlSparklineGroup.Style.SeriesColor); + sparklineGroup.NegativeColor = new X14.NegativeColor().FromClosedXMLColor(xlSparklineGroup.Style.NegativeColor); + sparklineGroup.MarkersColor = new X14.MarkersColor().FromClosedXMLColor(xlSparklineGroup.Style.MarkersColor); + + sparklineGroup.High = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.HighPoint); + sparklineGroup.Low = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.LowPoint); + sparklineGroup.First = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.FirstPoint); + sparklineGroup.Last = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.LastPoint); + sparklineGroup.Negative = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.NegativePoints); + sparklineGroup.Markers = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.Markers); + + sparklineGroup.DisplayHidden = xlSparklineGroup.DisplayHidden; + sparklineGroup.LineWeight = xlSparklineGroup.LineWeight; + sparklineGroup.Type = xlSparklineGroup.Type.ToOpenXml(); + sparklineGroup.DisplayEmptyCellsAs = xlSparklineGroup.DisplayEmptyCellsAs.ToOpenXml(); + + sparklineGroup.AxisColor = new X14.AxisColor() { Rgb = xlSparklineGroup.HorizontalAxis.Color.Color.ToHex() }; + sparklineGroup.DisplayXAxis = xlSparklineGroup.HorizontalAxis.IsVisible; + sparklineGroup.RightToLeft = xlSparklineGroup.HorizontalAxis.RightToLeft; + sparklineGroup.DateAxis = xlSparklineGroup.HorizontalAxis.DateAxis; + if (xlSparklineGroup.HorizontalAxis.DateAxis) + sparklineGroup.Formula = new OfficeExcel.Formula( + xlSparklineGroup.DateRange.RangeAddress.ToString(XLReferenceStyle.A1, true)); + + sparklineGroup.MinAxisType = xlSparklineGroup.VerticalAxis.MinAxisType.ToOpenXml(); + if (xlSparklineGroup.VerticalAxis.MinAxisType == XLSparklineAxisMinMax.Custom) + sparklineGroup.ManualMin = xlSparklineGroup.VerticalAxis.ManualMin; + + sparklineGroup.MaxAxisType = xlSparklineGroup.VerticalAxis.MaxAxisType.ToOpenXml(); + if (xlSparklineGroup.VerticalAxis.MaxAxisType == XLSparklineAxisMinMax.Custom) + sparklineGroup.ManualMax = xlSparklineGroup.VerticalAxis.ManualMax; + + var sparklines = new X14.Sparklines(xlSparklineGroup + .Select(xlSparkline => new X14.Sparkline + { + Formula = new OfficeExcel.Formula(xlSparkline.SourceData.RangeAddress.ToString(XLReferenceStyle.A1, true)), + ReferenceSequence = + new OfficeExcel.ReferenceSequence(xlSparkline.Location.Address.ToString()) + }) + ); + + sparklineGroup.Append(sparklines); + sparklineGroups.Append(sparklineGroup); + } + + // if all Sparkline groups had no Sparklines, remove the entire SparklineGroup element + if (sparklineGroups.ChildElements.Count == 0) + { + sparklineGroups.Remove(); + } + } + + #endregion Sparklines + + #region DataValidations + + // Saving of data validations happens in 2 phases because depending on the data validation + // content, it gets saved into 1 of 2 possible locations in the XML structure. + // First phase, save all the data validations that aren't references to other sheets into + // the standard data validations section. + var dataValidationsStandard = new List<(IXLDataValidation DataValidation, string MinValue, string MaxValue)>(); + var dataValidationsExtension = new List<(IXLDataValidation DataValidation, string MinValue, string MaxValue)>(); + if (options.ConsolidateDataValidationRanges) + { + xlWorksheet.DataValidations.Consolidate(); + } + + foreach (var dv in xlWorksheet.DataValidations) + { + var (minReferencesAnotherSheet, minValue) = UsesExternalSheet(xlWorksheet, dv.MinValue); + var (maxReferencesAnotherSheet, maxValue) = UsesExternalSheet(xlWorksheet, dv.MaxValue); + + static (bool, string) UsesExternalSheet(XLWorksheet sheet, string value) + { + if (!XLHelper.IsValidRangeAddress(value)) + return (false, value); + + var separatorIndex = value.LastIndexOf('!'); + var hasSheet = separatorIndex >= 0; + if (!hasSheet) + return (false, value); + + var sheetName = value[..separatorIndex].UnescapeSheetName(); + if (XLHelper.SheetComparer.Equals(sheet.Name, sheetName)) + { + // The spec wants us to include references to ranges on the same worksheet without the sheet name + return (false, value.Substring(separatorIndex + 1)); + } + + return (true, value); + } + + if (minReferencesAnotherSheet || maxReferencesAnotherSheet) + { + // We're dealing with a data validation that references another sheet so has to be saved to extensions + dataValidationsExtension.Add((dv, minValue, maxValue)); + } + else + { + // We're dealing with a standard data validation + dataValidationsStandard.Add((dv, minValue, maxValue)); + } + } + + // Save validations that don't use another sheet. It must have at least 1 child, XML doesn't allow 0. + if (!dataValidationsStandard.Any(d => d.DataValidation.IsDirty())) + { + worksheet.RemoveAllChildren(); + cm.SetElement(XLWorksheetContents.DataValidations, null); + } + else + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.DataValidations); + worksheet.InsertAfter(new DataValidations(), previousElement); + } + + var dataValidations = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.DataValidations, dataValidations); + dataValidations.RemoveAllChildren(); + + foreach (var (dv, minValue, maxValue) in dataValidationsStandard) + { + var sequence = string.Join(" ", dv.Ranges.Select(x => x.RangeAddress)); + var dataValidation = new DataValidation + { + AllowBlank = dv.IgnoreBlanks, + Formula1 = new Formula1(minValue), + Formula2 = new Formula2(maxValue), + Type = dv.AllowedValues.ToOpenXml(), + ShowErrorMessage = dv.ShowErrorMessage, + Prompt = dv.InputMessage, + PromptTitle = dv.InputTitle, + ErrorTitle = dv.ErrorTitle, + Error = dv.ErrorMessage, + ShowDropDown = !dv.InCellDropdown, + ShowInputMessage = dv.ShowInputMessage, + ErrorStyle = dv.ErrorStyle.ToOpenXml(), + Operator = dv.Operator.ToOpenXml(), + SequenceOfReferences = new ListValue { InnerText = sequence } + }; + + dataValidations.AppendChild(dataValidation); + } + dataValidations.Count = (UInt32)dataValidationsStandard.Count; + } + + // Second phase, save all the data validations that reference other sheets into the worksheet extensions. + const string dataValidationsExtensionUri = "{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}"; + if (dataValidationsExtension.Count == 0) + { + var worksheetExtensionList = worksheet.Elements().FirstOrDefault(); + var worksheetExtension = worksheetExtensionList?.Elements() + .FirstOrDefault(ext => string.Equals(ext.Uri, dataValidationsExtensionUri, StringComparison.OrdinalIgnoreCase)); + + worksheetExtension?.RemoveAllChildren(); + + if (worksheetExtensionList != null) + { + if (worksheetExtension != null && !worksheetExtension.HasChildren) + { + worksheetExtensionList.RemoveChild(worksheetExtension); + } + + if (!worksheetExtensionList.HasChildren) + { + worksheet.RemoveChild(worksheetExtensionList); + cm.SetElement(XLWorksheetContents.WorksheetExtensionList, null); + } + } + } + else + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.WorksheetExtensionList); + worksheet.InsertAfter(new WorksheetExtensionList(), previousElement); + } + + var worksheetExtensionList = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.WorksheetExtensionList, worksheetExtensionList); + + var extensionDataValidations = worksheetExtensionList.Descendants().SingleOrDefault(); + + if (extensionDataValidations == null || !extensionDataValidations.Any()) + { + var worksheetExtension = new WorksheetExtension() { Uri = dataValidationsExtensionUri }; + worksheetExtension.AddNamespaceDeclaration("x14", X14Main2009SsNs); + worksheetExtensionList.Append(worksheetExtension); + + extensionDataValidations = new X14.DataValidations(); + extensionDataValidations.AddNamespaceDeclaration("xm", XmMain2006); + worksheetExtension.Append(extensionDataValidations); + } + else + { + extensionDataValidations.RemoveAllChildren(); + } + + foreach (var (dv, minValue, maxValue) in dataValidationsExtension) + { + var sequence = string.Join(" ", dv.Ranges.Select(x => x.RangeAddress)); + var dataValidation = new X14.DataValidation + { + AllowBlank = dv.IgnoreBlanks, + DataValidationForumla1 = !string.IsNullOrWhiteSpace(minValue) ? new X14.DataValidationForumla1(new OfficeExcel.Formula(minValue)) : null, + DataValidationForumla2 = !string.IsNullOrWhiteSpace(maxValue) ? new X14.DataValidationForumla2(new OfficeExcel.Formula(maxValue)) : null, + Type = dv.AllowedValues.ToOpenXml(), + ShowErrorMessage = dv.ShowErrorMessage, + Prompt = dv.InputMessage, + PromptTitle = dv.InputTitle, + ErrorTitle = dv.ErrorTitle, + Error = dv.ErrorMessage, + ShowDropDown = !dv.InCellDropdown, + ShowInputMessage = dv.ShowInputMessage, + ErrorStyle = dv.ErrorStyle.ToOpenXml(), + Operator = dv.Operator.ToOpenXml(), + ReferenceSequence = new OfficeExcel.ReferenceSequence() { Text = sequence } + }; + extensionDataValidations.AppendChild(dataValidation); + } + extensionDataValidations.Count = (UInt32)dataValidationsExtension.Count; + } + + #endregion DataValidations + + #region Hyperlinks + + var relToRemove = worksheetPart.HyperlinkRelationships.ToList(); + relToRemove.ForEach(worksheetPart.DeleteReferenceRelationship); + if (!xlWorksheet.Hyperlinks.Any()) + { + worksheet.RemoveAllChildren(); + cm.SetElement(XLWorksheetContents.Hyperlinks, null); + } + else + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.Hyperlinks); + worksheet.InsertAfter(new Hyperlinks(), previousElement); + } + + var hyperlinks = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.Hyperlinks, hyperlinks); + hyperlinks.RemoveAllChildren(); + foreach (var hl in xlWorksheet.Hyperlinks) + { + Hyperlink hyperlink; + if (hl.IsExternal) + { + var rId = context.RelIdGenerator.GetNext(XLWorkbook.RelType.Workbook); + hyperlink = new Hyperlink { Reference = hl.Cell.Address.ToString(), Id = rId }; + worksheetPart.AddHyperlinkRelationship(hl.ExternalAddress, true, rId); + } + else + { + hyperlink = new Hyperlink + { + Reference = hl.Cell.Address.ToString(), + Location = hl.InternalAddress, + Display = hl.Cell.GetFormattedString() + }; + } + if (!String.IsNullOrWhiteSpace(hl.Tooltip)) + hyperlink.Tooltip = hl.Tooltip; + hyperlinks.AppendChild(hyperlink); + } + } + + #endregion Hyperlinks + + #region PrintOptions + + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.PrintOptions); + worksheet.InsertAfter(new PrintOptions(), previousElement); + } + + var printOptions = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.PrintOptions, printOptions); + + printOptions.HorizontalCentered = xlWorksheet.PageSetup.CenterHorizontally; + printOptions.VerticalCentered = xlWorksheet.PageSetup.CenterVertically; + printOptions.Headings = xlWorksheet.PageSetup.ShowRowAndColumnHeadings; + printOptions.GridLines = xlWorksheet.PageSetup.ShowGridlines; + + #endregion PrintOptions + + #region PageMargins + + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.PageMargins); + worksheet.InsertAfter(new PageMargins(), previousElement); + } + + var pageMargins = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.PageMargins, pageMargins); + pageMargins.Left = xlWorksheet.PageSetup.Margins.Left; + pageMargins.Right = xlWorksheet.PageSetup.Margins.Right; + pageMargins.Top = xlWorksheet.PageSetup.Margins.Top; + pageMargins.Bottom = xlWorksheet.PageSetup.Margins.Bottom; + pageMargins.Header = xlWorksheet.PageSetup.Margins.Header; + pageMargins.Footer = xlWorksheet.PageSetup.Margins.Footer; + + #endregion PageMargins + + #region PageSetup + + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.PageSetup); + worksheet.InsertAfter(new PageSetup(), previousElement); + } + + var pageSetup = worksheet.Elements().First(); + cm.SetElement(XLWorksheetContents.PageSetup, pageSetup); + + pageSetup.Orientation = xlWorksheet.PageSetup.PageOrientation.ToOpenXml(); + pageSetup.PaperSize = (UInt32)xlWorksheet.PageSetup.PaperSize; + pageSetup.BlackAndWhite = xlWorksheet.PageSetup.BlackAndWhite; + pageSetup.Draft = xlWorksheet.PageSetup.DraftQuality; + pageSetup.PageOrder = xlWorksheet.PageSetup.PageOrder.ToOpenXml(); + pageSetup.CellComments = xlWorksheet.PageSetup.ShowComments.ToOpenXml(); + pageSetup.Errors = xlWorksheet.PageSetup.PrintErrorValue.ToOpenXml(); + + if (xlWorksheet.PageSetup.FirstPageNumber.HasValue) + { + // Negative first page numbers are written as uint, e.g. -1 is 4294967295. + pageSetup.FirstPageNumber = UInt32Value.FromUInt32((uint)xlWorksheet.PageSetup.FirstPageNumber.Value); + pageSetup.UseFirstPageNumber = true; + } + else + { + pageSetup.FirstPageNumber = null; + pageSetup.UseFirstPageNumber = null; + } + + if (xlWorksheet.PageSetup.HorizontalDpi > 0) + pageSetup.HorizontalDpi = (UInt32)xlWorksheet.PageSetup.HorizontalDpi; + else + pageSetup.HorizontalDpi = null; + + if (xlWorksheet.PageSetup.VerticalDpi > 0) + pageSetup.VerticalDpi = (UInt32)xlWorksheet.PageSetup.VerticalDpi; + else + pageSetup.VerticalDpi = null; + + if (xlWorksheet.PageSetup.Scale > 0) + { + pageSetup.Scale = (UInt32)xlWorksheet.PageSetup.Scale; + pageSetup.FitToWidth = null; + pageSetup.FitToHeight = null; + } + else + { + pageSetup.Scale = null; + + if (xlWorksheet.PageSetup.PagesWide >= 0 && xlWorksheet.PageSetup.PagesWide != 1) + pageSetup.FitToWidth = (UInt32)xlWorksheet.PageSetup.PagesWide; + + if (xlWorksheet.PageSetup.PagesTall >= 0 && xlWorksheet.PageSetup.PagesTall != 1) + pageSetup.FitToHeight = (UInt32)xlWorksheet.PageSetup.PagesTall; + } + + // For some reason some Excel files already contains pageSetup.Copies = 0 + // The validation fails for this + // Let's remove the attribute of that's the case. + if ((pageSetup?.Copies ?? 0) <= 0) + pageSetup.Copies = null; + + #endregion PageSetup + + #region HeaderFooter + + var headerFooter = worksheet.Elements().FirstOrDefault(); + if (headerFooter == null) + headerFooter = new HeaderFooter(); + else + worksheet.RemoveAllChildren(); + + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.HeaderFooter); + worksheet.InsertAfter(headerFooter, previousElement); + cm.SetElement(XLWorksheetContents.HeaderFooter, headerFooter); + } + if (((XLHeaderFooter)xlWorksheet.PageSetup.Header).Changed + || ((XLHeaderFooter)xlWorksheet.PageSetup.Footer).Changed) + { + headerFooter.RemoveAllChildren(); + + headerFooter.ScaleWithDoc = xlWorksheet.PageSetup.ScaleHFWithDocument; + headerFooter.AlignWithMargins = xlWorksheet.PageSetup.AlignHFWithMargins; + headerFooter.DifferentFirst = xlWorksheet.PageSetup.DifferentFirstPageOnHF; + headerFooter.DifferentOddEven = xlWorksheet.PageSetup.DifferentOddEvenPagesOnHF; + + var oddHeader = new OddHeader(xlWorksheet.PageSetup.Header.GetText(XLHFOccurrence.OddPages)); + headerFooter.AppendChild(oddHeader); + var oddFooter = new OddFooter(xlWorksheet.PageSetup.Footer.GetText(XLHFOccurrence.OddPages)); + headerFooter.AppendChild(oddFooter); + + var evenHeader = new EvenHeader(xlWorksheet.PageSetup.Header.GetText(XLHFOccurrence.EvenPages)); + headerFooter.AppendChild(evenHeader); + var evenFooter = new EvenFooter(xlWorksheet.PageSetup.Footer.GetText(XLHFOccurrence.EvenPages)); + headerFooter.AppendChild(evenFooter); + + var firstHeader = new FirstHeader(xlWorksheet.PageSetup.Header.GetText(XLHFOccurrence.FirstPage)); + headerFooter.AppendChild(firstHeader); + var firstFooter = new FirstFooter(xlWorksheet.PageSetup.Footer.GetText(XLHFOccurrence.FirstPage)); + headerFooter.AppendChild(firstFooter); + } + + #endregion HeaderFooter + + #region RowBreaks + + var rowBreakCount = xlWorksheet.PageSetup.RowBreaks.Count; + if (rowBreakCount > 0) + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.RowBreaks); + worksheet.InsertAfter(new RowBreaks(), previousElement); + } + + var rowBreaks = worksheet.Elements().First(); + + var existingBreaks = rowBreaks.ChildElements.OfType().ToArray(); + var rowBreaksToDelete = existingBreaks + .Where(rb => !rb.Id.HasValue || + !xlWorksheet.PageSetup.RowBreaks.Contains((int)rb.Id.Value)) + .ToList(); + + foreach (var rb in rowBreaksToDelete) + { + rowBreaks.RemoveChild(rb); + } + + var rowBreaksToAdd = xlWorksheet.PageSetup.RowBreaks + .Where(xlRb => !existingBreaks.Any(rb => rb.Id.HasValue && rb.Id.Value == xlRb)); + + rowBreaks.Count = (UInt32)rowBreakCount; + rowBreaks.ManualBreakCount = (UInt32)rowBreakCount; + var lastRowNum = (UInt32)xlWorksheet.RangeAddress.LastAddress.RowNumber; + foreach (var break1 in rowBreaksToAdd.Select(rb => new Break + { + Id = (UInt32)rb, + Max = lastRowNum, + ManualPageBreak = true + })) + rowBreaks.AppendChild(break1); + cm.SetElement(XLWorksheetContents.RowBreaks, rowBreaks); + } + else + { + worksheet.RemoveAllChildren(); + cm.SetElement(XLWorksheetContents.RowBreaks, null); + } + + #endregion RowBreaks + + #region ColumnBreaks + + var columnBreakCount = xlWorksheet.PageSetup.ColumnBreaks.Count; + if (columnBreakCount > 0) + { + if (!worksheet.Elements().Any()) + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.ColumnBreaks); + worksheet.InsertAfter(new ColumnBreaks(), previousElement); + } + + var columnBreaks = worksheet.Elements().First(); + + var existingBreaks = columnBreaks.ChildElements.OfType().ToArray(); + var columnBreaksToDelete = existingBreaks + .Where(cb => !cb.Id.HasValue || + !xlWorksheet.PageSetup.ColumnBreaks.Contains((int)cb.Id.Value)) + .ToList(); + + foreach (var rb in columnBreaksToDelete) + { + columnBreaks.RemoveChild(rb); + } + + var columnBreaksToAdd = xlWorksheet.PageSetup.ColumnBreaks + .Where(xlCb => !existingBreaks.Any(cb => cb.Id.HasValue && cb.Id.Value == xlCb)); + + columnBreaks.Count = (UInt32)columnBreakCount; + columnBreaks.ManualBreakCount = (UInt32)columnBreakCount; + var maxColumnNumber = (UInt32)xlWorksheet.RangeAddress.LastAddress.ColumnNumber; + foreach (var break1 in columnBreaksToAdd.Select(cb => new Break + { + Id = (UInt32)cb, + Max = maxColumnNumber, + ManualPageBreak = true + })) + columnBreaks.AppendChild(break1); + cm.SetElement(XLWorksheetContents.ColumnBreaks, columnBreaks); + } + else + { + worksheet.RemoveAllChildren(); + cm.SetElement(XLWorksheetContents.ColumnBreaks, null); + } + + #endregion ColumnBreaks + + #region Tables + + PopulateTablePartReferences((XLTables)xlWorksheet.Tables, worksheet, cm); + + #endregion Tables + + #region Drawings + + if (worksheetPart.DrawingsPart != null) + { + var xlPictures = xlWorksheet.Pictures as Drawings.XLPictures; + foreach (var removedPicture in xlPictures.Deleted) + { + worksheetPart.DrawingsPart.DeletePart(removedPicture); + } + xlPictures.Deleted.Clear(); + } + + foreach (var pic in xlWorksheet.Pictures) + { + AddPictureAnchor(worksheetPart, pic, context); + } + + if (xlWorksheet.Pictures.Any()) + RebaseNonVisualDrawingPropertiesIds(worksheetPart); + + var tableParts = worksheet.Elements().First(); + if (xlWorksheet.Pictures.Any() && !worksheet.OfType().Any()) + { + var worksheetDrawing = new Drawing { Id = worksheetPart.GetIdOfPart(worksheetPart.DrawingsPart) }; + worksheetDrawing.AddNamespaceDeclaration("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); + worksheet.InsertBefore(worksheetDrawing, tableParts); + cm.SetElement(XLWorksheetContents.Drawing, worksheet.Elements().First()); + } + + // Instead of saving a file with an empty Drawings.xml file, rather remove the .xml file + var hasCharts = worksheetPart.DrawingsPart is not null && worksheetPart.DrawingsPart.Parts.Any(); + if (worksheetPart.DrawingsPart is not null && // There is a drawing part for the sheet that could be deleted + xlWorksheet.LegacyDrawingId is null && // and sheet doesn't contain any form controls or comments or other shapes + !xlWorksheet.Pictures.Any() && // and also no pictures. + !hasCharts) // and no charts + { + var id = worksheetPart.GetIdOfPart(worksheetPart.DrawingsPart); + worksheet.RemoveChild(worksheet.OfType().FirstOrDefault(p => p.Id == id)); + worksheetPart.DeletePart(worksheetPart.DrawingsPart); + cm.SetElement(XLWorksheetContents.Drawing, null); + } + + #endregion Drawings + + #region LegacyDrawing + + // Does worksheet have any comments (stored in legacy VML drawing) + if (!String.IsNullOrEmpty(xlWorksheet.LegacyDrawingId)) + { + worksheet.RemoveAllChildren(); + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.LegacyDrawing); + worksheet.InsertAfter(new LegacyDrawing { Id = xlWorksheet.LegacyDrawingId }, + previousElement); + + cm.SetElement(XLWorksheetContents.LegacyDrawing, worksheet.Elements().First()); + } + else + { + worksheet.RemoveAllChildren(); + cm.SetElement(XLWorksheetContents.LegacyDrawing, null); + } + + #endregion LegacyDrawing + + #region LegacyDrawingHeaderFooter + + //LegacyDrawingHeaderFooter legacyHeaderFooter = worksheetPart.Worksheet.Elements().FirstOrDefault(); + //if (legacyHeaderFooter != null) + //{ + // worksheetPart.Worksheet.RemoveAllChildren(); + // { + // var previousElement = cm.GetPreviousElementFor(XLWSContentManager.XLWSContents.LegacyDrawingHeaderFooter); + // worksheetPart.Worksheet.InsertAfter(new LegacyDrawingHeaderFooter { Id = xlWorksheet.LegacyDrawingId }, + // previousElement); + // } + //} + + #endregion LegacyDrawingHeaderFooter + + return worksheet; + } + + private static void WriteCellValue(XmlWriter w, XLCell xlCell, SaveContext context) + { + var dataType = xlCell.DataType; + if (dataType == XLDataType.Blank) + return; + + if (dataType == XLDataType.Text) + { + var text = xlCell.GetText(); + if (xlCell.HasFormula) + { + WriteStringValue(w, text); + } + else + { + if (xlCell.ShareString) + { + var sharedStringId = context.GetSharedStringId(xlCell, text); + w.WriteStartElement("v", Main2006SsNs); + w.WriteValue(sharedStringId); + w.WriteEndElement(); + } + else + { + w.WriteStartElement("is", Main2006SsNs); + var richText = xlCell.RichText; + if (richText is not null) + { + TextSerializer.WriteRichTextElements(w, richText, context); + } + else + { + w.WriteStartElement("t", Main2006SsNs); + if (text.PreserveSpaces()) + w.WritePreserveSpaceAttr(); + + w.WriteString(text); + w.WriteEndElement(); + } + + w.WriteEndElement(); // is + } + } + } + else if (dataType == XLDataType.TimeSpan) + { + WriteNumberValue(w, xlCell.Value.GetUnifiedNumber()); + } + else if (dataType == XLDataType.Number) + { + WriteNumberValue(w, xlCell.Value.GetNumber()); + } + else if (dataType == XLDataType.DateTime) + { + // OpenXML SDK validator requires a specific format, in addition to the spec, but can reads many more + var date = xlCell.GetDateTime(); + if (xlCell.Worksheet.Workbook.Use1904DateSystem) + date = date.AddDays(-1462); + + WriteNumberValue(w, date.ToSerialDateTime()); + } + else if (dataType == XLDataType.Boolean) + { + WriteStringValue(w, xlCell.GetBoolean() ? TrueValue : FalseValue); + } + else if (dataType == XLDataType.Error) + { + WriteStringValue(w, xlCell.Value.GetError().ToDisplayString()); + } + else + { + throw new InvalidOperationException(); + } + + static void WriteStringValue(XmlWriter w, String text) + { + w.WriteStartElement("v", Main2006SsNs); + w.WriteString(text); + w.WriteEndElement(); + } + + static void WriteNumberValue(XmlWriter w, Double value) + { + w.WriteStartElement("v", Main2006SsNs); + w.WriteNumberValue(value); + w.WriteEndElement(); + } + } + + internal static void PopulateAutoFilter(XLAutoFilter xlAutoFilter, AutoFilter autoFilter) + { + var filterRange = xlAutoFilter.Range; + autoFilter.Reference = filterRange.RangeAddress.ToString(); + + foreach (var (columnNumber, xlFilterColumn) in xlAutoFilter.Columns) + { + var filterColumn = new FilterColumn { ColumnId = (UInt32)columnNumber - 1 }; + + switch (xlFilterColumn.FilterType) + { + case XLFilterType.Custom: + var customFilters = new CustomFilters(); + foreach (var xlFilter in xlFilterColumn) + { + // Since OOXML allows only string, the operand for custom filter must be serialized. + var filterValue = xlFilter.CustomValue.ToString(CultureInfo.InvariantCulture); + var customFilter = new CustomFilter { Val = filterValue }; + + if (xlFilter.Operator != XLFilterOperator.Equal) + customFilter.Operator = xlFilter.Operator.ToOpenXml(); + + if (xlFilter.Connector == XLConnector.And) + customFilters.And = true; + + customFilters.Append(customFilter); + } + filterColumn.Append(customFilters); + break; + + case XLFilterType.TopBottom: + // Although there is FilterValue attribute, populating it seems like more + // trouble than it's worth due to consistency issues. It's optional, so we + // can't rely on it during load anyway. + var top101 = new Top10 + { + Val = xlFilterColumn.TopBottomValue, + Percent = OpenXmlHelper.GetBooleanValue(xlFilterColumn.TopBottomType == XLTopBottomType.Percent, false), + Top = OpenXmlHelper.GetBooleanValue(xlFilterColumn.TopBottomPart == XLTopBottomPart.Top, true) + }; + filterColumn.Append(top101); + break; + + case XLFilterType.Dynamic: + var dynamicFilter = new DynamicFilter + { + Type = xlFilterColumn.DynamicType.ToOpenXml(), + Val = xlFilterColumn.DynamicValue + }; + filterColumn.Append(dynamicFilter); + break; + + case XLFilterType.Regular: + var filters = new Filters(); + foreach (var filter in xlFilterColumn) + { + if (filter.Value is string s) + filters.Append(new Filter { Val = s }); + } + + foreach (var filter in xlFilterColumn) + { + if (filter.Value is DateTime) + { + var d = (DateTime)filter.Value; + var dgi = new DateGroupItem + { + Year = (UInt16)d.Year, + DateTimeGrouping = filter.DateTimeGrouping.ToOpenXml() + }; + + if (filter.DateTimeGrouping >= XLDateTimeGrouping.Month) dgi.Month = (UInt16)d.Month; + if (filter.DateTimeGrouping >= XLDateTimeGrouping.Day) dgi.Day = (UInt16)d.Day; + if (filter.DateTimeGrouping >= XLDateTimeGrouping.Hour) dgi.Hour = (UInt16)d.Hour; + if (filter.DateTimeGrouping >= XLDateTimeGrouping.Minute) dgi.Minute = (UInt16)d.Minute; + if (filter.DateTimeGrouping >= XLDateTimeGrouping.Second) dgi.Second = (UInt16)d.Second; + + filters.Append(dgi); + } + } + + filterColumn.Append(filters); + break; + + default: + throw new NotSupportedException(); + } + autoFilter.Append(filterColumn); + } + + if (xlAutoFilter.Sorted) + { + string reference = null; + + if (filterRange.FirstCell().Address.RowNumber < filterRange.LastCell().Address.RowNumber) + reference = filterRange.Range(filterRange.FirstCell().CellBelow(), filterRange.LastCell()).RangeAddress.ToString(); + else + reference = filterRange.RangeAddress.ToString(); + + var sortState = new SortState + { + Reference = reference + }; + + var sortCondition = new SortCondition + { + Reference = + filterRange.Range(1, xlAutoFilter.SortColumn, filterRange.RowCount(), + xlAutoFilter.SortColumn).RangeAddress.ToString() + }; + if (xlAutoFilter.SortOrder == XLSortOrder.Descending) + sortCondition.Descending = true; + + sortState.Append(sortCondition); + autoFilter.Append(sortState); + } + } + + private static void CollapseColumns(Columns columns, Dictionary sheetColumns) + { + UInt32 lastMin = 1; + var count = sheetColumns.Count; + var arr = sheetColumns.OrderBy(kp => kp.Key).ToArray(); + // sheetColumns[kp.Key + 1] + //Int32 i = 0; + //foreach (KeyValuePair kp in arr + // //.Where(kp => !(kp.Key < count && ColumnsAreEqual(kp.Value, ))) + // ) + for (var i = 0; i < count; i++) + { + var kp = arr[i]; + if (i + 1 != count && ColumnsAreEqual(kp.Value, arr[i + 1].Value)) continue; + + var newColumn = (Column)kp.Value.CloneNode(true); + newColumn.Min = lastMin; + var newColumnMax = newColumn.Max.Value; + var columnsToRemove = + columns.Elements().Where(co => co.Min >= lastMin && co.Max <= newColumnMax). + Select(co => co).ToList(); + columnsToRemove.ForEach(c => columns.RemoveChild(c)); + + columns.AppendChild(newColumn); + lastMin = kp.Key + 1; + //i++; + } + } + + private static double GetColumnWidth(double columnWidth) + { + return Math.Min(255.0, Math.Max(0.0, columnWidth + XLConstants.ColumnWidthOffset)); + } + + private static void UpdateColumn(Column column, Columns columns, Dictionary sheetColumnsByMin) + { + if (!sheetColumnsByMin.TryGetValue(column.Min.Value, out Column newColumn)) + { + newColumn = (Column)column.CloneNode(true); + columns.AppendChild(newColumn); + sheetColumnsByMin.Add(column.Min.Value, newColumn); + } + else + { + var existingColumn = sheetColumnsByMin[column.Min.Value]; + newColumn = (Column)existingColumn.CloneNode(true); + newColumn.Min = column.Min; + newColumn.Max = column.Max; + newColumn.Style = column.Style; + newColumn.Width = column.Width.SaveRound(); + newColumn.CustomWidth = column.CustomWidth; + + if (column.Hidden != null) + newColumn.Hidden = true; + else + newColumn.Hidden = null; + + if (column.Collapsed != null) + newColumn.Collapsed = true; + else + newColumn.Collapsed = null; + + if (column.OutlineLevel != null && column.OutlineLevel > 0) + newColumn.OutlineLevel = (byte)column.OutlineLevel; + else + newColumn.OutlineLevel = null; + + sheetColumnsByMin.Remove(column.Min.Value); + if (existingColumn.Min + 1 > existingColumn.Max) + { + //existingColumn.Min = existingColumn.Min + 1; + //columns.InsertBefore(existingColumn, newColumn); + //existingColumn.Remove(); + columns.RemoveChild(existingColumn); + columns.AppendChild(newColumn); + sheetColumnsByMin.Add(newColumn.Min.Value, newColumn); + } + else + { + //columns.InsertBefore(existingColumn, newColumn); + columns.AppendChild(newColumn); + sheetColumnsByMin.Add(newColumn.Min.Value, newColumn); + existingColumn.Min = existingColumn.Min + 1; + sheetColumnsByMin.Add(existingColumn.Min.Value, existingColumn); + } + } + } + + private static bool ColumnsAreEqual(Column left, Column right) + { + return + ((left.Style == null && right.Style == null) + || (left.Style != null && right.Style != null && left.Style.Value == right.Style.Value)) + && ((left.Width == null && right.Width == null) + || (left.Width != null && right.Width != null && (Math.Abs(left.Width.Value - right.Width.Value) < XLHelper.Epsilon))) + && ((left.Hidden == null && right.Hidden == null) + || (left.Hidden != null && right.Hidden != null && left.Hidden.Value == right.Hidden.Value)) + && ((left.Collapsed == null && right.Collapsed == null) + || + (left.Collapsed != null && right.Collapsed != null && left.Collapsed.Value == right.Collapsed.Value)) + && ((left.OutlineLevel == null && right.OutlineLevel == null) + || + (left.OutlineLevel != null && right.OutlineLevel != null && + left.OutlineLevel.Value == right.OutlineLevel.Value)); + } + + // http://polymathprogrammer.com/2009/10/22/english-metric-units-and-open-xml/ + // http://archive.oreilly.com/pub/post/what_is_an_emu.html + // https://en.wikipedia.org/wiki/Office_Open_XML_file_formats#DrawingML + private static Int64 ConvertToEnglishMetricUnits(Int32 pixels, Double resolution) + { + return Convert.ToInt64(914400L * pixels / resolution); + } + + private static void AddPictureAnchor(WorksheetPart worksheetPart, Drawings.IXLPicture picture, SaveContext context) + { + var pic = picture as Drawings.XLPicture; + var drawingsPart = worksheetPart.DrawingsPart ?? + worksheetPart.AddNewPart(context.RelIdGenerator.GetNext(RelType.Workbook)); + + if (drawingsPart.WorksheetDrawing == null) + drawingsPart.WorksheetDrawing = new Xdr.WorksheetDrawing(); + + var worksheetDrawing = drawingsPart.WorksheetDrawing; + + // Add namespaces + if (!worksheetDrawing.NamespaceDeclarations.Any(nd => nd.Value.Equals("http://schemas.openxmlformats.org/drawingml/2006/main"))) + worksheetDrawing.AddNamespaceDeclaration("a", "http://schemas.openxmlformats.org/drawingml/2006/main"); + + if (!worksheetDrawing.NamespaceDeclarations.Any(nd => nd.Value.Equals("http://schemas.openxmlformats.org/officeDocument/2006/relationships"))) + worksheetDrawing.AddNamespaceDeclaration("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); + ///////// + + // Overwrite actual image binary data + ImagePart imagePart; + if (drawingsPart.HasPartWithId(pic.RelId)) + imagePart = drawingsPart.GetPartById(pic.RelId) as ImagePart; + else + { + pic.RelId = context.RelIdGenerator.GetNext(RelType.Workbook); + imagePart = drawingsPart.AddImagePart(pic.Format.ToOpenXml(), pic.RelId); + } + + using (var stream = new MemoryStream()) + { + pic.ImageStream.Position = 0; + pic.ImageStream.CopyTo(stream); + stream.Seek(0, SeekOrigin.Begin); + imagePart.FeedData(stream); + } + ///////// + + // Clear current anchors + var existingAnchor = GetAnchorFromImageId(drawingsPart, pic.RelId); + + var wb = pic.Worksheet.Workbook; + var extentsCx = ConvertToEnglishMetricUnits(pic.Width, wb.DpiX); + var extentsCy = ConvertToEnglishMetricUnits(pic.Height, wb.DpiY); + + var nvps = worksheetDrawing.Descendants(); + var nvpId = nvps.Any() ? + (UInt32Value)worksheetDrawing.Descendants().Max(p => p.Id.Value) + 1 : + 1U; + + Xdr.FromMarker fMark; + Xdr.ToMarker tMark; + switch (pic.Placement) + { + case Drawings.XLPicturePlacement.FreeFloating: + var absoluteAnchor = new Xdr.AbsoluteAnchor( + new Xdr.Position + { + X = ConvertToEnglishMetricUnits(pic.Left, wb.DpiX), + Y = ConvertToEnglishMetricUnits(pic.Top, wb.DpiY) + }, + new Xdr.Extent + { + Cx = extentsCx, + Cy = extentsCy + }, + new Xdr.Picture( + new Xdr.NonVisualPictureProperties( + new Xdr.NonVisualDrawingProperties { Id = nvpId, Name = pic.Name }, + new Xdr.NonVisualPictureDrawingProperties(new PictureLocks { NoChangeAspect = true }) + ), + new Xdr.BlipFill( + new Blip { Embed = drawingsPart.GetIdOfPart(imagePart), CompressionState = BlipCompressionValues.Print }, + new Stretch(new FillRectangle()) + ), + new Xdr.ShapeProperties( + new Transform2D( + new Offset { X = 0, Y = 0 }, + new Extents { Cx = extentsCx, Cy = extentsCy } + ), + new PresetGeometry { Preset = ShapeTypeValues.Rectangle } + ) + ), + new Xdr.ClientData() + ); + + AttachAnchor(absoluteAnchor, existingAnchor); + break; + + case Drawings.XLPicturePlacement.MoveAndSize: + var moveAndSizeFromMarker = pic.Markers[Drawings.XLMarkerPosition.TopLeft]; + if (moveAndSizeFromMarker == null) moveAndSizeFromMarker = new Drawings.XLMarker(picture.Worksheet.Cell("A1")); + fMark = new Xdr.FromMarker + { + ColumnId = new Xdr.ColumnId((moveAndSizeFromMarker.ColumnNumber - 1).ToInvariantString()), + RowId = new Xdr.RowId((moveAndSizeFromMarker.RowNumber - 1).ToInvariantString()), + ColumnOffset = new Xdr.ColumnOffset(ConvertToEnglishMetricUnits(moveAndSizeFromMarker.Offset.X, wb.DpiX).ToInvariantString()), + RowOffset = new Xdr.RowOffset(ConvertToEnglishMetricUnits(moveAndSizeFromMarker.Offset.Y, wb.DpiY).ToInvariantString()) + }; + + var moveAndSizeToMarker = pic.Markers[Drawings.XLMarkerPosition.BottomRight]; + if (moveAndSizeToMarker == null) moveAndSizeToMarker = new Drawings.XLMarker(picture.Worksheet.Cell("A1"), new System.Drawing.Point(picture.Width, picture.Height)); + tMark = new Xdr.ToMarker + { + ColumnId = new Xdr.ColumnId((moveAndSizeToMarker.ColumnNumber - 1).ToInvariantString()), + RowId = new Xdr.RowId((moveAndSizeToMarker.RowNumber - 1).ToInvariantString()), + ColumnOffset = new Xdr.ColumnOffset(ConvertToEnglishMetricUnits(moveAndSizeToMarker.Offset.X, wb.DpiX).ToInvariantString()), + RowOffset = new Xdr.RowOffset(ConvertToEnglishMetricUnits(moveAndSizeToMarker.Offset.Y, wb.DpiY).ToInvariantString()) + }; + + var twoCellAnchor = new Xdr.TwoCellAnchor( + fMark, + tMark, + new Xdr.Picture( + new Xdr.NonVisualPictureProperties( + new Xdr.NonVisualDrawingProperties { Id = nvpId, Name = pic.Name }, + new Xdr.NonVisualPictureDrawingProperties(new PictureLocks { NoChangeAspect = true }) + ), + new Xdr.BlipFill( + new Blip { Embed = drawingsPart.GetIdOfPart(imagePart), CompressionState = BlipCompressionValues.Print }, + new Stretch(new FillRectangle()) + ), + new Xdr.ShapeProperties( + new Transform2D( + new Offset { X = 0, Y = 0 }, + new Extents { Cx = extentsCx, Cy = extentsCy } + ), + new PresetGeometry { Preset = ShapeTypeValues.Rectangle } + ) + ), + new Xdr.ClientData() + ); + + AttachAnchor(twoCellAnchor, existingAnchor); + break; + + case Drawings.XLPicturePlacement.Move: + var moveFromMarker = pic.Markers[Drawings.XLMarkerPosition.TopLeft]; + if (moveFromMarker == null) moveFromMarker = new Drawings.XLMarker(picture.Worksheet.Cell("A1")); + fMark = new Xdr.FromMarker + { + ColumnId = new Xdr.ColumnId((moveFromMarker.ColumnNumber - 1).ToInvariantString()), + RowId = new Xdr.RowId((moveFromMarker.RowNumber - 1).ToInvariantString()), + ColumnOffset = new Xdr.ColumnOffset(ConvertToEnglishMetricUnits(moveFromMarker.Offset.X, wb.DpiX).ToInvariantString()), + RowOffset = new Xdr.RowOffset(ConvertToEnglishMetricUnits(moveFromMarker.Offset.Y, wb.DpiY).ToInvariantString()) + }; + + var oneCellAnchor = new Xdr.OneCellAnchor( + fMark, + new Xdr.Extent + { + Cx = extentsCx, + Cy = extentsCy + }, + new Xdr.Picture( + new Xdr.NonVisualPictureProperties( + new Xdr.NonVisualDrawingProperties { Id = nvpId, Name = pic.Name }, + new Xdr.NonVisualPictureDrawingProperties(new PictureLocks { NoChangeAspect = true }) + ), + new Xdr.BlipFill( + new Blip { Embed = drawingsPart.GetIdOfPart(imagePart), CompressionState = BlipCompressionValues.Print }, + new Stretch(new FillRectangle()) + ), + new Xdr.ShapeProperties( + new Transform2D( + new Offset { X = 0, Y = 0 }, + new Extents { Cx = extentsCx, Cy = extentsCy } + ), + new PresetGeometry { Preset = ShapeTypeValues.Rectangle } + ) + ), + new Xdr.ClientData() + ); + + AttachAnchor(oneCellAnchor, existingAnchor); + break; + } + + void AttachAnchor(OpenXmlElement pictureAnchor, OpenXmlElement existingAnchor) + { + if (existingAnchor is not null) + { + worksheetDrawing.ReplaceChild(pictureAnchor, existingAnchor); + } + else + { + worksheetDrawing.Append(pictureAnchor); + } + } + } + + private static void RebaseNonVisualDrawingPropertiesIds(WorksheetPart worksheetPart) + { + var worksheetDrawing = worksheetPart.DrawingsPart.WorksheetDrawing; + + var toRebase = worksheetDrawing.Descendants() + .ToList(); + + toRebase.ForEach(nvdpr => nvdpr.Id = Convert.ToUInt32(toRebase.IndexOf(nvdpr) + 1)); + } + + private static void PopulateTablePartReferences(XLTables xlTables, Worksheet worksheet, XLWorksheetContentManager cm) + { + var emptyTable = xlTables.FirstOrDefault(t => t.DataRange is null); + if (emptyTable != null) + throw new EmptyTableException($"Table '{emptyTable.Name}' should have at least 1 row."); + + TableParts tableParts; + if (worksheet.Elements().Any()) + { + tableParts = worksheet.Elements().First(); + } + else + { + var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.TableParts); + tableParts = new TableParts(); + worksheet.InsertAfter(tableParts, previousElement); + } + cm.SetElement(XLWorksheetContents.TableParts, tableParts); + + xlTables.Deleted.Clear(); + tableParts.RemoveAllChildren(); + foreach (var xlTable in xlTables.Cast()) + { + tableParts.AppendChild(new TablePart { Id = xlTable.RelId }); + } + + tableParts.Count = (UInt32)xlTables.Count(); + } + + /// + /// Stream detached worksheet DOM to the worksheet part stream. + /// Replaces the content of the part. + /// + private static void StreamToPart(Worksheet worksheet, WorksheetPart worksheetPart, XLWorksheet xlWorksheet, SaveContext context, SaveOptions options) + { + // Worksheet part might have some data, but the writer truncates everything upon creation. + using var writer = OpenXmlWriter.Create(worksheetPart); + using var reader = OpenXmlReader.Create(worksheet); + + writer.WriteStartDocument(true); + + while (reader.Read()) + { + if (reader.ElementType == typeof(SheetData)) + { + StreamSheetData(writer, xlWorksheet, context, options); + + // Skip whole SheetData elements from original file, already written + reader.Skip(); + } + + if (reader.IsStartElement) + { + writer.WriteStartElement(reader); + var canContainText = typeof(OpenXmlLeafTextElement).IsAssignableFrom(reader.ElementType); + if (canContainText) + { + var text = reader.GetText(); + if (text.Length > 0) + { + writer.WriteString(text); + } + } + } + else if (reader.IsEndElement) + { + writer.WriteEndElement(); + } + } + writer.Close(); + } + + private static void StreamSheetData(OpenXmlWriter writer, XLWorksheet xlWorksheet, SaveContext context, SaveOptions options) + { + // Steal through reflection for now, whole OpenXmlPartWriter will be replaced by XmlWriter soon. OpenXmlPartWriter has basically + // no inner state, unless it is in a string leaf node. By writing SheetData through XmlWriter only, we bypass all that. + var xmlWriterFieldInfo = typeof(OpenXmlPartWriter).GetField("_xmlWriter", BindingFlags.Instance | BindingFlags.NonPublic)!; + var untypedXmlWriter = xmlWriterFieldInfo.GetValue(writer); + var xml = (XmlWriter)untypedXmlWriter; + + var maxColumn = GetMaxColumn(xlWorksheet); + + xml.WriteStartElement("sheetData", Main2006SsNs); + + var tableTotalCells = new HashSet( + xlWorksheet.Tables + .Where(table => table.ShowTotalsRow) + .SelectMany(table => + table.TotalsRow().CellsUsed()) + .Select(cell => cell.Address)); + + // A rather complicated state machine, so rows and cells can be written in a single loop + var openedRowNumber = 0; + var isRowOpened = false; + var cellRef = new char[10]; // Buffer, must be enough to hold span and rowNumber as strings + var rows = xlWorksheet.Internals.RowsCollection.Keys.ToList(); + rows.Sort(); + var rowPropIndex = 0; + uint rowStyleId = 0; + foreach (var xlCell in xlWorksheet.Internals.CellsCollection.GetCells()) + { + var currentRowNumber = xlCell.SheetPoint.Row; + + // A space between cells can have several rows that don't contain cells, + // but have custom properties (e.g. height). Write them out. + while (rowPropIndex < rows.Count && rows[rowPropIndex] < currentRowNumber) + { + if (isRowOpened) + { + xml.WriteEndElement(); // row + isRowOpened = false; + } + + var rowNumber = rows[rowPropIndex]; + var xlRow = xlWorksheet.Internals.RowsCollection[rowNumber]; + if (RowHasCustomProps(xlRow)) + { + WriteStartRow(xml, xlRow, rowNumber, maxColumn, context); + + isRowOpened = true; + openedRowNumber = rowNumber; + } + + rowPropIndex++; + } + + // For saving cells to file, ignore conditional formatting, data validation rules and merged + // ranges. They just bloat the file + var isEmpty = xlCell.CachedValue.Type == XLDataType.Blank && + xlCell.IsEmpty(XLCellsUsedOptions.All + & ~XLCellsUsedOptions.ConditionalFormats + & ~XLCellsUsedOptions.DataValidation + & ~XLCellsUsedOptions.MergedRanges); + + if (isEmpty) + continue; + + if (openedRowNumber != currentRowNumber) + { + if (isRowOpened) + xml.WriteEndElement(); // row + + if (xlWorksheet.Internals.RowsCollection.TryGetValue(currentRowNumber, out var row)) + { + rowPropIndex++; + rowStyleId = context.SharedStyles[row.StyleValue].StyleId; + } + else + { + rowStyleId = 0; + } + + WriteStartRow(xml, row, currentRowNumber, maxColumn, context); + + isRowOpened = true; + openedRowNumber = currentRowNumber; + } + + WriteCell(xml, xlCell, cellRef, context, options, tableTotalCells, rowStyleId); + } + + if (isRowOpened) + xml.WriteEndElement(); // row + + // Write rows with custom properties after last cell. + while (rowPropIndex < rows.Count) + { + var rowNumber = rows[rowPropIndex]; + var xlRow = xlWorksheet.Internals.RowsCollection[rowNumber]; + if (RowHasCustomProps(xlRow)) + { + WriteStartRow(xml, xlRow, rowNumber, 0, context); + xml.WriteEndElement(); // row + } + + rowPropIndex++; + } + + xml.WriteEndElement(); // SheetData + + static bool RowHasCustomProps(XLRow xlRow) + { + return xlRow.HeightChanged || + xlRow.IsHidden || + xlRow.StyleValue != xlRow.Worksheet.StyleValue || + xlRow.Collapsed || + xlRow.OutlineLevel > 0; + } + + static void WriteStartRow(XmlWriter w, XLRow xlRow, int rowNumber, int maxColumn, SaveContext context) + { + w.WriteStartElement("row", Main2006SsNs); + + w.WriteStartAttribute("r"); + w.WriteValue(rowNumber); + w.WriteEndAttribute(); + + if (maxColumn > 0) + { + w.WriteStartAttribute("spans"); + w.WriteString("1:"); + w.WriteValue(maxColumn); + w.WriteEndAttribute(); + } + + if (xlRow is null) + return; + + if (xlRow.HeightChanged) + { + var height = xlRow.Height.SaveRound(); + w.WriteStartAttribute("ht"); + w.WriteNumberValue(height); + w.WriteEndAttribute(); + + // Note that dyDescent automatically implies custom height + w.WriteAttributeString("customHeight", TrueValue); + } + + if (xlRow.IsHidden) + { + w.WriteAttributeString("hidden", TrueValue); + } + + if (xlRow.StyleValue != xlRow.Worksheet.StyleValue) + { + var styleIndex = context.SharedStyles[xlRow.StyleValue].StyleId; + w.WriteAttribute("s", styleIndex); + w.WriteAttributeString("customFormat", TrueValue); + } + + if (xlRow.Collapsed) + { + w.WriteAttributeString("collapsed", TrueValue); + } + + if (xlRow.OutlineLevel > 0) + { + w.WriteAttribute("outlineLevel", xlRow.OutlineLevel); + } + + if (xlRow.ShowPhonetic) + { + w.WriteAttributeString("ph", TrueValue); + } + + if (xlRow.DyDescent is not null) + { + w.WriteAttribute("dyDescent", X14Ac2009SsNs, xlRow.DyDescent.Value); + } + + // thickBot and thickTop attributes are not written, because Excel seems to determine adjustments + // from cell borders on its own and it would be rather costly to check each cell in each row. + // If row was adjusted when cell had it's border modified, then it would be fine to write + // the thickBot/thickBot attributes. + } + + static void WriteStartCell(XmlWriter w, XLCell xlCell, Char[] reference, int referenceLength, String dataType, UInt32 styleId) + { + w.WriteStartElement("c", Main2006SsNs); + + w.WriteStartAttribute("r"); + w.WriteRaw(reference, 0, referenceLength); + w.WriteEndAttribute(); + + // TODO: if (styleId != 0) Test files have style even for 0, fix later + w.WriteAttribute("s", styleId); + + if (dataType is not null) + w.WriteAttributeString("t", dataType); + + if (xlCell.ShowPhonetic) + w.WriteAttributeString("ph", TrueValue); + + if (xlCell.CellMetaIndex is not null) + w.WriteAttribute("cm", xlCell.CellMetaIndex.Value); + + if (xlCell.ValueMetaIndex is not null) + w.WriteAttribute("vm", xlCell.ValueMetaIndex.Value); + } + + static void WriteCell(XmlWriter xml, XLCell xlCell, char[] cellRef, SaveContext context, SaveOptions options, HashSet tableTotalCells, uint rowStyleId) + { + var styleId = context.SharedStyles[xlCell.StyleValue].StyleId; + + Span cellRefSpan = cellRef; + var cellRefLen = xlCell.SheetPoint.Format(cellRefSpan); + + if (xlCell.HasFormula) + { + String dataType = null; + if (options.EvaluateFormulasBeforeSaving) + { + try + { + xlCell.Evaluate(false); + dataType = FormulaDataType[(int)xlCell.DataType]; + } + catch + { + // Do nothing, cell will be left blank. Unimplemented features or functions would stop trying to save a file. + } + } + + WriteStartCell(xml, xlCell, cellRef, cellRefLen, dataType, styleId); + + var xlFormula = xlCell.Formula; + if (xlFormula.Type == FormulaType.DataTable) + { + // Data table doesn't write actual text of formula, that is referenced by context + xml.WriteStartElement("f", Main2006SsNs); + xml.WriteAttributeString("t", "dataTable"); + xml.WriteAttributeString("ref", xlFormula.Range.ToString()); + + var is2D = xlFormula.Is2DDataTable; + if (is2D) + xml.WriteAttributeString("dt2D", TrueValue); + + var isDataRowTable = xlFormula.IsRowDataTable; + if (isDataRowTable) + xml.WriteAttributeString("dtr", TrueValue); + + xml.WriteAttributeString("r1", xlFormula.Input1.ToString()); + var input1Deleted = xlFormula.Input1Deleted; + if (input1Deleted) + xml.WriteAttributeString("del1", TrueValue); + + if (is2D) + xml.WriteAttributeString("r2", xlFormula.Input2.ToString()); + + var input2Deleted = xlFormula.Input2Deleted; + if (input2Deleted) + xml.WriteAttributeString("del2", TrueValue); + + // Excel doesn't recalculate table formula on load or on click of a button or any kind of forced recalculation. + // It is necessary to mark some precedent formula dirty (e.g. edit cell formula and enter in Excel). + // By setting the CalculateCell, we ensure that Excel will calculate values of data table formula on load and + // user will see correct values. + xml.WriteAttributeString("ca", TrueValue); + + xml.WriteEndElement(); // f + } + else if (xlCell.HasArrayFormula) + { + var isMasterCell = xlCell.Formula.Range.FirstPoint == xlCell.SheetPoint; + if (isMasterCell) + { + xml.WriteStartElement("f", Main2006SsNs); + xml.WriteAttributeString("t", "array"); + xml.WriteAttributeString("ref", xlCell.FormulaReference.ToStringRelative()); + xml.WriteString(xlCell.FormulaA1); + xml.WriteEndElement(); // f + } + } + else + { + xml.WriteStartElement("f", Main2006SsNs); + xml.WriteString(xlCell.FormulaA1); + xml.WriteEndElement(); // f + } + + if (options.EvaluateFormulasBeforeSaving && xlCell.CachedValue.Type != XLDataType.Blank && !xlCell.NeedsRecalculation) + { + WriteCellValue(xml, xlCell, context); + } + + xml.WriteEndElement(); // cell + } + else if (tableTotalCells.Contains(xlCell.Address)) + { + var table = xlCell.Worksheet.Tables.First(t => t.AsRange().Contains(xlCell)); + var field = (XLTableField)table.Fields.First(f => f.Column.ColumnNumber() == xlCell.Address.ColumnNumber); + + // If this is a cell in the totals row that contains a label (xor with function), write label + // Only label can be written. Total functions are basically formulas that use structured + // references and SR are not yet supported, so not yet possible to calculate total values. + if (!String.IsNullOrWhiteSpace(field.TotalsRowLabel)) + { + // Excel requires that table totals row label attribute in tableColumn must match the cell + // string from SST. If they don't match, Excel will consider it a corrupt workbook. + var sharedStringId = context.GetSharedStringId(xlCell, field.TotalsRowLabel); + WriteStartCell(xml, xlCell, cellRef, cellRefLen, "s", styleId); + xml.WriteStartElement("v", Main2006SsNs); + xml.WriteValue(sharedStringId); + xml.WriteEndElement(); + } + xml.WriteEndElement(); // cell + } + else if (xlCell.DataType != XLDataType.Blank) + { + // Cell contains only a value + var dataType = GetCellValueType(xlCell); + WriteStartCell(xml, xlCell, cellRef, cellRefLen, dataType, styleId); + + WriteCellValue(xml, xlCell, context); + xml.WriteEndElement(); // cell + } + else if (rowStyleId != styleId) + { + // Cell is blank and should be written only if it has different style from parent. + // Non-written cells use inherited style of a row. + WriteStartCell(xml, xlCell, cellRef, cellRefLen, null, styleId); + xml.WriteEndElement(); // cell + } + } + } + + /// + /// An array to convert data type for a formula cell. Key is . + /// It saves some performance through direct indexation instead of switch. + /// + private static readonly String[] FormulaDataType = + { + null, // blank + "b", // boolean + null, // number, default value, no need to save type + "str", // text, formula can only save this type, no inline or shared string + "e", // error + null, // datetime, saved as serialized date-time + null // timespan, saved as serialized date-time + }; + + /// + /// An array to convert data type for a cell that only contains a value. Key is . + /// It saves some performance through direct indexation instead of switch. + /// + private static readonly String[] ValueDataType = + { + null, // blank + "b", // boolean + null, // number, default value, no need to save type + "s", // text, the default is a shared string, but there also can be inline string depending on ShareString property + "e", // error + null, // datetime, saved as serialized date-time + null // timespan, saved as serialized date-time + }; + + private static String GetCellValueType(XLCell xlCell) + { + var dataType = xlCell.DataType; + if (dataType == XLDataType.Text && !xlCell.ShareString) + return "inlineStr"; + return ValueDataType[(int)dataType]; + } + + private static Int32 GetMaxColumn(XLWorksheet xlWorksheet) + { + var maxColumn = 0; + + if (!xlWorksheet.Internals.CellsCollection.IsEmpty) + { + maxColumn = xlWorksheet.Internals.CellsCollection.MaxColumnUsed; + } + + if (xlWorksheet.Internals.ColumnsCollection.Count > 0) + { + var maxColCollection = xlWorksheet.Internals.ColumnsCollection.Keys.Max(); + if (maxColCollection > maxColumn) + maxColumn = maxColCollection; + } + + return maxColumn; + } + } +} diff --git a/ClosedXML/Excel/IO/XmlToEnumMapper.cs b/ClosedXML/Excel/IO/XmlToEnumMapper.cs new file mode 100644 index 000000000..e16766905 --- /dev/null +++ b/ClosedXML/Excel/IO/XmlToEnumMapper.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using ClosedXML.IO; + +namespace ClosedXML.Excel.IO; + +/// +/// A universal two-way mapper of string representation of an enum value in the OOXML to ClosedXML enum. +/// +internal sealed class XmlToEnumMapper : IEnumMapper +{ + /// + /// A collection of all maps. The key is enum type, the value is Dictionary<string,SomeEnum> + /// Value can't be typed due to generic limitations (no common ancestor). + /// + private readonly Dictionary _textToEnumMaps; + + private static readonly Lazy LazyInstance = new(CreateSpreadsheetMapper); + + internal static XmlToEnumMapper Instance => LazyInstance.Value; + + private XmlToEnumMapper(Dictionary maps) + { + _textToEnumMaps = maps; + } + + public bool TryGetEnum(string text, out TEnum enumValue) + where TEnum : struct, Enum + { + var enumMap = (Dictionary)_textToEnumMaps[typeof(TEnum)]; + return enumMap.TryGetValue(text, out enumValue); + } + + private static XmlToEnumMapper CreateSpreadsheetMapper() + { + var builder = new Builder(); + + // ST_FontScheme + builder.Add(new Dictionary + { + { "none", XLFontScheme.None }, + { "major", XLFontScheme.Major }, + { "minor", XLFontScheme.Minor }, + }); + + // ST_UnderlineValues + builder.Add(new Dictionary + { + { "double", XLFontUnderlineValues.Double }, + { "doubleAccounting", XLFontUnderlineValues.DoubleAccounting }, + { "none", XLFontUnderlineValues.None }, + { "single", XLFontUnderlineValues.Single }, + { "singleAccounting", XLFontUnderlineValues.SingleAccounting }, + }); + + // ST_VerticalAlignRun + builder.Add(new Dictionary + { + { "baseline", XLFontVerticalTextAlignmentValues.Baseline }, + { "subscript", XLFontVerticalTextAlignmentValues.Subscript }, + { "superscript", XLFontVerticalTextAlignmentValues.Superscript }, + }); + + // ST_PatternType + builder.Add(new Dictionary + { + { "none", XLFillPatternValues.None }, + { "solid", XLFillPatternValues.Solid }, + { "mediumGray", XLFillPatternValues.MediumGray }, + { "darkGray", XLFillPatternValues.DarkGray }, + { "lightGray", XLFillPatternValues.LightGray }, + { "darkHorizontal", XLFillPatternValues.DarkHorizontal }, + { "darkVertical", XLFillPatternValues.DarkVertical }, + { "darkDown", XLFillPatternValues.DarkDown }, + { "darkUp", XLFillPatternValues.DarkUp }, + { "darkGrid", XLFillPatternValues.DarkGrid }, + { "darkTrellis", XLFillPatternValues.DarkTrellis }, + { "lightHorizontal", XLFillPatternValues.LightHorizontal }, + { "lightVertical", XLFillPatternValues.LightVertical }, + { "lightDown", XLFillPatternValues.LightDown }, + { "lightUp", XLFillPatternValues.LightUp }, + { "lightGrid", XLFillPatternValues.LightGrid }, + { "lightTrellis", XLFillPatternValues.LightTrellis }, + { "gray125", XLFillPatternValues.Gray125 }, + { "gray0625", XLFillPatternValues.Gray0625 }, + }); + + // ST_BorderStyle + builder.Add(new Dictionary + { + { "none", XLBorderStyleValues.None }, + { "thin", XLBorderStyleValues.Thin }, + { "medium", XLBorderStyleValues.Medium }, + { "dashed", XLBorderStyleValues.Dashed }, + { "dotted", XLBorderStyleValues.Dotted }, + { "thick", XLBorderStyleValues.Thick }, + { "double", XLBorderStyleValues.Double }, + { "hair", XLBorderStyleValues.Hair }, + { "mediumDashed", XLBorderStyleValues.MediumDashed }, + { "dashDot", XLBorderStyleValues.DashDot }, + { "mediumDashDot", XLBorderStyleValues.MediumDashDot }, + { "dashDotDot", XLBorderStyleValues.DashDotDot }, + { "mediumDashDotDot", XLBorderStyleValues.MediumDashDotDot }, + { "slantDashDot", XLBorderStyleValues.SlantDashDot }, + }); + + // ST_HorizontalAlignment + builder.Add(new Dictionary + { + { "general", XLAlignmentHorizontalValues.General }, + { "left", XLAlignmentHorizontalValues.Left }, + { "center", XLAlignmentHorizontalValues.Center }, + { "right", XLAlignmentHorizontalValues.Right }, + { "fill", XLAlignmentHorizontalValues.Fill }, + { "justify", XLAlignmentHorizontalValues.Justify }, + { "centerContinuous", XLAlignmentHorizontalValues.CenterContinuous }, + { "distributed", XLAlignmentHorizontalValues.Distributed }, + }); + + // ST_VerticalAlignment + builder.Add(new Dictionary + { + { "top", XLAlignmentVerticalValues.Top }, + { "center", XLAlignmentVerticalValues.Center }, + { "bottom", XLAlignmentVerticalValues.Bottom }, + { "justify", XLAlignmentVerticalValues.Justify }, + { "distributed", XLAlignmentVerticalValues.Distributed }, + }); + + // ST_TableStyleType + builder.Add(new Dictionary + { + { "wholeTable", XLTableStyleType.WholeTable }, + { "headerRow", XLTableStyleType.HeaderRow }, + { "totalRow", XLTableStyleType.TotalRow }, + { "firstColumn", XLTableStyleType.FirstColumn }, + { "lastColumn", XLTableStyleType.LastColumn }, + { "firstRowStripe", XLTableStyleType.FirstRowStripe }, + { "secondRowStripe", XLTableStyleType.SecondRowStripe }, + { "firstColumnStripe", XLTableStyleType.FirstColumnStripe }, + { "secondColumnStripe", XLTableStyleType.SecondColumnStripe }, + { "firstHeaderCell", XLTableStyleType.FirstHeaderCell }, + { "lastHeaderCell", XLTableStyleType.LastHeaderCell }, + { "firstTotalCell", XLTableStyleType.FirstTotalCell }, + { "lastTotalCell", XLTableStyleType.LastTotalCell }, + { "firstSubtotalColumn", XLTableStyleType.FirstSubtotalColumn }, + { "secondSubtotalColumn", XLTableStyleType.SecondSubtotalColumn }, + { "thirdSubtotalColumn", XLTableStyleType.ThirdSubtotalColumn }, + { "firstSubtotalRow", XLTableStyleType.FirstSubtotalRow }, + { "secondSubtotalRow", XLTableStyleType.SecondSubtotalRow }, + { "thirdSubtotalRow", XLTableStyleType.ThirdSubtotalRow }, + { "blankRow", XLTableStyleType.BlankRow }, + { "firstColumnSubheading", XLTableStyleType.FirstColumnSubheading }, + { "secondColumnSubheading", XLTableStyleType.SecondColumnSubheading }, + { "thirdColumnSubheading", XLTableStyleType.ThirdColumnSubheading }, + { "firstRowSubheading", XLTableStyleType.FirstRowSubheading }, + { "secondRowSubheading", XLTableStyleType.SecondRowSubheading }, + { "thirdRowSubheading", XLTableStyleType.ThirdRowSubheading }, + { "pageFieldLabels", XLTableStyleType.PageFieldLabels }, + { "pageFieldValues", XLTableStyleType.PageFieldValues }, + }); + + return builder.Build(); + } + + internal class Builder + { + private readonly Dictionary _maps = new(); + + public Builder Add(Dictionary map) + { + _maps.Add(typeof(T), map); + return this; + } + + public XmlToEnumMapper Build() + { + return new XmlToEnumMapper(_maps); + } + } +} diff --git a/ClosedXML/Excel/IO/XmlTreeReaderExtensions.cs b/ClosedXML/Excel/IO/XmlTreeReaderExtensions.cs new file mode 100644 index 000000000..b00a90a60 --- /dev/null +++ b/ClosedXML/Excel/IO/XmlTreeReaderExtensions.cs @@ -0,0 +1,150 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using ClosedXML.IO; +using ClosedXML.Utils; + +namespace ClosedXML.Excel.IO; + +/// +/// A helper methods for patterns and types commonly found in OOXML. Reading concrete types is not +/// something for . +/// +internal static class XmlTreeReaderExtensions +{ + /// + /// Try to open an optional CT_Color and read the color. + /// + /// true when there was a color element, false if there wasn't. + public static bool TryReadColor(this XmlTreeReader reader, string colorElementName, string ns, [NotNullWhen(true)] out XLColor? color) + { + if (reader.TryOpen(colorElementName, ns)) + { + color = reader.ParseColor(colorElementName, ns); + return true; + } + + color = default; + return false; + } + + /// + /// Read CT_Color. + /// + public static XLColor ParseColor(this XmlTreeReader reader, string colorElementName, string ns) + { + // OI-29500: Office prioritizes the attributes as auto < indexed < rgb < theme, and only + // round trips the type with the highest priority if two or more are specified. + var theme = reader.GetOptionalUInt("theme"); + if (theme is not null) + { + var tint = reader.GetOptionalDouble("tint") ?? 0; + var themeColor = XLColor.FromTheme((XLThemeColor)theme.Value, tint); + reader.Close(colorElementName, ns); + return themeColor; + } + + var rgb = reader.GetOptionalString("rgb"); + if (rgb is not null) + { + var rgbColor = XLColor.FromColor(ColorStringParser.ParseFromArgb(rgb.AsSpan())); + reader.Close(colorElementName, ns); + return rgbColor; + } + + var indexed = reader.GetOptionalUintAsInt("indexed"); + if (indexed is not null) + { + var indexedColor = indexed <= 64 ? XLColor.FromIndex(indexed.Value) : XLColor.NoColor; + reader.Close(colorElementName, ns); + return indexedColor; + } + + var auto = reader.GetOptionalBool("auto"); + if (auto is not null) + { + // TODO: I have no idea what to do with auto + var autoColor = XLColor.NoColor; + reader.Close(colorElementName, ns); + return autoColor; + } + + throw PartStructureException.IncorrectElementFormat(colorElementName); + } + + /// + /// Read CT_BooleanProperty. + /// + public static bool TryReadBoolElement(this XmlTreeReader reader, string boolElementName, string ns, out bool value) + { + if (!reader.TryOpen(boolElementName, ns)) + { + value = default; + return false; + } + + value = reader.GetOptionalBool("val") ?? true; + + reader.Close(boolElementName, ns); + return true; + } + + /// + /// Try to read an element with an attribute val that contains a XString. Example: + /// + /// + /// + /// ]]> + /// + public static bool TryReadXStringValElement(this XmlTreeReader reader, string elementName, string ns, [NotNullWhen(true)] out string? value) + { + if (reader.TryOpen(elementName, ns)) + { + value = reader.GetXString("val"); + reader.Close(elementName, ns); + return true; + } + + value = null; + return false; + } + + /// + /// Try to read an optional element of type CT_IntProperty + /// + public static bool TryReadIntValElement(this XmlTreeReader reader, string elementName, string ns, [NotNullWhen(true)] out int? value) + { + if (reader.TryOpen(elementName, ns)) + { + value = reader.GetInt("val"); + reader.Close(elementName, ns); + return true; + } + + value = null; + return false; + } + + /// + /// Try to read an optional element that contains a required enum in a val attribute. + /// + /// + /// + /// ]]> + /// + /// true when there was the element, false when there wasn't the element. + public static bool TryReadEnumValElement(this XmlTreeReader reader, string elementName, string ns, [NotNullWhen(true)] out TEnum? enumValue) + where TEnum : struct, Enum + { + if (reader.TryOpen(elementName, ns)) + { + enumValue = reader.GetEnum("val"); + reader.Close(elementName, ns); + return true; + } + + enumValue = null; + return false; + } +} diff --git a/ClosedXML/Excel/IXLFileSharing.cs b/ClosedXML/Excel/IXLFileSharing.cs index d75c18e02..1eb0f5fb4 100644 --- a/ClosedXML/Excel/IXLFileSharing.cs +++ b/ClosedXML/Excel/IXLFileSharing.cs @@ -1,4 +1,4 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using System; namespace ClosedXML.Excel @@ -12,6 +12,6 @@ public interface IXLFileSharing //Byte[] ReservationPassword { get; set; } //Byte[] SaltValue { get; set; } //Int32 SpinCount { get; set; } - String UserName { get; set; } + String? UserName { get; set; } } } diff --git a/ClosedXML/Excel/IXLOutline.cs b/ClosedXML/Excel/IXLOutline.cs index 33b4711ca..6fc988923 100644 --- a/ClosedXML/Excel/IXLOutline.cs +++ b/ClosedXML/Excel/IXLOutline.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/IXLSheetView.cs b/ClosedXML/Excel/IXLSheetView.cs index 7a7f14eb8..19ae1ac3c 100644 --- a/ClosedXML/Excel/IXLSheetView.cs +++ b/ClosedXML/Excel/IXLSheetView.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; diff --git a/ClosedXML/Excel/IXLTheme.cs b/ClosedXML/Excel/IXLTheme.cs index b506e22ee..72dac2313 100644 --- a/ClosedXML/Excel/IXLTheme.cs +++ b/ClosedXML/Excel/IXLTheme.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace ClosedXML.Excel { public interface IXLTheme diff --git a/ClosedXML/Excel/IXLWorkbook.cs b/ClosedXML/Excel/IXLWorkbook.cs index 49f84afc2..9e3a7cd97 100644 --- a/ClosedXML/Excel/IXLWorkbook.cs +++ b/ClosedXML/Excel/IXLWorkbook.cs @@ -1,9 +1,12 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; using System.Data; using System.Globalization; using System.IO; +using ClosedXML.Excel.CalcEngine.Exceptions; namespace ClosedXML.Excel { @@ -50,18 +53,17 @@ public interface IXLWorkbook : IXLProtectable - /// Gets an object to manipulate this workbook's named ranges. + /// Gets an object to manipulate this workbook's defined names. /// - IXLNamedRanges NamedRanges { get; } + IXLDefinedNames DefinedNames { get; } /// /// Gets or sets the default outline options for the workbook. @@ -75,6 +77,12 @@ public interface IXLWorkbook : IXLProtectable IXLPageSetup PageOptions { get; set; } + /// + /// Gets all pivot caches in a workbook. A one cache can be + /// used by multiple tables. Unused caches are not saved. + /// + IXLPivotCaches PivotCaches { get; } + /// /// Gets or sets the workbook's properties. /// @@ -133,21 +141,50 @@ public interface IXLWorkbook : IXLProtectable + /// Add a worksheet with a table at Cell(row:1, column:1). The dataTable's name is used for the + /// worksheet name. The name of a table will be generated as Table{number suffix}. + /// + /// Datatable to insert + /// Inserted Worksheet + IXLWorksheet AddWorksheet(DataTable dataTable); + + /// + /// Add a worksheet with a table at Cell(row:1, column:1). The sheetName provided is used for the + /// worksheet name. The name of a table will be generated as Table{number suffix}. + /// + /// dataTable to insert as Excel Table + /// Worksheet and Excel Table name + /// Inserted Worksheet IXLWorksheet AddWorksheet(DataTable dataTable, String sheetName); + /// + /// Add a worksheet with a table at Cell(row:1, column:1). + /// + /// dataTable to insert as Excel Table + /// Worksheet name + /// Excel Table name + /// Inserted Worksheet + IXLWorksheet AddWorksheet(DataTable dataTable, String sheetName, String tableName); + IXLCell Cell(String namedCell); IXLCells Cells(String namedCells); IXLCustomProperty CustomProperty(String name); - Object Evaluate(String expression); + /// + /// Evaluate a formula expression. + /// + /// Formula expression to evaluate. + /// + /// If the expression contains a function that requires a context (e.g. current cell or worksheet). + /// + XLCellValue Evaluate(String expression); IXLCells FindCells(Func predicate); @@ -155,16 +192,31 @@ public interface IXLWorkbook : IXLProtectable predicate); - IXLNamedRange NamedRange(String rangeName); - - [Obsolete("Use Protect(String password, Algorithm algorithm, TElement allowedElements)")] - IXLWorkbookProtection Protect(Boolean lockStructure, Boolean lockWindows, String password); +#nullable enable + [Obsolete($"Use {nameof(DefinedName)} instead.")] + IXLDefinedName? NamedRange(String name); - [Obsolete("Use Protect(String password, Algorithm algorithm, TElement allowedElements)")] - IXLWorkbookProtection Protect(Boolean lockStructure); - - [Obsolete("Use Protect(String password, Algorithm algorithm, TElement allowedElements)")] - IXLWorkbookProtection Protect(Boolean lockStructure, Boolean lockWindows); + /// + /// Try to find a defined name. If specifies a sheet, try to find + /// name in the sheet first and fall back to the workbook if not found in the sheet. + /// + /// + /// Requested name Sheet1!Name will first try to find Name in a sheet + /// Sheet1 (if such sheet exists) and if not found there, tries to find Name + /// in workbook. + /// + /// + /// + /// + /// Requested name Name will be searched only in a workbooks . + /// + /// + /// + /// Name of requested name, either plain name (e.g. Name) or with + /// sheet specified (e.g. Sheet!Name). + /// Found name or null. + IXLDefinedName? DefinedName(String name); +#nullable disable IXLRange Range(String range); @@ -219,7 +271,6 @@ public interface IXLWorkbook : IXLProtectableThe search text. /// The compare options. /// if set to true search formulae instead of cell values. - /// IEnumerable Search(String searchText, CompareOptions compareOptions = CompareOptions.Ordinal, Boolean searchFormulae = false); XLWorkbook SetLockStructure(Boolean value); diff --git a/ClosedXML/Excel/IXLWorksheet.cs b/ClosedXML/Excel/IXLWorksheet.cs index b35813e88..3b331624a 100644 --- a/ClosedXML/Excel/IXLWorksheet.cs +++ b/ClosedXML/Excel/IXLWorksheet.cs @@ -25,7 +25,8 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable - /// Gets or sets the name (caption) of this worksheet. + /// Gets or sets the name (caption) of this worksheet. The sheet rename also renames sheet + /// in formulas and defined names. /// String Name { get; set; } @@ -45,25 +46,27 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable IXLOutline Outline { get; } + /// + /// All hyperlinks in the sheet. + /// + IXLHyperlinks Hyperlinks { get; } + /// /// Gets the first row of the worksheet. /// IXLRow FirstRow(); /// - /// Gets the first row of the worksheet that contains a cell with a value. + /// Gets the first non-empty row of the worksheet that contains a cell with a value. /// Formatted empty cells do not count. /// - IXLRow FirstRowUsed(); + IXLRow? FirstRowUsed(); /// - /// Gets the first row of the worksheet that contains a cell with a value. + /// Gets the first non-empty row of the worksheet that contains a cell with a value. /// - /// If set to true formatted empty cells will count as used. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRow FirstRowUsed(Boolean includeFormats); - - IXLRow FirstRowUsed(XLCellsUsedOptions options); + /// The options to determine whether a cell is used. + IXLRow? FirstRowUsed(XLCellsUsedOptions options); /// /// Gets the last row of the worksheet. @@ -71,18 +74,15 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable - /// Gets the last row of the worksheet that contains a cell with a value. + /// Gets the last non-empty row of the worksheet that contains a cell with a value. /// - IXLRow LastRowUsed(); + IXLRow? LastRowUsed(); /// - /// Gets the last row of the worksheet that contains a cell with a value. + /// Gets the last non-empty row of the worksheet that contains a cell with a value. /// - /// If set to true formatted empty cells will count as used. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRow LastRowUsed(Boolean includeFormats); - - IXLRow LastRowUsed(XLCellsUsedOptions options); + /// The options to determine whether a cell is used. + IXLRow? LastRowUsed(XLCellsUsedOptions options); /// /// Gets the first column of the worksheet. @@ -90,18 +90,15 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable - /// Gets the first column of the worksheet that contains a cell with a value. + /// Gets the first non-empty column of the worksheet that contains a cell with a value. /// - IXLColumn FirstColumnUsed(); + IXLColumn? FirstColumnUsed(); /// - /// Gets the first column of the worksheet that contains a cell with a value. + /// Gets the first non-empty column of the worksheet that contains a cell with a value. /// - /// If set to true formatted empty cells will count as used. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLColumn FirstColumnUsed(Boolean includeFormats); - - IXLColumn FirstColumnUsed(XLCellsUsedOptions options); + /// The options to determine whether a cell is used. + IXLColumn? FirstColumnUsed(XLCellsUsedOptions options); /// /// Gets the last column of the worksheet. @@ -109,18 +106,15 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable - /// Gets the last column of the worksheet that contains a cell with a value. + /// Gets the last non-empty column of the worksheet that contains a cell with a value. /// - IXLColumn LastColumnUsed(); + IXLColumn? LastColumnUsed(); /// - /// Gets the last column of the worksheet that contains a cell with a value. + /// Gets the last non-empty column of the worksheet that contains a cell with a value. /// - /// If set to true formatted empty cells will count as used. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLColumn LastColumnUsed(Boolean includeFormats); - - IXLColumn LastColumnUsed(XLCellsUsedOptions options); + /// The options to determine whether a cell is used. + IXLColumn? LastColumnUsed(XLCellsUsedOptions options); /// /// Gets a collection of all columns in this worksheet. @@ -165,7 +159,6 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable /// The first row to return. /// The last row to return. - /// IXLRows Rows(Int32 firstRow, Int32 lastRow); /// @@ -195,6 +188,7 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectableGets the cell at the specified address. /// The cell address in the worksheet. + /// Address is not A1 or workbook-scoped named range. IXLCell Cell(string cellAddressInRange); /// @@ -217,6 +211,7 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectableReturns the specified range. /// e.g. Range("A1"), Range("A1:C2") /// The range boundaries. + /// is not a valid address or named range. IXLRange Range(string rangeAddress); /// Returns the specified range. @@ -302,16 +297,23 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable void Delete(); + [Obsolete($"Use {nameof(DefinedNames)} instead.")] + IXLDefinedNames NamedRanges { get; } + /// - /// Gets an object to manage this worksheet's named ranges. + /// Gets an object to manage this worksheet's defined names. /// - IXLNamedRanges NamedRanges { get; } + IXLDefinedNames DefinedNames { get; } + + [Obsolete($"Use {nameof(DefinedName)} instead.")] + IXLDefinedName NamedRange(String rangeName); /// - /// Gets the specified named range. + /// Gets the specified defined name. /// - /// Name of the range. - IXLNamedRange NamedRange(String rangeName); + /// Name identifier of defined name, without sheet name. + /// Name wasn't found in sheets defined names. + IXLDefinedName DefinedName(String name); /// /// Gets an object to manage how the worksheet is going to displayed by Excel. @@ -339,23 +341,25 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable /// - /// IXLWorksheet CopyTo(String newSheetName); IXLWorksheet CopyTo(String newSheetName, Int32 position); IXLWorksheet CopyTo(XLWorkbook workbook); + /// + /// Copy a worksheet from this workbook to a different workbook as a new sheet. + /// + /// Workbook into which copy this sheet. + /// Name of new sheet in the where will the data be copied. Sheet will be in the last position. + /// Newly created sheet in the . IXLWorksheet CopyTo(XLWorkbook workbook, String newSheetName); IXLWorksheet CopyTo(XLWorkbook workbook, String newSheetName, Int32 position); - IXLRange RangeUsed(); - - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRange RangeUsed(bool includeFormats); + IXLRange? RangeUsed(); - IXLRange RangeUsed(XLCellsUsedOptions options); + IXLRange? RangeUsed(XLCellsUsedOptions options); IXLDataValidations DataValidations { get; } @@ -429,19 +433,13 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable predicate = null); + IXLRows RowsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents, Func? predicate = null); - IXLRows RowsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents, Func predicate = null); + IXLRows RowsUsed(Func? predicate); - IXLRows RowsUsed(Func predicate); + IXLColumns ColumnsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents, Func? predicate = null); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLColumns ColumnsUsed(Boolean includeFormats, Func predicate = null); - - IXLColumns ColumnsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents, Func predicate = null); - - IXLColumns ColumnsUsed(Func predicate); + IXLColumns ColumnsUsed(Func? predicate); IXLRanges MergedRanges { get; } @@ -451,7 +449,10 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectable + /// The active cell of the worksheet. + /// + IXLCell? ActiveCell { get; set; } /// /// Evaluate an formula and return a result. @@ -459,10 +460,10 @@ public interface IXLWorksheet : IXLRangeBase, IXLProtectableFormula to evaluate. /// A cell address that is used to provide context for formula calculation (mostly implicit intersection). /// If was needed for some part of calculation. - object Evaluate(String expression, string formulaAddress = null); + XLCellValue Evaluate(String expression, string? formulaAddress = null); /// - /// Force recalculation of all cell formulas. + /// Force recalculation of all cell formulas in the sheet while leaving other sheets without change, even if their dirty cells. /// void RecalculateAllFormulas(); diff --git a/ClosedXML/Excel/IXLWorksheets.cs b/ClosedXML/Excel/IXLWorksheets.cs index ea79b0b81..a94d1e5c5 100644 --- a/ClosedXML/Excel/IXLWorksheets.cs +++ b/ClosedXML/Excel/IXLWorksheets.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Data; +using System.Diagnostics.CodeAnalysis; namespace ClosedXML.Excel { @@ -20,6 +21,8 @@ public interface IXLWorksheets : IEnumerable IXLWorksheet Add(DataTable dataTable, String sheetName); + IXLWorksheet Add(DataTable dataTable, String sheetName, String tableName); + void Add(DataSet dataSet); Boolean Contains(String sheetName); @@ -28,7 +31,7 @@ public interface IXLWorksheets : IEnumerable void Delete(Int32 position); - bool TryGetWorksheet(string sheetName, out IXLWorksheet worksheet); + bool TryGetWorksheet(string sheetName, [NotNullWhen(true)] out IXLWorksheet? worksheet); IXLWorksheet Worksheet(String sheetName); diff --git a/ClosedXML/Excel/InsertData/ArrayReader.cs b/ClosedXML/Excel/InsertData/ArrayReader.cs index a33df5b2a..edf68618c 100644 --- a/ClosedXML/Excel/InsertData/ArrayReader.cs +++ b/ClosedXML/Excel/InsertData/ArrayReader.cs @@ -1,4 +1,4 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using System; using System.Collections; using System.Collections.Generic; @@ -15,9 +15,9 @@ public ArrayReader(IEnumerable data) _data = data ?? throw new ArgumentNullException(nameof(data)); } - public IEnumerable> GetData() + public IEnumerable> GetRecords() { - return _data.Select(item => item.Cast()); + return _data.Select(item => item.Cast().Select(XLCellValue.FromInsertedObject)); } public int GetPropertiesCount() @@ -28,14 +28,9 @@ public int GetPropertiesCount() return _data.First().Cast().Count(); } - public string GetPropertyName(int propertyIndex) + public string? GetPropertyName(int propertyIndex) { return null; } - - public int GetRecordsCount() - { - return _data.Count(); - } } } diff --git a/ClosedXML/Excel/InsertData/DataRecordReader.cs b/ClosedXML/Excel/InsertData/DataRecordReader.cs index 070e5567e..844325edb 100644 --- a/ClosedXML/Excel/InsertData/DataRecordReader.cs +++ b/ClosedXML/Excel/InsertData/DataRecordReader.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; using System.Data; @@ -8,7 +10,7 @@ namespace ClosedXML.Excel.InsertData { internal class DataRecordReader : IInsertDataReader { - private readonly IEnumerable[] _inMemoryData; + private readonly IEnumerable[] _inMemoryData; private string[] _columns; public DataRecordReader(IEnumerable data) @@ -18,7 +20,7 @@ public DataRecordReader(IEnumerable data) _inMemoryData = ReadToEnd(data).ToArray(); } - public IEnumerable> GetData() + public IEnumerable> GetRecords() { return _inMemoryData; } @@ -42,12 +44,7 @@ public string GetPropertyName(int propertyIndex) return _columns[propertyIndex]; } - public int GetRecordsCount() - { - return _inMemoryData.Length; - } - - private IEnumerable> ReadToEnd(IEnumerable data) + private IEnumerable> ReadToEnd(IEnumerable data) { foreach (var dataRecord in data) { @@ -55,7 +52,7 @@ private IEnumerable> ReadToEnd(IEnumerable data } } - private IEnumerable ToEnumerable(IDataRecord dataRecord) + private IEnumerable ToEnumerable(IDataRecord dataRecord) { var firstRow = false; if (_columns == null) @@ -69,7 +66,8 @@ private IEnumerable ToEnumerable(IDataRecord dataRecord) if (firstRow) _columns[i] = dataRecord.GetName(i); - yield return dataRecord[i]; + var value = dataRecord[i]; + yield return XLCellValue.FromInsertedObject(value); } } } diff --git a/ClosedXML/Excel/InsertData/DataTableReader.cs b/ClosedXML/Excel/InsertData/DataTableReader.cs index 4d6ecdd71..3d42372eb 100644 --- a/ClosedXML/Excel/InsertData/DataTableReader.cs +++ b/ClosedXML/Excel/InsertData/DataTableReader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.Linq; @@ -8,7 +8,7 @@ namespace ClosedXML.Excel.InsertData internal class DataTableReader : IInsertDataReader { private readonly IEnumerable _dataRows; - private readonly DataTable _dataTable; + private readonly DataTable? _dataTable; public DataTableReader(DataTable dataTable) { @@ -22,9 +22,9 @@ public DataTableReader(IEnumerable dataRows) _dataTable = _dataRows.FirstOrDefault()?.Table; } - public IEnumerable> GetData() + public IEnumerable> GetRecords() { - return _dataRows.Select(r => r.ItemArray); + return _dataRows.Select(r => r.ItemArray.Select(XLCellValue.FromInsertedObject)); } public int GetPropertiesCount() @@ -38,7 +38,7 @@ public int GetPropertiesCount() return 0; } - public string GetPropertyName(int propertyIndex) + public string? GetPropertyName(int propertyIndex) { if (propertyIndex < 0) throw new ArgumentOutOfRangeException(nameof(propertyIndex), "Property index must be non-negative"); @@ -51,10 +51,5 @@ public string GetPropertyName(int propertyIndex) return _dataTable.Columns[propertyIndex].Caption; } - - public int GetRecordsCount() - { - return _dataRows.Count(); - } } } diff --git a/ClosedXML/Excel/InsertData/IInsertDataReader.cs b/ClosedXML/Excel/InsertData/IInsertDataReader.cs index 18e4ad016..8212187df 100644 --- a/ClosedXML/Excel/InsertData/IInsertDataReader.cs +++ b/ClosedXML/Excel/InsertData/IInsertDataReader.cs @@ -1,4 +1,4 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using System.Collections.Generic; namespace ClosedXML.Excel.InsertData @@ -11,7 +11,7 @@ internal interface IInsertDataReader /// /// Get a collection of records, each as a collection of values, extracted from a source. /// - IEnumerable> GetData(); + IEnumerable> GetRecords(); /// /// Get the number of properties to use as a table with. @@ -22,12 +22,6 @@ internal interface IInsertDataReader /// /// Get the title of the property with the specified index. /// - string GetPropertyName(int propertyIndex); - - /// - /// Get the total number of records. - /// - /// - int GetRecordsCount(); + string? GetPropertyName(int propertyIndex); } } diff --git a/ClosedXML/Excel/InsertData/InsertDataReaderFactory.cs b/ClosedXML/Excel/InsertData/InsertDataReaderFactory.cs index c85caa3ff..4ca272ea4 100644 --- a/ClosedXML/Excel/InsertData/InsertDataReaderFactory.cs +++ b/ClosedXML/Excel/InsertData/InsertDataReaderFactory.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using System.Collections; using System.Collections.Generic; @@ -47,7 +49,7 @@ public IInsertDataReader CreateReader(IEnumerable data) { if (data == null) throw new ArgumentNullException(nameof(data)); - if (data?.GetType().GetElementType() == typeof(String)) + if (data.GetType().GetElementType() == typeof(String)) return new SimpleTypeReader(data); return new ArrayReader(data); diff --git a/ClosedXML/Excel/InsertData/NullDataReader.cs b/ClosedXML/Excel/InsertData/NullDataReader.cs index 825aafffd..75116a8d0 100644 --- a/ClosedXML/Excel/InsertData/NullDataReader.cs +++ b/ClosedXML/Excel/InsertData/NullDataReader.cs @@ -1,4 +1,4 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using System.Collections.Generic; using System.Linq; @@ -6,6 +6,7 @@ namespace ClosedXML.Excel.InsertData { internal class NullDataReader : IInsertDataReader { + private readonly XLCellValue[] _row = { Blank.Value }; private readonly int _count; public NullDataReader(IEnumerable nulls) @@ -13,13 +14,9 @@ public NullDataReader(IEnumerable nulls) _count = nulls.Count(); } - public IEnumerable> GetData() + public IEnumerable> GetRecords() { - var res = new object[] { null }.AsEnumerable(); - for (int i = 0; i < _count; i++) - { - yield return res; - } + return Enumerable.Repeat(_row, _count); } public int GetPropertiesCount() @@ -27,14 +24,9 @@ public int GetPropertiesCount() return 0; } - public string GetPropertyName(int propertyIndex) + public string? GetPropertyName(int propertyIndex) { return null; } - - public int GetRecordsCount() - { - return _count; - } } } diff --git a/ClosedXML/Excel/InsertData/ObjectReader.cs b/ClosedXML/Excel/InsertData/ObjectReader.cs index 48411f6ee..bdf9662e7 100644 --- a/ClosedXML/Excel/InsertData/ObjectReader.cs +++ b/ClosedXML/Excel/InsertData/ObjectReader.cs @@ -1,4 +1,4 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using ClosedXML.Attributes; using ClosedXML.Extensions; using System; @@ -11,7 +11,7 @@ namespace ClosedXML.Excel.InsertData { internal class ObjectReader : IInsertDataReader { - private const BindingFlags bindingFlags = BindingFlags.Public + private const BindingFlags MemberBindingFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static; @@ -21,24 +21,24 @@ internal class ObjectReader : IInsertDataReader public ObjectReader(IEnumerable data) { - _data = data?.Cast() ?? throw new ArgumentNullException(nameof(data)); + _data = data.Cast(); - var itemType = data.GetItemType(); + var itemType = data.GetItemType()!; if (itemType.IsNullableType()) itemType = itemType.GetUnderlyingType(); - _members = itemType.GetFields(bindingFlags).Cast() - .Concat(itemType.GetProperties(bindingFlags)) + _members = itemType.GetFields(MemberBindingFlags).Cast() + .Concat(itemType.GetProperties(MemberBindingFlags).Where(pi => !pi.GetIndexParameters().Any())) .Where(mi => !XLColumnAttribute.IgnoreMember(mi)) - .OrderBy(mi => XLColumnAttribute.GetOrder(mi)) + .OrderBy(XLColumnAttribute.GetOrder) .ToArray(); _staticMembers = _members.Select(ReflectionExtensions.IsStatic).ToArray(); } - public IEnumerable> GetData() + public IEnumerable> GetRecords() { - return _data.Select(GetItemData); + return _data.Select(item => GetItemData(item).Select(XLCellValue.FromInsertedObject)); } public int GetPropertiesCount() @@ -46,7 +46,7 @@ public int GetPropertiesCount() return _members.Length; } - public string GetPropertyName(int propertyIndex) + public string? GetPropertyName(int propertyIndex) { if (propertyIndex < 0) throw new ArgumentOutOfRangeException(nameof(propertyIndex), "Property index must be non-negative"); @@ -62,12 +62,7 @@ public string GetPropertyName(int propertyIndex) return fieldName; } - public int GetRecordsCount() - { - return _data.Count(); - } - - private IEnumerable GetItemData(object item) + private IEnumerable GetItemData(object item) { for (int i = 0; i < _members.Length; i++) { diff --git a/ClosedXML/Excel/InsertData/SimpleNullableTypeReader.cs b/ClosedXML/Excel/InsertData/SimpleNullableTypeReader.cs index d7c019420..4f308bf7e 100644 --- a/ClosedXML/Excel/InsertData/SimpleNullableTypeReader.cs +++ b/ClosedXML/Excel/InsertData/SimpleNullableTypeReader.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using System.Collections; using System.Collections.Generic; @@ -17,9 +19,9 @@ public SimpleNullableTypeReader(IEnumerable data) _itemType = data.GetItemType().GetUnderlyingType(); } - public IEnumerable> GetData() + public IEnumerable> GetRecords() { - return _data.Select(item => new[] { item }.Cast()); + return _data.Select(item => new[] { item }.Select(XLCellValue.FromInsertedObject)); } public int GetPropertiesCount() @@ -34,10 +36,5 @@ public string GetPropertyName(int propertyIndex = 0) return _itemType.Name; } - - public int GetRecordsCount() - { - return _data.Count(); - } } } diff --git a/ClosedXML/Excel/InsertData/SimpleTypeReader.cs b/ClosedXML/Excel/InsertData/SimpleTypeReader.cs index b94cc9a35..eb500dd38 100644 --- a/ClosedXML/Excel/InsertData/SimpleTypeReader.cs +++ b/ClosedXML/Excel/InsertData/SimpleTypeReader.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using System.Collections; using System.Collections.Generic; @@ -17,9 +19,9 @@ public SimpleTypeReader(IEnumerable data) _itemType = data.GetItemType(); } - public IEnumerable> GetData() + public IEnumerable> GetRecords() { - return _data.Select(item => new[] { item }.Cast()); + return _data.Select(item => new[] { item }.Select(XLCellValue.FromInsertedObject)); } public int GetPropertiesCount() @@ -34,10 +36,5 @@ public string GetPropertyName(int propertyIndex = 0) return _itemType.Name; } - - public int GetRecordsCount() - { - return _data.Count(); - } } } diff --git a/ClosedXML/Excel/InsertData/UntypedObjectReader.cs b/ClosedXML/Excel/InsertData/UntypedObjectReader.cs index 13869a7c1..7e6a4abc0 100644 --- a/ClosedXML/Excel/InsertData/UntypedObjectReader.cs +++ b/ClosedXML/Excel/InsertData/UntypedObjectReader.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using System.Collections; using System.Collections.Generic; @@ -55,11 +57,11 @@ IInsertDataReader CreateReader(List itemsOfSameType, Type itemType) } } - public IEnumerable> GetData() + public IEnumerable> GetRecords() { foreach (var reader in _readers) { - foreach (var item in reader.GetData()) + foreach (var item in reader.GetRecords()) { yield return item; } @@ -76,11 +78,6 @@ public string GetPropertyName(int propertyIndex) return GetFirstNonNullReader()?.GetPropertyName(propertyIndex); } - public int GetRecordsCount() - { - return _data.Count(); - } - private IInsertDataReader GetFirstNonNullReader() { return _readers.FirstOrDefault(r => !(r is NullDataReader)); diff --git a/ClosedXML/Excel/LoadOptions.cs b/ClosedXML/Excel/LoadOptions.cs index 7dbd5a3c5..3ebbfbef8 100644 --- a/ClosedXML/Excel/LoadOptions.cs +++ b/ClosedXML/Excel/LoadOptions.cs @@ -1,4 +1,4 @@ -// Keep this file CodeMaid organized and cleaned +// Keep this file CodeMaid organized and cleaned using System; using System.Drawing; using ClosedXML.Graphics; @@ -15,9 +15,7 @@ public class LoadOptions /// /// A graphics engine that will be used for workbooks without explicitly set engine. /// - public static IXLGraphicEngine DefaultGraphicEngine { internal get; set; } - - public XLEventTracking EventTracking { get; set; } = XLEventTracking.Enabled; + public static IXLGraphicEngine? DefaultGraphicEngine { internal get; set; } /// /// Should all formulas in a workbook be recalculated during load? Default value is false. @@ -27,7 +25,7 @@ public class LoadOptions /// /// Graphic engine used by the workbook. /// - public IXLGraphicEngine GraphicEngine { get; set; } + public IXLGraphicEngine? GraphicEngine { get; set; } /// /// DPI for the workbook. Default is 96. diff --git a/ClosedXML/Excel/Misc/XLDictionary.cs b/ClosedXML/Excel/Misc/XLDictionary.cs index b386e897b..a82660b4a 100644 --- a/ClosedXML/Excel/Misc/XLDictionary.cs +++ b/ClosedXML/Excel/Misc/XLDictionary.cs @@ -1,7 +1,7 @@ +#nullable disable + using System; using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Misc/XLFormula.cs b/ClosedXML/Excel/Misc/XLFormula.cs index 80276679c..dff4fb18b 100644 --- a/ClosedXML/Excel/Misc/XLFormula.cs +++ b/ClosedXML/Excel/Misc/XLFormula.cs @@ -1,7 +1,6 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { @@ -25,15 +24,15 @@ public XLFormula(double value) { Value = value.ToInvariantString(); } - + public XLFormula(int value) { Value = value.ToInvariantString(); } internal String _value; - public String Value - { + public String Value + { get { return _value; } set { @@ -48,7 +47,7 @@ public String Value if (IsFormula) _value = _value.Substring(1); } - + } } diff --git a/ClosedXML/Excel/Misc/XLIdManager.cs b/ClosedXML/Excel/Misc/XLIdManager.cs index 7dde3e8e3..a675e4e71 100644 --- a/ClosedXML/Excel/Misc/XLIdManager.cs +++ b/ClosedXML/Excel/Misc/XLIdManager.cs @@ -1,7 +1,7 @@ +#nullable disable + using System; using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/NamedRanges/IXLNamedRange.cs b/ClosedXML/Excel/NamedRanges/IXLNamedRange.cs deleted file mode 100644 index 56de92507..000000000 --- a/ClosedXML/Excel/NamedRanges/IXLNamedRange.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; - -namespace ClosedXML.Excel -{ - public enum XLNamedRangeScope - { - Worksheet, - Workbook - } - - public interface IXLNamedRange - { - #region Public Properties - - /// - /// Gets or sets the comment for this named range. - /// - /// - /// The comment for this named range. - /// - String Comment { get; set; } - - /// - /// Checks if the named range contains invalid references (#REF!). - /// - bool IsValid { get; } - - /// - /// Gets or sets the name of the range. - /// - /// - /// The name of the range. - /// - String Name { get; set; } - - /// - /// Gets the ranges associated with this named range. - /// Note: A named range can point to multiple ranges. - /// - IXLRanges Ranges { get; } - String RefersTo { get; set; } - - /// - /// Gets the scope of this named range. - /// - XLNamedRangeScope Scope { get; } - - /// - /// Gets or sets the visibility of this named range. - /// - /// - /// true if visible; otherwise, false. - /// - Boolean Visible { get; set; } - - #endregion Public Properties - - #region Public Methods - - /// - /// Adds the specified range to this named range. - /// Note: A named range can point to multiple ranges. - /// - /// Workbook containing the range - /// The range address to add. - IXLRanges Add(XLWorkbook workbook, String rangeAddress); - - /// - /// Adds the specified range to this named range. - /// Note: A named range can point to multiple ranges. - /// - /// The range to add. - IXLRanges Add(IXLRange range); - - /// - /// Adds the specified ranges to this named range. - /// Note: A named range can point to multiple ranges. - /// - /// The ranges to add. - IXLRanges Add(IXLRanges ranges); - - /// - /// Clears the list of ranges associated with this named range. - /// (it does not clear the cells) - /// - void Clear(); - - IXLNamedRange CopyTo(IXLWorksheet targetSheet); - - /// - /// Deletes this named range (not the cells). - /// - void Delete(); - /// - /// Removes the specified range from this named range. - /// Note: A named range can point to multiple ranges. - /// - /// The range address to remove. - void Remove(String rangeAddress); - - /// - /// Removes the specified range from this named range. - /// Note: A named range can point to multiple ranges. - /// - /// The range to remove. - void Remove(IXLRange range); - - /// - /// Removes the specified ranges from this named range. - /// Note: A named range can point to multiple ranges. - /// - /// The ranges to remove. - void Remove(IXLRanges ranges); - - IXLNamedRange SetRefersTo(String range); - - IXLNamedRange SetRefersTo(IXLRangeBase range); - - IXLNamedRange SetRefersTo(IXLRanges ranges); - - #endregion Public Methods - } -} diff --git a/ClosedXML/Excel/NamedRanges/IXLNamedRanges.cs b/ClosedXML/Excel/NamedRanges/IXLNamedRanges.cs deleted file mode 100644 index 3d0eb51a9..000000000 --- a/ClosedXML/Excel/NamedRanges/IXLNamedRanges.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ClosedXML.Excel -{ - public interface IXLNamedRanges: IEnumerable - { - /// - /// Gets the specified named range. - /// - /// Name of the range. - IXLNamedRange NamedRange(String rangeName); - - /// - /// Adds a new named range. - /// - /// Name of the range to add. - /// The range address to add. - /// - IXLNamedRange Add(String rangeName, String rangeAddress); - - /// - /// Adds a new named range. - /// - /// Name of the range to add. - /// The range to add. - /// - IXLNamedRange Add(String rangeName, IXLRange range); - - /// - /// Adds a new named range. - /// - /// Name of the range to add. - /// The ranges to add. - /// - IXLNamedRange Add(String rangeName, IXLRanges ranges); - - /// - /// Adds a new named range. - /// - /// Name of the ranges to add. - /// The range address to add. - /// The comment for the new named range. - IXLNamedRange Add(String rangeName, String rangeAddress, String comment); - - /// - /// Adds a new named range. - /// - /// Name of the ranges to add. - /// The range to add. - /// The comment for the new named range. - IXLNamedRange Add(String rangeName, IXLRange range, String comment); - - /// - /// Adds a new named range. - /// - /// Name of the ranges to add. - /// The ranges to add. - /// The comment for the new named range. - IXLNamedRange Add(String rangeName, IXLRanges ranges, String comment); - - /// - /// Deletes the specified named range (not the cells). - /// - /// Name of the range to delete. - void Delete(String rangeName); - - /// - /// Deletes the specified named range's index (not the cells). - /// - /// Index of the named range to delete. - void Delete(Int32 rangeIndex); - - - /// - /// Deletes all named ranges (not the cells). - /// - void DeleteAll(); - - Boolean TryGetValue(String name, out IXLNamedRange range); - - Boolean Contains(String name); - - /// - /// Returns a subset of named ranges that do not have invalid references. - /// - IEnumerable ValidNamedRanges(); - - /// - /// Returns a subset of named ranges that do have invalid references. - /// - IEnumerable InvalidNamedRanges(); - } -} diff --git a/ClosedXML/Excel/NamedRanges/XLNamedRange.cs b/ClosedXML/Excel/NamedRanges/XLNamedRange.cs deleted file mode 100644 index 538fedfe2..000000000 --- a/ClosedXML/Excel/NamedRanges/XLNamedRange.cs +++ /dev/null @@ -1,241 +0,0 @@ -using ClosedXML.Extensions; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ClosedXML.Excel -{ - internal class XLNamedRange : IXLNamedRange - { - private String _name; - private readonly XLNamedRanges _namedRanges; - - internal XLWorkbook Workbook => _namedRanges.Workbook; - - public XLNamedRange(XLNamedRanges namedRanges, String rangeName, String range, String comment = null) - : this(namedRanges, rangeName, validateName: true, range, comment) - { - } - - public XLNamedRange(XLNamedRanges namedRanges, String rangeName, IXLRanges ranges, String comment = null) - : this(namedRanges, rangeName, validateName: true, comment) - { - ranges.ForEach(r => RangeList.Add(r.RangeAddress.ToStringFixed(XLReferenceStyle.A1, true))); - } - - internal XLNamedRange(XLNamedRanges namedRanges, String rangeName, Boolean validateName, String range, String comment) - : this(namedRanges, rangeName, validateName, comment) - { - //TODO range.Split(',') may produce incorrect result if a worksheet name contains comma. Refactoring needed. - range.Split(',').ForEach(r => RangeList.Add(r)); - } - - internal XLNamedRange(XLNamedRanges namedRanges, String rangeName, Boolean validateName, String comment) - { - _namedRanges = namedRanges ?? throw new ArgumentNullException(nameof(namedRanges)); - Visible = true; - - if (validateName) - Name = rangeName; - else - SetNameWithoutValidation(rangeName); - - Comment = comment; - } - - /// - /// Checks if the named range contains invalid references (#REF!). - /// - public bool IsValid - { - get - { - return RangeList.SelectMany(c => c.Split(',')).All(r => - !r.StartsWith("#REF!", StringComparison.OrdinalIgnoreCase) && - !r.EndsWith("#REF!", StringComparison.OrdinalIgnoreCase)); - } - } - - public String Name - { - get { return _name; } - set - { - if (_name == value) return; - - var oldname = _name ?? string.Empty; - - var existingNames = _namedRanges.Select(nr => nr.Name).ToList(); - if (_namedRanges.Scope == XLNamedRangeScope.Workbook) - existingNames.AddRange(_namedRanges.Workbook.NamedRanges.Select(nr => nr.Name)); - - if (_namedRanges.Scope == XLNamedRangeScope.Worksheet) - existingNames.AddRange(_namedRanges.Worksheet.NamedRanges.Select(nr => nr.Name)); - - existingNames = existingNames.Distinct().ToList(); - - if (!XLHelper.ValidateName("named range", value, oldname, existingNames, out String message)) - throw new ArgumentException(message, nameof(value)); - - _name = value; - - if (!String.IsNullOrWhiteSpace(oldname) && !String.Equals(oldname, _name, StringComparison.OrdinalIgnoreCase)) - { - _namedRanges.Delete(oldname); - _namedRanges.Add(_name, this); - } - } - } - - private void SetNameWithoutValidation(string value) - { - _name = value; - } - - public IXLRanges Ranges - { - get - { - var ranges = new XLRanges(); - foreach (var rangeToAdd in - from rangeAddress in RangeList.SelectMany(c => c.Split(',')).Where(s => s[0] != '"') - let match = XLHelper.NamedRangeReferenceRegex.Match(rangeAddress) - select - match.Groups["Sheet"].Success && Workbook.Worksheets.Contains(match.Groups["Sheet"].Value) - ? Workbook.WorksheetsInternal.Worksheet(match.Groups["Sheet"].Value).Range(match.Groups["Range"].Value) as IXLRangeBase - : Workbook.Worksheets.SelectMany(sheet => sheet.Tables).SingleOrDefault(table => table.Name == match.Groups["Table"].Value)? - .DataRange?.Column(match.Groups["Column"].Value)) - { - if (rangeToAdd != null) - ranges.Add(rangeToAdd); - } - return ranges; - } - } - - public String Comment { get; set; } - - public Boolean Visible { get; set; } - - public XLNamedRangeScope Scope { get { return _namedRanges.Scope; } } - - public IXLRanges Add(XLWorkbook workbook, String rangeAddress) - { - var ranges = new XLRanges(); - var byExclamation = rangeAddress.Split('!'); - var wsName = byExclamation[0].Replace("'", ""); - var rng = byExclamation[1]; - var rangeToAdd = workbook.WorksheetsInternal.Worksheet(wsName).Range(rng); - - ranges.Add(rangeToAdd); - return Add(ranges); - } - - public IXLRanges Add(IXLRange range) - { - var ranges = new XLRanges { range }; - return Add(ranges); - } - - public IXLRanges Add(IXLRanges ranges) - { - ranges.ForEach(r => RangeList.Add(r.ToString())); - return ranges; - } - - public void Delete() - { - _namedRanges.Delete(Name); - } - - public void Clear() - { - RangeList.Clear(); - } - - public void Remove(String rangeAddress) - { - RangeList.Remove(rangeAddress); - } - - public void Remove(IXLRange range) - { - RangeList.Remove(range.ToString()); - } - - public void Remove(IXLRanges ranges) - { - ranges.ForEach(r => RangeList.Remove(r.ToString())); - } - - public override string ToString() - { - String retVal = RangeList.Aggregate(String.Empty, (agg, r) => agg + (r + ",")); - if (retVal.Length > 0) retVal = retVal.Substring(0, retVal.Length - 1); - return retVal; - } - - public String RefersTo - { - get { return ToString(); } - set - { - RangeList.Clear(); - RangeList.Add(value); - } - } - - public IXLNamedRange CopyTo(IXLWorksheet targetSheet) - { - if (targetSheet == _namedRanges.Worksheet) - throw new InvalidOperationException("Cannot copy named range to the worksheet it already belongs to."); - - var ranges = new XLRanges(); - foreach (var r in Ranges) - { - if (_namedRanges.Worksheet == r.Worksheet) - // Named ranges on the source worksheet have to point to the new destination sheet - ranges.Add(targetSheet.Range(((XLRangeAddress)r.RangeAddress).WithoutWorksheet())); - else - ranges.Add(r); - } - - return targetSheet.NamedRanges.Add(Name, ranges); - } - - internal IList RangeList { get; set; } = new List(); - - public IXLNamedRange SetRefersTo(String range) - { - RefersTo = range; - return this; - } - - public IXLNamedRange SetRefersTo(IXLRangeBase range) - { - RangeList.Clear(); - RangeList.Add(range.RangeAddress.ToStringFixed(XLReferenceStyle.A1, true)); - return this; - } - - public IXLNamedRange SetRefersTo(IXLRanges ranges) - { - RangeList.Clear(); - ranges.ForEach(r => RangeList.Add(r.RangeAddress.ToStringFixed(XLReferenceStyle.A1, true))); - return this; - } - - internal void OnWorksheetDeleted(string worksheetName) - { - var escapedSheetName = worksheetName.EscapeSheetName(); - RangeList = RangeList - .Select( - rl => string.Join(",", rl - .Split(',') - .Select(r => r.StartsWith(escapedSheetName + "!", StringComparison.OrdinalIgnoreCase) - ? "#REF!" + r.Substring(escapedSheetName.Length + 1) - : r)) - ).ToList(); - } - } -} diff --git a/ClosedXML/Excel/PageSetup/IXLHFItem.cs b/ClosedXML/Excel/PageSetup/IXLHFItem.cs index 5f7fc0e7b..a697da617 100644 --- a/ClosedXML/Excel/PageSetup/IXLHFItem.cs +++ b/ClosedXML/Excel/PageSetup/IXLHFItem.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/PageSetup/IXLHeaderFooter.cs b/ClosedXML/Excel/PageSetup/IXLHeaderFooter.cs index 235875447..459a46a32 100644 --- a/ClosedXML/Excel/PageSetup/IXLHeaderFooter.cs +++ b/ClosedXML/Excel/PageSetup/IXLHeaderFooter.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/PageSetup/IXLMargins.cs b/ClosedXML/Excel/PageSetup/IXLMargins.cs index 148fcfb94..112958ebd 100644 --- a/ClosedXML/Excel/PageSetup/IXLMargins.cs +++ b/ClosedXML/Excel/PageSetup/IXLMargins.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/PageSetup/IXLPageSetup.cs b/ClosedXML/Excel/PageSetup/IXLPageSetup.cs index 388fd862f..3bd0bc0cb 100644 --- a/ClosedXML/Excel/PageSetup/IXLPageSetup.cs +++ b/ClosedXML/Excel/PageSetup/IXLPageSetup.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; @@ -158,7 +160,8 @@ public interface IXLPageSetup /// Gets or sets the page number that will begin the printout. /// For example, the first page of your printout could be numbered page 5. /// - UInt32? FirstPageNumber { get; set; } + /// First page number can be negative, e.g. -2. + Int32? FirstPageNumber { get; set; } /// /// Gets or sets a value indicating whether the worksheet will be centered on the page horizontally. /// @@ -288,7 +291,9 @@ public interface IXLPageSetup IXLPageSetup SetScale(Int32 value); IXLPageSetup SetHorizontalDpi(Int32 value); IXLPageSetup SetVerticalDpi(Int32 value); - IXLPageSetup SetFirstPageNumber(UInt32? value); + /// > + /// First page number or null for auto/default page numbering. + IXLPageSetup SetFirstPageNumber(Int32? value); IXLPageSetup SetCenterHorizontally(); IXLPageSetup SetCenterHorizontally(Boolean value); IXLPageSetup SetCenterVertically(); IXLPageSetup SetCenterVertically(Boolean value); IXLPageSetup SetPaperSize(XLPaperSize value); diff --git a/ClosedXML/Excel/PageSetup/IXLPrintAreas.cs b/ClosedXML/Excel/PageSetup/IXLPrintAreas.cs index 8f04642ad..6bb0d287e 100644 --- a/ClosedXML/Excel/PageSetup/IXLPrintAreas.cs +++ b/ClosedXML/Excel/PageSetup/IXLPrintAreas.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/PageSetup/XLHFItem.cs b/ClosedXML/Excel/PageSetup/XLHFItem.cs index f46500c1a..b8bf4a286 100644 --- a/ClosedXML/Excel/PageSetup/XLHFItem.cs +++ b/ClosedXML/Excel/PageSetup/XLHFItem.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Text; @@ -40,7 +42,7 @@ public IXLRichString AddText(XLHFPredefinedText predefinedText) public IXLRichString AddText(String text, XLHFOccurrence occurrence) { - XLRichString richText = new XLRichString(text, this.HeaderFooter.Worksheet.Style.Font, this); + var richText = new XLRichString(text, HeaderFooter.Worksheet.Style.Font, this, null); var hfText = new XLHFText(richText, this); if (occurrence == XLHFOccurrence.AllPages) diff --git a/ClosedXML/Excel/PageSetup/XLHFText.cs b/ClosedXML/Excel/PageSetup/XLHFText.cs index 6659cd67c..d3669b9e8 100644 --- a/ClosedXML/Excel/PageSetup/XLHFText.cs +++ b/ClosedXML/Excel/PageSetup/XLHFText.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Text; diff --git a/ClosedXML/Excel/PageSetup/XLHeaderFooter.cs b/ClosedXML/Excel/PageSetup/XLHeaderFooter.cs index 2290db06d..0dddcdede 100644 --- a/ClosedXML/Excel/PageSetup/XLHeaderFooter.cs +++ b/ClosedXML/Excel/PageSetup/XLHeaderFooter.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; @@ -5,9 +7,8 @@ namespace ClosedXML.Excel { using System.Linq; - internal class XLHeaderFooter: IXLHeaderFooter + internal class XLHeaderFooter : IXLHeaderFooter { - public XLHeaderFooter(XLWorksheet worksheet) { this.Worksheet = worksheet; @@ -21,9 +22,9 @@ public XLHeaderFooter(XLHeaderFooter defaultHF, XLWorksheet worksheet) { this.Worksheet = worksheet; defaultHF.innerTexts.ForEach(kp => innerTexts.Add(kp.Key, kp.Value)); - Left = new XLHFItem(defaultHF.Left as XLHFItem, this); - Center = new XLHFItem(defaultHF.Center as XLHFItem, this); - Right = new XLHFItem(defaultHF.Right as XLHFItem, this); + Left = new XLHFItem((XLHFItem)defaultHF.Left, this); + Center = new XLHFItem((XLHFItem)defaultHF.Center, this); + Right = new XLHFItem((XLHFItem)defaultHF.Right, this); SetAsInitial(); } @@ -72,7 +73,7 @@ private struct ParsedHeaderFooterElement public string Text; } - private static IEnumerable ParseFormattedHeaderFooterText(string text) + private static List ParseFormattedHeaderFooterText(string text) { Func IsAtPositionIndicator = i => i < text.Length - 1 && text[i] == '&' && (new char[] { 'L', 'C', 'R' }.Contains(text[i + 1])); @@ -84,7 +85,7 @@ private static IEnumerable ParseFormattedHeaderFooter { if (IsAtPositionIndicator(i)) { - if ("" != hfElement) parsedElements.Add(new ParsedHeaderFooterElement() + if (hfElement.Length > 0) parsedElements.Add(new ParsedHeaderFooterElement() { Position = currentPosition, Text = hfElement @@ -104,7 +105,7 @@ private static IEnumerable ParseFormattedHeaderFooter } } - if ("" != hfElement) + if (hfElement.Length > 0) parsedElements.Add(new ParsedHeaderFooterElement() { Position = currentPosition, diff --git a/ClosedXML/Excel/PageSetup/XLMargins.cs b/ClosedXML/Excel/PageSetup/XLMargins.cs index 964354280..1ff4c8844 100644 --- a/ClosedXML/Excel/PageSetup/XLMargins.cs +++ b/ClosedXML/Excel/PageSetup/XLMargins.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/PageSetup/XLPageSetup.cs b/ClosedXML/Excel/PageSetup/XLPageSetup.cs index ecb54300d..56231fa66 100644 --- a/ClosedXML/Excel/PageSetup/XLPageSetup.cs +++ b/ClosedXML/Excel/PageSetup/XLPageSetup.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -111,7 +113,7 @@ public void SetColumnsToRepeatAtLeft(Int32 firstColumnToRepeatAtLeft, Int32 last public XLPaperSize PaperSize { get; set; } public Int32 HorizontalDpi { get; set; } public Int32 VerticalDpi { get; set; } - public UInt32? FirstPageNumber { get; set; } + public Int32? FirstPageNumber { get; set; } public Boolean CenterHorizontally { get; set; } public Boolean CenterVertically { get; set; } public XLPrintErrorValues PrintErrorValue { get; set; } @@ -224,7 +226,7 @@ public void AddVerticalPageBreak(Int32 column) public IXLPageSetup SetScale(Int32 value) { Scale = value; return this; } public IXLPageSetup SetHorizontalDpi(Int32 value) { HorizontalDpi = value; return this; } public IXLPageSetup SetVerticalDpi(Int32 value) { VerticalDpi = value; return this; } - public IXLPageSetup SetFirstPageNumber(UInt32? value) { FirstPageNumber = value; return this; } + public IXLPageSetup SetFirstPageNumber(Int32? value) { FirstPageNumber = value; return this; } public IXLPageSetup SetCenterHorizontally() { CenterHorizontally = true; return this; } public IXLPageSetup SetCenterHorizontally(Boolean value) { CenterHorizontally = value; return this; } public IXLPageSetup SetCenterVertically() { CenterVertically = true; return this; } public IXLPageSetup SetCenterVertically(Boolean value) { CenterVertically = value; return this; } public IXLPageSetup SetPaperSize(XLPaperSize value) { PaperSize = value; return this; } diff --git a/ClosedXML/Excel/PageSetup/XLPrintAreas.cs b/ClosedXML/Excel/PageSetup/XLPrintAreas.cs index 6aea4b9a2..1eabd4cba 100644 --- a/ClosedXML/Excel/PageSetup/XLPrintAreas.cs +++ b/ClosedXML/Excel/PageSetup/XLPrintAreas.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Collections.Generic; using System.Linq; diff --git a/ClosedXML/Excel/Patterns/Quadrant.cs b/ClosedXML/Excel/Patterns/Quadrant.cs index cc6cc624b..352d05b15 100644 --- a/ClosedXML/Excel/Patterns/Quadrant.cs +++ b/ClosedXML/Excel/Patterns/Quadrant.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; using System.Linq; @@ -19,7 +21,7 @@ internal class Quadrant /// /// Smaller quadrants which the current one is split to. Is NULL until ranges are added to child quadrants. /// - public IEnumerable Children { get; private set; } + public IReadOnlyList Children { get; private set; } /// /// The level of current quadrant. Top most has level 0, child quadrants has levels (Level + 1). diff --git a/ClosedXML/Excel/PivotTables/Areas/FieldIndex.cs b/ClosedXML/Excel/PivotTables/Areas/FieldIndex.cs new file mode 100644 index 000000000..9a5aa9640 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/FieldIndex.cs @@ -0,0 +1,40 @@ +using System; +using System.Diagnostics; + +namespace ClosedXML.Excel; + +/// +/// A type for field index, so there is a better idea what is a semantic content of some +/// variable/props. Not detrimental to performance, JIT will inline struct to int. +/// +[DebuggerDisplay("{Value}")] +internal readonly record struct FieldIndex +{ + internal FieldIndex(int value) + { + if (value < 0 && value != -2) + throw new ArgumentOutOfRangeException(); + + Value = value; + } + + /// + /// The index of a 'data' field (). + /// + internal static FieldIndex DataField => -2; + + /// + /// Index of a field in . Can be -2 for 'data' field, + /// otherwise non-negative. + /// + internal int Value { get; } + + /// + /// Is this index for a 'data' field? + /// + internal bool IsDataField => Value == -2; + + public static implicit operator int(FieldIndex index) => index.Value; + + public static implicit operator FieldIndex(int index) => new(index); +} diff --git a/ClosedXML/Excel/PivotTables/Areas/IXLPivotField.cs b/ClosedXML/Excel/PivotTables/Areas/IXLPivotField.cs new file mode 100644 index 000000000..4903e0330 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/IXLPivotField.cs @@ -0,0 +1,146 @@ +#nullable disable + +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel +{ + public enum XLSubtotalFunction + { + Automatic, + Sum, + Count, + Average, + Maximum, + Minimum, + Product, + CountNumbers, + StandardDeviation, + PopulationStandardDeviation, + Variance, + PopulationVariance, + } + + public enum XLPivotLayout { Outline, Tabular, Compact } + + /// + /// A fluent API representation of a field on an row, + /// column or + /// filter axis of a . + /// + /// + /// If the field is a 'data' field, a lot of properties don't make sense and can't be set. In + /// such case, the setter will throw and getter + /// will return default value for the field. + /// + public interface IXLPivotField + { + /// + /// + /// Name of the field in a pivot table . If the field + /// is 'data' field, return . + /// + /// + /// Note that field name in pivot cache is generally same as in the source data range, but + /// not always. Field names are unique in the cache and if the source data range contains + /// duplicate column names, the cache will rename them to keep all names unique. + /// + /// + String SourceName { get; } + + /// + /// of the field in the pivot table. Custom name is a unique + /// across all fields used in the pivot table (e.g. if same field is added to values area + /// multiple times, it must have custom name, e.g. Sum1 of Field, + /// Sum2 of Field). + /// + /// When setting name to a name that is already used by + /// another field. + String CustomName { get; set; } + + String SubtotalCaption { get; set; } + + /// + /// Get subtotals of the field. The content of the collection depends on the type of subtotal: + /// + /// None - the collection is empty. + /// Automatic - the collection contains one element with function . + /// Custom - the collection contains a set of functions (at least one) except the . + /// + /// + IReadOnlyCollection Subtotals { get; } + Boolean IncludeNewItemsInFilter { get; set; } + Boolean Outline { get; set; } + Boolean Compact { get; set; } + Boolean? SubtotalsAtTop { get; set; } + Boolean RepeatItemLabels { get; set; } + Boolean InsertBlankLines { get; set; } + Boolean ShowBlankItems { get; set; } + Boolean InsertPageBreaks { get; set; } + + /// + /// Are all items of the field collapsed? + /// + /// + /// If only a subset of items is collapsed, getter returns false. + /// + Boolean Collapsed { get; set; } + + XLPivotSortType SortType { get; set; } + + /// + IXLPivotField SetCustomName(String value); + + IXLPivotField SetSubtotalCaption(String value); + + IXLPivotField AddSubtotal(XLSubtotalFunction value); + + IXLPivotField SetIncludeNewItemsInFilter(Boolean value = true); + + IXLPivotField SetLayout(XLPivotLayout value); + + IXLPivotField SetSubtotalsAtTop(Boolean value = true); + + IXLPivotField SetRepeatItemLabels(Boolean value = true); + + IXLPivotField SetInsertBlankLines(Boolean value = true); + + IXLPivotField SetShowBlankItems(Boolean value = true); + + IXLPivotField SetInsertPageBreaks(Boolean value = true); + + IXLPivotField SetCollapsed(Boolean value = true); + + IXLPivotField SetSort(XLPivotSortType value); + + /// + /// Selected values for filter of the pivot + /// table. Empty for non-filter fields. + /// + IReadOnlyList SelectedValues { get; } + + /// + /// Add a value to selected values of a filter field (). + /// Doesn't do anything, if this field is not a filter fields. + /// + IXLPivotField AddSelectedValue(XLCellValue value); + + /// + /// Add a values to a selected values of a filter field. Doesn't do anything if this field + /// is not a filter fields. + /// + IXLPivotField AddSelectedValues(IEnumerable values); + + IXLPivotFieldStyleFormats StyleFormats { get; } + + Boolean IsOnRowAxis { get; } + Boolean IsOnColumnAxis { get; } + Boolean IsInFilterList { get; } + + /// + /// Index of a field in all pivot fields or -2 + /// for data field. + /// + Int32 Offset { get; } + } +} diff --git a/ClosedXML/Excel/PivotTables/Areas/IXLPivotFields.cs b/ClosedXML/Excel/PivotTables/Areas/IXLPivotFields.cs new file mode 100644 index 000000000..1ea104f52 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/IXLPivotFields.cs @@ -0,0 +1,88 @@ +// Keep this file CodeMaid organised and cleaned +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel; + +/// +/// +/// A collection of fields on column labels, +/// row labels or +/// report filters of a +/// . +/// +/// +public interface IXLPivotFields : IEnumerable +{ + IXLPivotField Add(String sourceName); + + /// + /// Add a field to the axis labels/report filters. + /// + /// Name of the field in . The value can + /// also be for + /// ΣValues field. + /// Display name of added field. Custom name of a filed must be unique + /// in pivot table. Ignored for 'data' field. + /// The added field. + /// Field can't be added (e.g. it is already used or can't + /// be added to specific collection). + IXLPivotField Add(String sourceName, String customName); + + /// + /// Remove all fields from the axis. It also removes data of removed fields, like custom names and items. + /// + void Clear(); + + /// + /// Does this axis contain a field? + /// + /// Name of the field in . Use + /// for data field. + /// true if the axis contains the field, false otherwise. + Boolean Contains(String sourceName); + + /// + /// Does this axis contain a field? + /// + /// Checked pivot field. + /// true if the axis contains the field, false otherwise. + Boolean Contains(IXLPivotField pivotField); + + /// + /// Get a field in the axis. + /// + /// Name of the field in we are looking + /// for in the axis. + /// Found field. + /// Axis doesn't contain field with specified name. + IXLPivotField Get(String sourceName); + + /// + /// Get field by index in the collection. + /// + /// Index of the field in this collection. + /// Found field. + /// + IXLPivotField Get(Int32 index); + + /// + /// Get index of a field in the collection. Use the index in the method. + /// + /// of the field in the pivot cache. + /// Index of the field or -1 if not found. + Int32 IndexOf(String sourceName); + + /// + /// Get index of a field in the collection. Use the index in the method. + /// + /// Field to find. Uses . + /// Index of the field or -1 if not a member of this collection. + Int32 IndexOf(IXLPivotField pf); + + /// + /// Remove a field from axis. Doesn't throw, if field is not present. + /// + /// of a field to remove. + void Remove(String sourceName); +} diff --git a/ClosedXML/Excel/PivotTables/Areas/XLPivotAxis.cs b/ClosedXML/Excel/PivotTables/Areas/XLPivotAxis.cs new file mode 100644 index 000000000..66b8636af --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/XLPivotAxis.cs @@ -0,0 +1,16 @@ +namespace ClosedXML.Excel; + +/// +/// Describes an axis of a pivot table. Used to determine which areas should be styled through +/// . +/// +/// +/// [ISO-29500] 18.18.1 ST_Axis(PivotTable Axis). +/// +internal enum XLPivotAxis +{ + AxisRow, + AxisCol, + AxisPage, + AxisValues, +} diff --git a/ClosedXML/Excel/PivotTables/Areas/XLPivotDataField.cs b/ClosedXML/Excel/PivotTables/Areas/XLPivotDataField.cs new file mode 100644 index 000000000..e09dc803e --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/XLPivotDataField.cs @@ -0,0 +1,303 @@ +using System; +using System.Diagnostics; + +namespace ClosedXML.Excel; + +/// +/// A field that describes calculation of value to display in the +/// area of pivot table. +/// +internal class XLPivotDataField : IXLPivotValue +{ + private const int BaseFieldDefaultValue = -1; + private const int BaseItemPreviousValue = 1048828; + private const int BaseItemNextValue = 1048829; + private const int BaseItemDefaultValue = 1048832; + + private readonly XLPivotTable _pivotTable; + + private int _baseField = BaseFieldDefaultValue; + private uint _baseItem = BaseItemDefaultValue; + private XLPivotCalculation _showDataAsFormat = XLPivotCalculation.Normal; + private XLPivotSummary _subtotal = XLPivotSummary.Sum; + + internal XLPivotDataField(XLPivotTable pivotTable, int field) + { + _pivotTable = pivotTable; + Field = field; + } + + /// + /// Custom name of the data field (e.g. Sum of Sold). Can be left empty to keep same + /// as source name. Use to get value with fallback. + /// + /// + /// For data fields, the name is duplicated at and here. + /// This property has a preference. + /// + internal string? DataFieldName { get; set; } + + /// + /// Field index to . + /// + /// + /// Unlike axis, this field index can't be -2 for data fields. That field can't be in + /// the data area. + /// + internal int Field { get; } + + /// + /// An aggregation function that calculates the value to display in the data cells of pivot area. + /// + public XLPivotSummary Subtotal + { + get => _subtotal; + set => _subtotal = value; + } + + /// + /// A calculation takes value calculated by aggregation and transforms + /// it into the final value to display to the user. The calculation might need + /// and/or . + /// + public XLPivotCalculation ShowDataAsFormat + { + get => _showDataAsFormat; + init => _showDataAsFormat = value; + } + + /// + /// Index to the base field () when + /// needs a field for its calculation. + /// + public int BaseField + { + get => _baseField; + init => _baseField = value; + } + + /// + /// Index to the base item of when needs + /// an item for its calculation. + /// + public uint BaseItem + { + get => _baseItem; + init => _baseItem = value; + } + + /// + /// Formatting to apply to the data field. If disagree, this has precedence. + /// + internal XLNumberFormatValue? NumberFormatValue { get; set; } + + #region IXLPivotValue + + public string? BaseFieldName + { + get + { + var sourceNames = _pivotTable.PivotCache.FieldNames; + if (_baseField < 0 || _baseField >= sourceNames.Count) + return null; + + return sourceNames[_baseField]; + } + set + { + if (value is null) + { + _baseField = BaseFieldDefaultValue; + return; + } + + if (!_pivotTable.TryGetSourceNameFieldIndex(value, out var index)) + { + throw new ArgumentOutOfRangeException($"Source name '{value}' not found."); + } + + _baseField = index; + } + } + + public XLCellValue BaseItemValue + { + get + { + var baseFieldSpecified = _baseField != BaseFieldDefaultValue; + if (!baseFieldSpecified) + return Blank.Value; + + var baseItemSpecified = _baseItem != BaseItemDefaultValue; + if (!baseItemSpecified) + return Blank.Value; + + if (_baseItem == BaseItemPreviousValue) + return Blank.Value; + + if (_baseItem == BaseItemNextValue) + return Blank.Value; + + var baseField = _pivotTable.PivotFields[_baseField]; + var fieldItem = baseField.Items[checked((int)BaseItem)]; + return fieldItem.GetValue() ?? Blank.Value; + } + set + { + if (_baseField == BaseItemDefaultValue) + throw new InvalidOperationException("Base field not specified for the field."); + + var field = _pivotTable.PivotFields[_baseField]; + var fieldItem = field.GetOrAddItem(value); + var itemIndex = fieldItem.ItemIndex ?? BaseFieldDefaultValue; + _baseItem = checked((uint)itemIndex); + } + } + + public XLPivotCalculation Calculation + { + get => ShowDataAsFormat; + set => _showDataAsFormat = value; + } + + public XLPivotCalculationItem CalculationItem + { + get + { + return _baseItem switch + { + BaseItemPreviousValue => XLPivotCalculationItem.Previous, + BaseItemNextValue => XLPivotCalculationItem.Next, + _ => XLPivotCalculationItem.Value, + }; + } + set + { + switch (value) + { + case XLPivotCalculationItem.Previous: + _baseItem = BaseItemPreviousValue; + break; + case XLPivotCalculationItem.Next: + _baseItem = BaseItemNextValue; + break; + case XLPivotCalculationItem.Value: + // Calculation value should be set in tandem with the base item value. + // Base item other than prev/next special constants is implicitly a value. + if (BaseItem is BaseItemPreviousValue or BaseItemNextValue) + { + // If value is not yet set, just use unspecified value. User should + // set value by calling `BaseItemValue` after calling this, but Excel + // accepts valid base field with unspecified item without need to repair. + _baseItem = BaseItemDefaultValue; + } + + // When base item is not a valid reference to the field.Items, Excel + // tries to repair the workbook, so user should always set base value. + break; + default: + throw new UnreachableException(); + } + } + } + + public string CustomName + { + get => DataFieldName ?? _pivotTable.PivotFields[Field].Name ?? _pivotTable.PivotCache.FieldNames[Field]; + set => DataFieldName = value; + } + + public IXLPivotValueFormat NumberFormat => new XLPivotValueFormat(this); + + public string SourceName => _pivotTable.PivotCache.FieldNames[Field]; + + public XLPivotSummary SummaryFormula + { + get => Subtotal; + set => _subtotal = value; + } + + public IXLPivotValue SetBaseFieldName(string value) + { + BaseFieldName = value; + return this; + } + + public IXLPivotValue SetBaseItemValue(XLCellValue value) + { + BaseItemValue = value; + return this; + } + + public IXLPivotValue SetCalculation(XLPivotCalculation value) + { + Calculation = value; + return this; + } + + public IXLPivotValue SetCalculationItem(XLPivotCalculationItem value) + { + CalculationItem = value; + return this; + } + + public IXLPivotValue SetSummaryFormula(XLPivotSummary value) + { + SummaryFormula = value; + return this; + } + + public IXLPivotValueCombination ShowAsDifferenceFrom(string fieldSourceName) + { + BaseFieldName = fieldSourceName; + SetCalculation(XLPivotCalculation.DifferenceFrom); + return new XLPivotValueCombination(this); + } + + public IXLPivotValue ShowAsIndex() + { + return SetCalculation(XLPivotCalculation.Index); + } + + public IXLPivotValue ShowAsNormal() + { + return SetCalculation(XLPivotCalculation.Normal); + } + + public IXLPivotValueCombination ShowAsPercentageDifferenceFrom(string fieldSourceName) + { + BaseFieldName = fieldSourceName; + SetCalculation(XLPivotCalculation.PercentageDifferenceFrom); + return new XLPivotValueCombination(this); + } + + public IXLPivotValueCombination ShowAsPercentageFrom(string fieldSourceName) + { + BaseFieldName = fieldSourceName; + SetCalculation(XLPivotCalculation.PercentageOf); + return new XLPivotValueCombination(this); + } + + public IXLPivotValue ShowAsPercentageOfColumn() + { + return SetCalculation(XLPivotCalculation.PercentageOfColumn); + } + + public IXLPivotValue ShowAsPercentageOfRow() + { + return SetCalculation(XLPivotCalculation.PercentageOfRow); + } + + public IXLPivotValue ShowAsPercentageOfTotal() + { + return SetCalculation(XLPivotCalculation.PercentageOfTotal); + } + + public IXLPivotValue ShowAsRunningTotalIn(string fieldSourceName) + { + BaseFieldName = fieldSourceName; + return SetCalculation(XLPivotCalculation.RunningTotal); + } + + #endregion IXPivotValue +} diff --git a/ClosedXML/Excel/PivotTables/Areas/XLPivotDataFields.cs b/ClosedXML/Excel/PivotTables/Areas/XLPivotDataFields.cs new file mode 100644 index 000000000..730f9f788 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/XLPivotDataFields.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel; + +/// +/// A collection of . +/// +internal class XLPivotDataFields : IXLPivotValues, IReadOnlyCollection +{ + private readonly XLPivotTable _pivotTable; + + /// + /// Fields displayed in the data area of the pivot table, in the order fields are displayed. + /// + private readonly List _fields = new(); + + internal XLPivotDataFields(XLPivotTable pivotTable) + { + _pivotTable = pivotTable; + } + + public int Count => _fields.Count; + + #region IXLPivotValues + + public IXLPivotValue Add(string sourceName) + { + return AddField(sourceName, sourceName); + } + + public IXLPivotValue Add(string sourceName, string customName) + { + return AddField(sourceName, customName); + } + + public void Clear() + { + _fields.Clear(); + foreach (var field in _fields) + _pivotTable.RemoveFieldFromAxis(field.Field); + } + + public bool Contains(string customName) + { + return IndexOf(customName) != -1; + } + + public bool Contains(IXLPivotValue pivotValue) + { + return Contains(pivotValue.CustomName); + } + + public IXLPivotValue Get(string customName) + { + var dataField = _fields.SingleOrDefault(x => XLHelper.NameComparer.Equals(x.CustomName, customName)); + if (dataField is null) + { + throw new KeyNotFoundException($"Unable to find data field for '{customName}'."); + } + + return dataField; + } + + public IXLPivotValue Get(int index) + { + return _fields[index]; + } + + public int IndexOf(string customName) + { + return _fields.FindIndex(x => XLHelper.NameComparer.Equals(x.CustomName, customName)); + } + + public int IndexOf(IXLPivotValue pivotValue) + { + return IndexOf(pivotValue.CustomName); + } + + public void Remove(string customName) + { + var index = IndexOf(customName); + if (index == -1) + return; + + var dataField = _fields[index]; + _pivotTable.RemoveFieldFromAxis(dataField.Field); + _fields.Remove(dataField); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + #endregion + + internal XLPivotDataField AddField(string sourceName, string? customName) + { + if (!_pivotTable.TryGetSourceNameFieldIndex(sourceName, out var fieldIndex)) + { + var validNames = string.Join("','", _pivotTable.PivotCache.FieldNames); + throw new ArgumentOutOfRangeException(nameof(sourceName), $"Field '{sourceName}' is not in the fields of a pivot cache. Should be one of '{validNames}'."); + } + + if (fieldIndex.IsDataField) + throw new ArgumentException("'Values' field can be used only on row or column axis."); + + var dataField = new XLPivotDataField(_pivotTable, fieldIndex.Value) + { + DataFieldName = customName, + }; + AddField(dataField); + + // If there are multiple values, at least axis must contain 'data' field. + // Otherwise, Excel requires a repair. + if (_fields.Count > 1 && + !_pivotTable.RowAxis.ContainsDataField && + !_pivotTable.ColumnAxis.ContainsDataField) + { + _pivotTable.ColumnLabels.Add(XLConstants.PivotTable.ValuesSentinalLabel); + } + + return dataField; + } + + internal void AddField(XLPivotDataField dataField) + { + // Excel invariant - data field must have the flag if and only if it is in the data fields collection. + _fields.Add(dataField); + _pivotTable.PivotFields[dataField.Field].DataField = true; + } + + public IEnumerator GetEnumerator() + { + return _fields.GetEnumerator(); + } +} diff --git a/ClosedXML/Excel/PivotTables/Areas/XLPivotFieldAxisItem.cs b/ClosedXML/Excel/PivotTables/Areas/XLPivotFieldAxisItem.cs new file mode 100644 index 000000000..53c90342a --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/XLPivotFieldAxisItem.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel; + +/// +/// A representation of a single row/column axis values in a . +/// +/// +/// Represents 18.10.1.44 i (Row Items) and 18.10.1.96 x (Member Property Index). +/// +internal class XLPivotFieldAxisItem +{ + public XLPivotFieldAxisItem(XLPivotItemType itemType, int dataItem, IEnumerable fieldItems) + { + ItemType = itemType; + DataItem = dataItem; + FieldItem = fieldItems.ToList(); + } + + /// + /// Each item is an index to field items of corresponding field from + /// . Value 1048832 specifies that no item appears + /// at the position. + /// + internal List FieldItem { get; } + + /// + /// Type of item. + /// + internal XLPivotItemType ItemType { get; } + + /// + /// If this item (row/column) contains 'data' field, this contains an index into the + /// that should be used as a value. The value for 'data' field in the is ignored, but Excel fills + /// same number as this index. + /// + internal int DataItem { get; } +} diff --git a/ClosedXML/Excel/PivotTables/Areas/XLPivotFieldItem.cs b/ClosedXML/Excel/PivotTables/Areas/XLPivotFieldItem.cs new file mode 100644 index 000000000..8e6778932 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/XLPivotFieldItem.cs @@ -0,0 +1,109 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace ClosedXML.Excel; + +/// +/// Representation of item (basically one value of a field). Each value used somewhere in pivot +/// table (e.g. data area, row/column labels and so on) must have an entry here. By itself, it +/// doesn't contain values, it only references shared items of the field in the +/// . +/// +/// +/// [OI29500] 18.10.1.45 item (PivotTable Field Item). +/// +internal class XLPivotFieldItem +{ + private readonly XLPivotTable _pivotTable; + private readonly XLPivotTableField _pivotField; + + internal XLPivotFieldItem(XLPivotTableField pivotField, int? itemIndex) + { + if (itemIndex.HasValue && itemIndex.Value < 0) + throw new ArgumentOutOfRangeException(nameof(itemIndex)); + + _pivotField = pivotField; + _pivotTable = pivotField.PivotTable; + ItemIndex = itemIndex; + } + + #region XML attributes + + /// + /// If present, must be unique within the containing field items. + /// + internal string? ItemUserCaption { get; init; } + + internal XLPivotItemType ItemType { get; init; } = XLPivotItemType.Data; + + /// + /// + /// Flag indicating the item is hidden. Used for . When + /// item field is a page field, the hidden flag mean unselected values in the page filter. + /// Non-hidden values are selected in the filter. + /// + /// + /// Allowed for non-OLAP pivot tables only. + /// + /// + internal bool Hidden { get; set; } = false; + + /// + /// Flag indicating that the item has a character value. + /// + /// Allowed for OLAP pivot tables only. + internal bool ValueIsString { get; init; } = false; + + /// + /// Excel uses the sd attribute to indicate whether the item is expanded. + /// + /// Allowed for non-OLAP pivot tables only. Spec for the sd had to be patched.. + internal bool ShowDetails { get; set; } = true; + + /// Allowed for non-OLAP pivot tables only. + internal bool CalculatedMember { get; init; } = false; + + /// + /// Item itself is missing from the source data + /// + /// Allowed for non-OLAP pivot tables only. + internal bool Missing { get; init; } = false; + + /// Allowed for OLAP pivot tables only. + internal bool ApproximatelyHasChildren { get; init; } = false; + + /// + /// Index to an item in the sharedItems of the field. The index must be unique in containing field items. When is , it must be set. + /// Never negative. + /// + internal int? ItemIndex { get; } + + /// Allowed for OLAP pivot tables only. + internal bool DrillAcrossAttributes { get; init; } = true; + + /// + /// Attributes sd (show detail) and d (detail) were swapped in spec, fixed by OI29500. + /// A flag that indicates whether details are hidden for this item? + /// + /// d attribute. Allowed for OLAP pivot tables only. + internal bool Details { get; init; } + + #endregion XML attributes + + [MemberNotNullWhen(true, nameof(ItemIndex))] + private bool ValueIsData => ItemType == XLPivotItemType.Data; + + /// + /// Get value of an item from cache or null if not data item. + /// + internal XLCellValue? GetValue() + { + if (!ValueIsData) + return null; + + var fieldIndex = _pivotTable.GetFieldIndex(_pivotField); + var sharedItems = _pivotTable.PivotCache.GetFieldSharedItems(fieldIndex); + var itemIndex = ItemIndex.Value; + return sharedItems[checked((uint)itemIndex)]; + } +} diff --git a/ClosedXML/Excel/PivotTables/Areas/XLPivotTableAxis.cs b/ClosedXML/Excel/PivotTables/Areas/XLPivotTableAxis.cs new file mode 100644 index 000000000..348f41659 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/XLPivotTableAxis.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel; + +/// +/// A description of one axis (/) +/// of a . It consists of fields in a specific order and values that make up +/// individual rows/columns of the axis. +/// +/// +/// [ISO-29500] 18.10.1.17 colItems (Column Items), 18.10.1.84 rowItems (Row Items). +/// +internal class XLPivotTableAxis : IXLPivotFields +{ + private readonly XLPivotTable _pivotTable; + + private readonly XLPivotAxis _axis; + + /// + /// Fields displayed on the axis, in the order of the fields on the axis. + /// + private readonly List _fields = new(); + + /// + /// Values of one row/column in an axis. Items are not kept in sync with . + /// + private readonly List _axisItems = new(); + + internal XLPivotTableAxis(XLPivotTable pivotTable, XLPivotAxis axis) + { + _pivotTable = pivotTable; + _axis = axis; + } + + /// + /// A list of fields to displayed on the axis. It determines which fields and in what order + /// should the fields be displayed. + /// + internal IReadOnlyList Fields => _fields; + + /// + /// Individual row/column parts of the axis. + /// + internal IReadOnlyList Items => _axisItems; + + internal bool ContainsDataField => _fields.Any(x => x.IsDataField); + + IXLPivotField IXLPivotFields.Add(String sourceName) => Add(sourceName, sourceName); + + IXLPivotField IXLPivotFields.Add(String sourceName, String customName) => Add(sourceName, customName); + + void IXLPivotFields.Clear() => Clear(); + + Boolean IXLPivotFields.Contains(String sourceName) => Contains(sourceName); + + Boolean IXLPivotFields.Contains(IXLPivotField pivotField) => Contains(pivotField.SourceName); + + IXLPivotField IXLPivotFields.Get(String sourceName) + { + if (!_pivotTable.TryGetSourceNameFieldIndex(sourceName, out var index) || + !_fields.Contains(index)) + throw new KeyNotFoundException($"Field with source name '{sourceName}' not found in {_axis}."); + + return new XLPivotTableAxisField(_pivotTable, index); + } + + IXLPivotField IXLPivotFields.Get(Int32 index) + { + if (index < 0 || index >= _fields.Count) + throw new IndexOutOfRangeException(); + + return new XLPivotTableAxisField(_pivotTable, _fields[index]); + } + + Int32 IXLPivotFields.IndexOf(String sourceName) + { + return IndexOf(sourceName); + } + + Int32 IXLPivotFields.IndexOf(IXLPivotField pf) + { + return IndexOf(pf.SourceName); + } + + void IXLPivotFields.Remove(String sourceName) + { + var index = IndexOf(sourceName); + if (index == -1) + return; + + _pivotTable.RemoveFieldFromAxis(_fields[index]); + _fields.RemoveAt(index); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IEnumerator GetEnumerator() + { + foreach (var fieldIndex in _fields) + yield return new XLPivotTableAxisField(_pivotTable, fieldIndex); + } + + internal int IndexOf(FieldIndex index) + { + return _fields.IndexOf(index); + } + + internal bool Contains(string sourceName) + { + if (!_pivotTable.TryGetSourceNameFieldIndex(sourceName, out var index)) + return false; + + return _fields.Contains(index); + } + + /// + /// Add field to the axis, as an index. + /// + internal void AddField(FieldIndex fieldIndex) + { + if (_pivotTable.IsFieldUsedOnAxis(fieldIndex)) + throw new ArgumentException("Field is already used on an axis."); + + _fields.Add(fieldIndex); + } + + private XLPivotTableAxisField Add(String sourceName, String customName) + { + var field = AddField(sourceName, customName); + + // Excel by default adds a subtotal, but previous versions of ClosedXML didn't have them, + // so keep API behavior. + if (field.Offset != FieldIndex.DataField.Value) + _pivotTable.PivotFields[field.Offset].RemoveSubtotal(XLSubtotalFunction.Automatic); + + return field; + } + + internal XLPivotTableAxisField AddField(String sourceName, String customName) + { + var index = _pivotTable.AddFieldToAxis(sourceName, customName, _axis); + _fields.Add(index); + return new XLPivotTableAxisField(_pivotTable, index); + } + + /// + /// Add a row/column axis values (i.e. values visible on the axis). + /// + internal void AddItem(XLPivotFieldAxisItem axisItem) + { + _axisItems.Add(axisItem); + } + + internal void Clear() + { + foreach (var fieldIndex in _fields) + _pivotTable.RemoveFieldFromAxis(fieldIndex); + + _axisItems.Clear(); + _fields.Clear(); + } + + private Int32 IndexOf(String sourceName) + { + if (!_pivotTable.TryGetSourceNameFieldIndex(sourceName, out var fieldIndex)) + return -1; + + return _fields.IndexOf(fieldIndex); + } +} diff --git a/ClosedXML/Excel/PivotTables/Areas/XLPivotTableAxisField.cs b/ClosedXML/Excel/PivotTables/Areas/XLPivotTableAxisField.cs new file mode 100644 index 000000000..e024fa579 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/XLPivotTableAxisField.cs @@ -0,0 +1,266 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel; + +/// +/// A fluent API for one field in , either +/// or . +/// +internal class XLPivotTableAxisField : IXLPivotField +{ + private readonly XLPivotTable _pivotTable; + private readonly FieldIndex _index; + + internal XLPivotTableAxisField(XLPivotTable pivotTable, FieldIndex index) + { + _pivotTable = pivotTable; + _index = index; + } + + #region IXLPivotField memebers + + public string SourceName + { + get + { + if (_index.IsDataField) + return XLConstants.PivotTable.ValuesSentinalLabel; + + return _pivotTable.PivotCache.FieldNames[_index]; + } + } + + public string CustomName + { + get => GetFieldValue(f => f.Name, _pivotTable.DataCaption); + set + { + if (_index.IsDataField) + { + _pivotTable.DataCaption = value; + return; + } + + if (_pivotTable.TryGetCustomNameFieldIndex(value, out var idx) && idx != _index) + throw new ArgumentException($"Custom name '{value}' is already used by another field."); + + _pivotTable.PivotFields[_index].Name = value; + } + } + + public string SubtotalCaption + { + get => GetFieldValue(f => f.SubtotalCaption, string.Empty); + set => GetField().SubtotalCaption = value; + } + + public IReadOnlyCollection Subtotals + { + get + { + var subtotal = GetField().Subtotals; + var isCustomSubtotal = subtotal.Count > 1 || (subtotal.Count > 0 && !subtotal.Contains(XLSubtotalFunction.Automatic)); + if (isCustomSubtotal) + { + // When subtotal is custom, the automatic is not shown + subtotal = new HashSet(subtotal); + subtotal.Remove(XLSubtotalFunction.Automatic); + } + + return subtotal; + } + } + + public bool IncludeNewItemsInFilter + { + get => GetFieldValue(f => f.IncludeNewItemsInFilter, false); + set => GetField().IncludeNewItemsInFilter = value; + } + + public bool Outline + { + get => GetFieldValue(f => f.Outline, true); + set => GetField().Outline = value; + } + public bool Compact + { + get => GetFieldValue(f => f.Compact, true); + set => GetField().Compact = value; + } + + public bool? SubtotalsAtTop + { + get => GetFieldValue(f => f.SubtotalTop, true); + set => GetField().SubtotalTop = value ?? true; + } + + public bool RepeatItemLabels + { + get => GetFieldValue(f => f.RepeatItemLabels, false); + set => GetField().RepeatItemLabels = value; + } + + public bool InsertBlankLines + { + get => GetFieldValue(f => f.InsertBlankRow, false); + set => GetField().InsertBlankRow = value; + } + + public bool ShowBlankItems + { + get => GetFieldValue(f => f.ShowAll, true); + set => GetField().ShowAll = value; + } + + public bool InsertPageBreaks + { + get => GetFieldValue(f => f.InsertPageBreak, false); + set => GetField().InsertPageBreak = value; + } + + public bool Collapsed + { + get + { + return GetFieldValue(f => !f.Items.Any(i => i.ShowDetails), false); + } + set + { + foreach (var item in GetField().Items) + item.ShowDetails = !value; + } + } + + public XLPivotSortType SortType + { + get => GetFieldValue(f => f.SortType, XLPivotSortType.Default); + set => GetField().SortType = value; + } + + public IReadOnlyList SelectedValues => Array.Empty(); + + public IXLPivotFieldStyleFormats StyleFormats => new XLPivotTableAxisFieldStyleFormats(_pivotTable, this); + + public bool IsOnRowAxis => GetFieldValue(f => f.Axis == XLPivotAxis.AxisRow, _pivotTable.DataOnRows); + + public bool IsOnColumnAxis => GetFieldValue(f => f.Axis == XLPivotAxis.AxisCol, !_pivotTable.DataOnRows); + + public bool IsInFilterList => false; + + public int Offset => _index; + + public IXLPivotField SetCustomName(string value) + { + CustomName = value; + return this; + } + + public IXLPivotField SetSubtotalCaption(string value) + { + SubtotalCaption = value; + return this; + } + + public IXLPivotField AddSubtotal(XLSubtotalFunction value) + { + GetField().AddSubtotal(value); + return this; + } + + public IXLPivotField SetIncludeNewItemsInFilter(bool value) + { + IncludeNewItemsInFilter = value; + return this; + } + + public IXLPivotField SetLayout(XLPivotLayout value) + { + GetField().SetLayout(value); + return this; + } + + public IXLPivotField SetSubtotalsAtTop(bool value) + { + SubtotalsAtTop = value; + return this; + } + + public IXLPivotField SetRepeatItemLabels(bool value) + { + RepeatItemLabels = value; + return this; + } + + public IXLPivotField SetInsertBlankLines(bool value) + { + InsertBlankLines = value; + return this; + } + + public IXLPivotField SetShowBlankItems(bool value) + { + ShowBlankItems = value; + return this; + } + + public IXLPivotField SetInsertPageBreaks(bool value) + { + InsertPageBreaks = value; + return this; + } + + public IXLPivotField SetCollapsed(bool value) + { + Collapsed = true; + return this; + } + + public IXLPivotField SetSort(XLPivotSortType value) + { + SortType = value; + return this; + } + + public IXLPivotField AddSelectedValue(XLCellValue value) => this; + + public IXLPivotField AddSelectedValues(IEnumerable values) => this; + + #endregion IXLPivotField members + + internal XLPivotAxis Axis => IsOnColumnAxis ? XLPivotAxis.AxisCol : XLPivotAxis.AxisRow; + + /// + /// Get position of the field on the axis, starting at 0. + /// + internal int Position + { + get + { + var axis = IsOnColumnAxis ? _pivotTable.ColumnAxis : _pivotTable.RowAxis; + var position = axis.IndexOf(_index); + if (position == -1) + throw new InvalidOperationException("Field is not on the axis."); + + return position; + } + } + + private XLPivotTableField GetField() + { + if (_index.IsDataField) + throw new InvalidOperationException("Can't set this property on a data field."); + + return _pivotTable.PivotFields[_index]; + } + + private T GetFieldValue(Func getter, T dataFieldValue) + { + if (_index.IsDataField) + return dataFieldValue; + var field = _pivotTable.PivotFields[_index]; + return getter(field); + } +} diff --git a/ClosedXML/Excel/PivotTables/Areas/XLPivotTableFilters.cs b/ClosedXML/Excel/PivotTables/Areas/XLPivotTableFilters.cs new file mode 100644 index 000000000..064d7e221 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/XLPivotTableFilters.cs @@ -0,0 +1,185 @@ +// Keep this file CodeMaid organised and cleaned +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace ClosedXML.Excel; + +/// +/// Page/filter fields of a . It determines filter values and layout. +/// It is accessible through fluent API . +/// +internal class XLPivotTableFilters : IXLPivotFields +{ + private readonly XLPivotTable _pivotTable; + + /// + /// Filter fields in correct order. The layout is determined by + /// and + /// . + /// + private readonly List _fields = new(); + + internal XLPivotTableFilters(XLPivotTable pivotTable) + { + _pivotTable = pivotTable; + } + + IXLPivotField IXLPivotFields.Add(String sourceName) => Add(sourceName, sourceName); + + IXLPivotField IXLPivotFields.Add(String sourceName, String customName) => Add(sourceName, customName); + + public void Clear() + { + foreach (var field in _fields) + _pivotTable.RemoveFieldFromAxis(field.Field); + + _fields.Clear(); + } + + public Boolean Contains(String sourceName) + { + return IndexOf(sourceName) >= 0; + } + + public bool Contains(IXLPivotField pivotField) + { + return Contains(pivotField.SourceName); + } + + public IXLPivotField Get(String sourceName) + { + if (!_pivotTable.TryGetSourceNameFieldIndex(sourceName, out var fieldIndex)) + throw new KeyNotFoundException($"Field with source name '{sourceName}' not found in {XLPivotAxis.AxisPage}."); + + var filterField = _fields.SingleOrDefault(f => f.Field == fieldIndex); + if (filterField is null) + throw new KeyNotFoundException($"Field with source name '{sourceName}' not found in {XLPivotAxis.AxisPage}."); + + return new XLPivotTablePageField(_pivotTable, filterField); + } + + public IXLPivotField Get(Int32 index) + { + if (index < 0 || index >= _fields.Count) + throw new IndexOutOfRangeException(); + + return new XLPivotTablePageField(_pivotTable, _fields[index]); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IEnumerator GetEnumerator() + { + foreach (var field in _fields) + yield return new XLPivotTablePageField(_pivotTable, field); + } + + public Int32 IndexOf(String sourceName) + { + if (!_pivotTable.TryGetSourceNameFieldIndex(sourceName, out var fieldIndex)) + return -1; + + return _fields.FindIndex(f => f.Field == fieldIndex); + } + + public Int32 IndexOf(IXLPivotField pf) + { + return IndexOf(pf.SourceName); + } + + public void Remove(String sourceName) + { + var index = IndexOf(sourceName); + if (index == -1) + return; + + var heightDifference = GetHeightDifference(-1); + var movedArea = _pivotTable.Area.ShiftRows(heightDifference); + + _fields.RemoveAt(index); + _pivotTable.RemoveFieldFromAxis(index); + + _pivotTable.Area = movedArea; + } + + internal IReadOnlyList Fields => _fields; + + internal XLPivotTablePageField Add(String sourceName, String customName) + { + if (sourceName == XLConstants.PivotTable.ValuesSentinalLabel) + throw new ArgumentException(nameof(sourceName), $"The column '{sourceName}' does not appear in the source range."); + + var heightDifference= GetHeightDifference(1); + var movedArea = _pivotTable.Area.ShiftRows(heightDifference); + + var fieldIndex = _pivotTable.AddFieldToAxis(sourceName, customName, XLPivotAxis.AxisPage); + var filterField = new XLPivotPageField(fieldIndex); + _fields.Add(filterField); + + _pivotTable.Area = movedArea; + return new XLPivotTablePageField(_pivotTable, filterField); + } + + internal bool Contains(FieldIndex fieldIndex) + { + return _fields.FindIndex(f => f.Field == fieldIndex) >= 0; + } + + internal void AddField(XLPivotPageField pageField) + { + _fields.Add(pageField); + } + + /// + /// Number of rows/cols occupied by the filter area. Filter area is above the pivot table and it + /// optional (i.e. size 0 indicates no filter). + /// + internal (int Width, int Height) GetSize() + { + return GetSize(_fields.Count, _pivotTable.FilterAreaOrder, _pivotTable.FilterFieldsPageWrap); + } + + /// + /// Number of rows/cols occupied by the filter area, including the gap below, if there is at least one filter. + /// + internal (int Width, int Height) GetSizeWithGap() + { + return GetSizeWithGap(_fields.Count, _pivotTable.FilterAreaOrder, _pivotTable.FilterFieldsPageWrap); + } + + private int GetHeightDifference(int fieldChangeCount) + { + var originalHeight = GetSizeWithGap(_fields.Count, _pivotTable.FilterAreaOrder, _pivotTable.FilterFieldsPageWrap).Height; + var modifiedHeight = GetSizeWithGap(_fields.Count + fieldChangeCount, _pivotTable.FilterAreaOrder, _pivotTable.FilterFieldsPageWrap).Height; + return modifiedHeight - originalHeight; + } + + private static (int Width, int Height) GetSize(int fieldCount, XLFilterAreaOrder order, int filterWrap) + { + if (filterWrap == 0) + filterWrap = int.MaxValue; + + var dim1 = Math.DivRem(fieldCount, filterWrap, out var dim2); + dim1 = fieldCount > 0 ? dim1 + 1 : dim1; + + return order switch + { + XLFilterAreaOrder.DownThenOver => new(dim1, dim2), + XLFilterAreaOrder.OverThenDown => new(dim2, dim1), + _ => throw new UnreachableException(), + }; + } + + private static (int Width, int Height) GetSizeWithGap(int fieldCount, XLFilterAreaOrder order, int filterWrap) + { + var filtersSize = GetSize(fieldCount, order, filterWrap); + return filtersSize.Height > 0 + ? (filtersSize.Width, filtersSize.Height + 1) + : filtersSize; + } +} diff --git a/ClosedXML/Excel/PivotTables/Areas/XLPivotTablePageField.cs b/ClosedXML/Excel/PivotTables/Areas/XLPivotTablePageField.cs new file mode 100644 index 000000000..3b048583a --- /dev/null +++ b/ClosedXML/Excel/PivotTables/Areas/XLPivotTablePageField.cs @@ -0,0 +1,244 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel; + +/// +/// Fluent API for filter fields of a . This class shouldn't contain any +/// state, only logic to change state per API. +/// +internal class XLPivotTablePageField : IXLPivotField +{ + private readonly XLPivotTable _pivotTable; + private readonly XLPivotPageField _filterField; + + internal XLPivotTablePageField(XLPivotTable pivotTable, XLPivotPageField filterField) + { + _pivotTable = pivotTable; + _filterField = filterField; + } + + public string SourceName => _pivotTable.PivotCache.FieldNames[_filterField.Field]; + + public string CustomName + { + get => GetField().Name; + set => GetField().Name = value; + } + + public string SubtotalCaption + { + get => GetField().SubtotalCaption; + set => GetField().SubtotalCaption = value; + } + + public IReadOnlyCollection Subtotals => GetField().Subtotals; + + public bool IncludeNewItemsInFilter + { + get => GetField().IncludeNewItemsInFilter; + set => GetField().IncludeNewItemsInFilter = value; + } + + public bool Outline + { + get => GetField().Outline; + set => GetField().Outline = value; + } + + public bool Compact + { + get => GetField().Compact; + set => GetField().Compact = value; + } + + public bool? SubtotalsAtTop + { + get => GetField().SubtotalTop; + set => GetField().SubtotalTop = value ?? true; + } + + public bool RepeatItemLabels + { + get => GetField().RepeatItemLabels; + set => GetField().RepeatItemLabels = value; + } + + public bool InsertBlankLines + { + get => GetField().InsertBlankRow; + set => GetField().InsertBlankRow = value; + } + + public bool ShowBlankItems + { + get => GetField().ShowAll; + set => GetField().ShowAll = value; + } + + public bool InsertPageBreaks + { + get => GetField().InsertPageBreak; + set => GetField().InsertPageBreak = value; + } + + public bool Collapsed + { + get => GetField().Collapsed; + set => GetField().Collapsed = value; + } + + public XLPivotSortType SortType + { + get => GetField().SortType; + set => GetField().SortType = value; + } + + public IXLPivotField SetCustomName(string value) + { + CustomName = value; + return this; + } + + public IXLPivotField SetSubtotalCaption(string value) + { + SubtotalCaption = value; + return this; + } + + public IXLPivotField AddSubtotal(XLSubtotalFunction value) + { + GetField().AddSubtotal(value); + return this; + } + + public IXLPivotField SetIncludeNewItemsInFilter(bool value) + { + IncludeNewItemsInFilter = value; + return this; + } + + public IXLPivotField SetLayout(XLPivotLayout value) + { + GetField().SetLayout(value); + return this; + } + + public IXLPivotField SetSubtotalsAtTop(bool value) + { + SubtotalsAtTop = value; + return this; + } + + public IXLPivotField SetRepeatItemLabels(bool value) + { + RepeatItemLabels = value; + return this; + } + + public IXLPivotField SetInsertBlankLines(bool value) + { + InsertBlankLines = value; + return this; + } + + public IXLPivotField SetShowBlankItems(bool value) + { + ShowBlankItems = value; + return this; + } + + public IXLPivotField SetInsertPageBreaks(bool value) + { + InsertPageBreaks = value; + return this; + } + + public IXLPivotField SetCollapsed(bool value) + { + Collapsed = value; + return this; + } + + public IXLPivotField SetSort(XLPivotSortType value) + { + SortType = value; + return this; + } + + public IReadOnlyList SelectedValues + { + get + { + var shownItems = GetField().Items.Where(i => !i.Hidden); + var selectedValues = new List(); + foreach (var selectedItem in shownItems) + { + var selectedValue = selectedItem.GetValue(); + if (selectedValue is not null) + selectedValues.Add(selectedValue.Value); + } + + return selectedValues; + } + } + + public IXLPivotField AddSelectedValue(XLCellValue value) + { + // Try to keep the original behavior of ClosedXML - it always allows multiple selected items for added values. + // But it's complete kludge with no sane semantic that will be nuked ASAP. + var pivotField = GetField(); + + var nothingSelected = _filterField.ItemIndex is null && !pivotField.MultipleItemSelectionAllowed; + if (nothingSelected) + { + var fieldItem = pivotField.GetOrAddItem(value); + _filterField.ItemIndex = fieldItem.ItemIndex; + return this; + } + + var oneItemSelected = _filterField.ItemIndex is not null && !pivotField.MultipleItemSelectionAllowed; + if (oneItemSelected) + { + // Switch to multiple + pivotField.MultipleItemSelectionAllowed = true; + foreach (var item in pivotField.Items.Where(x => x.ItemType == XLPivotItemType.Data)) + item.Hidden = true; + + var selectedItem = pivotField.Items.Single(i => i.ItemIndex == _filterField.ItemIndex); + selectedItem.Hidden = false; + _filterField.ItemIndex = null; + var fieldItem = pivotField.GetOrAddItem(value); + fieldItem.Hidden = false; + return this; + } + else + { + // Add another item to selected item filters. + var fieldItem = pivotField.GetOrAddItem(value); + fieldItem.Hidden = false; + return this; + } + } + + public IXLPivotField AddSelectedValues(IEnumerable values) + { + foreach (var value in values) + AddSelectedValue(value); + + return this; + } + + public IXLPivotFieldStyleFormats StyleFormats => throw new NotImplementedException("Styles for filter fields are not yet implemented."); + public bool IsOnRowAxis => false; + public bool IsOnColumnAxis => false; + public bool IsInFilterList => true; + public int Offset => _filterField.Field; + + private XLPivotTableField GetField() + { + return _pivotTable.PivotFields[_filterField.Field]; + } +} diff --git a/ClosedXML/Excel/PivotTables/IXLPivotCache.cs b/ClosedXML/Excel/PivotTables/IXLPivotCache.cs new file mode 100644 index 000000000..a14f9663c --- /dev/null +++ b/ClosedXML/Excel/PivotTables/IXLPivotCache.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using ClosedXML.Excel.Exceptions; + +namespace ClosedXML.Excel +{ + /// + /// A cache of pivot data - essentially a collection of fields and their values that can be + /// displayed by a . Data for the cache are retrieved from + /// an area (a table or a range). The pivot cache data are cached, i.e. + /// the data in the source are not immediately updated once the data in a worksheet change. + /// + public interface IXLPivotCache + { + /// + /// Get names of all fields in the source, in left to right order. Every field name is unique. + /// + /// + /// The field names are case insensitive. The field names of the cached + /// source might differ from actual names of the columns + /// in the data cells. + /// + IReadOnlyList FieldNames { get; } + + /// + /// Gets the number of unused items in shared items to allow before discarding unused items. + /// + /// + /// Shared items are distinct values of a source field values. Updating them can be expensive + /// and this controls, when should the cache be updated. Application-dependent attribute. + /// + /// Default value is . + XLItemsToRetain ItemsToRetainPerField { get; set; } + + /// + /// Will Excel refresh the cache when it opens the workbook. + /// + /// Default value is false. + Boolean RefreshDataOnOpen { get; set; } + + /// + /// Should the cached values of the pivot source be saved into the workbook file? + /// If source data are not saved, they will have to be refreshed from the source + /// reference which might cause a change in the table values. + /// + /// Default value is true. + Boolean SaveSourceData { get; set; } + + /// + /// Refresh data in the pivot source from the source reference data. + /// + /// The data source for the pivot table can't be found. + IXLPivotCache Refresh(); + + /// + IXLPivotCache SetItemsToRetainPerField(XLItemsToRetain value); + + /// + /// Sets the value to true. + IXLPivotCache SetRefreshDataOnOpen(); + + /// + IXLPivotCache SetRefreshDataOnOpen(Boolean value); + + /// + /// Sets the value to true. + IXLPivotCache SetSaveSourceData(); + + /// + IXLPivotCache SetSaveSourceData(Boolean value); + } +} diff --git a/ClosedXML/Excel/PivotTables/IXLPivotCaches.cs b/ClosedXML/Excel/PivotTables/IXLPivotCaches.cs new file mode 100644 index 000000000..f51190f03 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/IXLPivotCaches.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace ClosedXML.Excel +{ + /// + /// A collection of pivot caches. Pivot cache + /// can be added from a or a . + /// + public interface IXLPivotCaches : IEnumerable + { + /// + /// Add a new pivot cache for the range. If the range area is same as + /// an area of a table, the created cache will reference the table + /// as source of data instead of a range of cells. + /// + /// Range for which to create the pivot cache. + /// The pivot cache for the range. + IXLPivotCache Add(IXLRange range); + } +} diff --git a/ClosedXML/Excel/PivotTables/IXLPivotField.cs b/ClosedXML/Excel/PivotTables/IXLPivotField.cs deleted file mode 100644 index 3d588392d..000000000 --- a/ClosedXML/Excel/PivotTables/IXLPivotField.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ClosedXML.Excel -{ - public enum XLSubtotalFunction - { - Automatic, - None, - Sum, - Count, - Average, - Minimum, - Maximum, - Product, - CountNumbers, - StandardDeviation, - PopulationStandardDeviation, - Variance, - PopulationVariance, - } - - public enum XLPivotLayout { Outline, Tabular, Compact } - - public interface IXLPivotField - { - String SourceName { get; } - String CustomName { get; set; } - String SubtotalCaption { get; set; } - - List Subtotals { get; } - Boolean IncludeNewItemsInFilter { get; set; } - - Boolean Outline { get; set; } - Boolean Compact { get; set; } - Boolean? SubtotalsAtTop { get; set; } - Boolean RepeatItemLabels { get; set; } - Boolean InsertBlankLines { get; set; } - Boolean ShowBlankItems { get; set; } - Boolean InsertPageBreaks { get; set; } - Boolean Collapsed { get; set; } - XLPivotSortType SortType { get; set; } - - IXLPivotField SetCustomName(String value); - - IXLPivotField SetSubtotalCaption(String value); - - IXLPivotField AddSubtotal(XLSubtotalFunction value); - - IXLPivotField SetIncludeNewItemsInFilter(); IXLPivotField SetIncludeNewItemsInFilter(Boolean value); - - IXLPivotField SetLayout(XLPivotLayout value); - - IXLPivotField SetSubtotalsAtTop(); IXLPivotField SetSubtotalsAtTop(Boolean value); - - IXLPivotField SetRepeatItemLabels(); IXLPivotField SetRepeatItemLabels(Boolean value); - - IXLPivotField SetInsertBlankLines(); IXLPivotField SetInsertBlankLines(Boolean value); - - IXLPivotField SetShowBlankItems(); IXLPivotField SetShowBlankItems(Boolean value); - - IXLPivotField SetInsertPageBreaks(); IXLPivotField SetInsertPageBreaks(Boolean value); - - IXLPivotField SetCollapsed(); IXLPivotField SetCollapsed(Boolean value); - - IXLPivotField SetSort(XLPivotSortType value); - - IList SelectedValues { get; } - - IXLPivotField AddSelectedValue(Object value); - - IXLPivotField AddSelectedValues(IEnumerable values); - - IXLPivotFieldStyleFormats StyleFormats { get; } - - Boolean IsOnRowAxis { get; } - Boolean IsOnColumnAxis { get; } - Boolean IsInFilterList { get; } - - Int32 Offset { get; } - } -} diff --git a/ClosedXML/Excel/PivotTables/IXLPivotFields.cs b/ClosedXML/Excel/PivotTables/IXLPivotFields.cs deleted file mode 100644 index 148120dcd..000000000 --- a/ClosedXML/Excel/PivotTables/IXLPivotFields.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Keep this file CodeMaid organised and cleaned -using System; -using System.Collections.Generic; - -namespace ClosedXML.Excel -{ - public interface IXLPivotFields : IEnumerable - { - IXLPivotField Add(String sourceName); - - IXLPivotField Add(String sourceName, String customName); - - void Clear(); - - Boolean Contains(String sourceName); - - Boolean Contains(IXLPivotField pivotField); - - IXLPivotField Get(String sourceName); - - IXLPivotField Get(Int32 index); - - Int32 IndexOf(String sourceName); - Int32 IndexOf(IXLPivotField pf); - - void Remove(String sourceName); - } -} diff --git a/ClosedXML/Excel/PivotTables/IXLPivotSource.cs b/ClosedXML/Excel/PivotTables/IXLPivotSource.cs new file mode 100644 index 000000000..51e03fb0a --- /dev/null +++ b/ClosedXML/Excel/PivotTables/IXLPivotSource.cs @@ -0,0 +1,17 @@ +using System; + +namespace ClosedXML.Excel; + +/// +/// An abstraction of source data for a . Implementations must correctly +/// implement equals. +/// +internal interface IXLPivotSource : IEquatable +{ + /// + /// Try to determine actual area of the source reference in the + /// workbook. Source reference might not be valid in the workbook, some might + /// not be supported. + /// + bool TryGetSource(XLWorkbook workbook, out XLWorksheet? sheet, out XLSheetRange? sheetArea); +} diff --git a/ClosedXML/Excel/PivotTables/IXLPivotTable.cs b/ClosedXML/Excel/PivotTables/IXLPivotTable.cs index 8399e00d0..6eff7ce6c 100644 --- a/ClosedXML/Excel/PivotTables/IXLPivotTable.cs +++ b/ClosedXML/Excel/PivotTables/IXLPivotTable.cs @@ -1,124 +1,23 @@ +#nullable disable + using System; -using System.Collections.Generic; namespace ClosedXML.Excel { - public enum XLPivotTableTheme - { - None, - PivotStyleDark1, - PivotStyleDark10, - PivotStyleDark11, - PivotStyleDark12, - PivotStyleDark13, - PivotStyleDark14, - PivotStyleDark15, - PivotStyleDark16, - PivotStyleDark17, - PivotStyleDark18, - PivotStyleDark19, - PivotStyleDark2, - PivotStyleDark20, - PivotStyleDark21, - PivotStyleDark22, - PivotStyleDark23, - PivotStyleDark24, - PivotStyleDark25, - PivotStyleDark26, - PivotStyleDark27, - PivotStyleDark28, - PivotStyleDark3, - PivotStyleDark4, - PivotStyleDark5, - PivotStyleDark6, - PivotStyleDark7, - PivotStyleDark8, - PivotStyleDark9, - PivotStyleLight1, - PivotStyleLight10, - PivotStyleLight11, - PivotStyleLight12, - PivotStyleLight13, - PivotStyleLight14, - PivotStyleLight15, - PivotStyleLight16, - PivotStyleLight17, - PivotStyleLight18, - PivotStyleLight19, - PivotStyleLight2, - PivotStyleLight20, - PivotStyleLight21, - PivotStyleLight22, - PivotStyleLight23, - PivotStyleLight24, - PivotStyleLight25, - PivotStyleLight26, - PivotStyleLight27, - PivotStyleLight28, - PivotStyleLight3, - PivotStyleLight4, - PivotStyleLight5, - PivotStyleLight6, - PivotStyleLight7, - PivotStyleLight8, - PivotStyleLight9, - PivotStyleMedium1, - PivotStyleMedium10, - PivotStyleMedium11, - PivotStyleMedium12, - PivotStyleMedium13, - PivotStyleMedium14, - PivotStyleMedium15, - PivotStyleMedium16, - PivotStyleMedium17, - PivotStyleMedium18, - PivotStyleMedium19, - PivotStyleMedium2, - PivotStyleMedium20, - PivotStyleMedium21, - PivotStyleMedium22, - PivotStyleMedium23, - PivotStyleMedium24, - PivotStyleMedium25, - PivotStyleMedium26, - PivotStyleMedium27, - PivotStyleMedium28, - PivotStyleMedium3, - PivotStyleMedium4, - PivotStyleMedium5, - PivotStyleMedium6, - PivotStyleMedium7, - PivotStyleMedium8, - PivotStyleMedium9 - } - - public enum XLPivotSortType - { - Default = 0, - Ascending = 1, - Descending = 2 - } - - public enum XLPivotSubtotals - { - DoNotShow, - AtTop, - AtBottom - } - - public enum XLFilterAreaOrder { DownThenOver, OverThenDown } - - public enum XLItemsToRetain { Automatic, None, Max } - - public enum XLPivotTableSourceType { Range, Table } - public interface IXLPivotTable { XLPivotTableTheme Theme { get; set; } - IXLPivotFields Fields { get; } IXLPivotFields ReportFilters { get; } + + /// + /// Labels displayed in columns (i.e. horizontal axis) of the pivot table. + /// IXLPivotFields ColumnLabels { get; } + + /// + /// Labels displayed in rows (i.e. vertical axis) of the pivot table. + /// IXLPivotFields RowLabels { get; } IXLPivotValues Values { get; } @@ -129,17 +28,33 @@ public interface IXLPivotTable String ColumnHeaderCaption { get; set; } String RowHeaderCaption { get; set; } + /// + /// Top left corner cell of a pivot table. If the pivot table contains filters fields, the target cell is top + /// left cell of the first filter field. + /// IXLCell TargetCell { get; set; } - IXLRange SourceRange { get; set; } - IXLTable SourceTable { get; set; } - XLPivotTableSourceType SourceType { get; } - - IEnumerable SourceRangeFieldsAvailable { get; } + /// + /// The cache of data for the pivot table. The pivot table is created + /// from cached data, not up-to-date data in a worksheet. + /// + IXLPivotCache PivotCache { get; set; } Boolean MergeAndCenterWithLabels { get; set; } // MergeItem Int32 RowLabelIndent { get; set; } // Indent - XLFilterAreaOrder FilterAreaOrder { get; set; } // PageOverThenDown + + /// + /// Filter fields layout setting that indicates layout order of filter fields. The layout + /// uses to determine when to break to a new row or + /// column. Default value is . + /// + XLFilterAreaOrder FilterAreaOrder { get; set; } + + /// + /// Specifies the number of page fields to display before starting another row or column. + /// Value = 0 means unlimited. + /// + /// If value < 0. Int32 FilterFieldsPageWrap { get; set; } // PageWrap String ErrorValueReplacement { get; set; } // ErrorCaption String EmptyCellReplacement { get; set; } // MissingCaption @@ -167,10 +82,7 @@ public interface IXLPivotTable Boolean RepeatRowLabels { get; set; } Boolean PrintTitles { get; set; } - Boolean SaveSourceData { get; set; } Boolean EnableShowDetails { get; set; } - Boolean RefreshDataOnOpen { get; set; } - XLItemsToRetain ItemsToRetainPerField { get; set; } Boolean EnableCellEditing { get; set; } IXLPivotTable CopyTo(IXLCell targetCell); @@ -199,6 +111,14 @@ public interface IXLPivotTable IXLPivotTable SetShowGrandTotalsRows(); IXLPivotTable SetShowGrandTotalsRows(Boolean value); + /// + /// Should pivot table display a grand total for each row in the last column of a pivot + /// table (it will enlarge pivot table for extra column). + /// + /// + /// This API has inverse row/column names than the Excel. Excel: On for rows + /// should use this method ShowGrandTotalsColumns. + /// IXLPivotTable SetShowGrandTotalsColumns(); IXLPivotTable SetShowGrandTotalsColumns(Boolean value); IXLPivotTable SetFilteredItemsInSubtotals(); IXLPivotTable SetFilteredItemsInSubtotals(Boolean value); @@ -233,13 +153,10 @@ public interface IXLPivotTable IXLPivotTable SetPrintTitles(); IXLPivotTable SetPrintTitles(Boolean value); - IXLPivotTable SetSaveSourceData(); IXLPivotTable SetSaveSourceData(Boolean value); IXLPivotTable SetEnableShowDetails(); IXLPivotTable SetEnableShowDetails(Boolean value); - IXLPivotTable SetRefreshDataOnOpen(); IXLPivotTable SetRefreshDataOnOpen(Boolean value); - IXLPivotTable SetItemsToRetainPerField(XLItemsToRetain value); IXLPivotTable SetEnableCellEditing(); IXLPivotTable SetEnableCellEditing(Boolean value); @@ -252,6 +169,10 @@ public interface IXLPivotTable Boolean ShowRowStripes { get; set; } Boolean ShowColumnStripes { get; set; } XLPivotSubtotals Subtotals { get; set; } + + /// + /// Set the layout of the pivot table. It also changes layout of all pivot fields. + /// XLPivotLayout Layout { set; } Boolean InsertBlankLines { set; } diff --git a/ClosedXML/Excel/PivotTables/IXLPivotTables.cs b/ClosedXML/Excel/PivotTables/IXLPivotTables.cs index 45ca93500..b03dff690 100644 --- a/ClosedXML/Excel/PivotTables/IXLPivotTables.cs +++ b/ClosedXML/Excel/PivotTables/IXLPivotTables.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; @@ -5,22 +7,49 @@ namespace ClosedXML.Excel { public interface IXLPivotTables : IEnumerable { + /// + /// Add a pivot table that will use the pivot cache. + /// + /// Name of new pivot table. + /// A cell where will the pivot table be have it's left top corner. + /// Pivot cache to use for the pivot table. + /// Added pivot table. + /// There already is a pivot table with the same name. + IXLPivotTable Add(String name, IXLCell targetCell, IXLPivotCache pivotCache); + + /// + /// Add a pivot table from source data of . + /// If workbook already contains a cache for same range as the + /// , the matching pivot cache is used. + /// + /// Name of new pivot table + /// A cell where will the pivot table be have it's left top corner. + /// A range to add/find pivot cache. + /// There already is a pivot table with the same name. IXLPivotTable Add(String name, IXLCell targetCell, IXLRange range); + /// + /// Add a pivot table from source data of . + /// If workbook already contains a cache for same range as the + /// , the matching pivot cache is used. + /// + /// Name of new pivot table + /// A cell where will the pivot table be have it's left top corner. + /// A table to add/find pivot cache. + /// There already is a pivot table with the same name. IXLPivotTable Add(String name, IXLCell targetCell, IXLTable table); - [Obsolete("Use Add instead")] - IXLPivotTable AddNew(String name, IXLCell targetCell, IXLRange range); - - [Obsolete("Use Add instead")] - IXLPivotTable AddNew(String name, IXLCell targetCell, IXLTable table); - Boolean Contains(String name); void Delete(String name); void DeleteAll(); + /// + /// Get pivot table with the specified name (case insensitive). + /// + /// Name of a pivot table to return. + /// No such pivot table found. IXLPivotTable PivotTable(String name); } } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/AbstractPivotFieldReference.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/AbstractPivotFieldReference.cs deleted file mode 100644 index e22635c71..000000000 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/AbstractPivotFieldReference.cs +++ /dev/null @@ -1,19 +0,0 @@ -using DocumentFormat.OpenXml; -using System; -using System.Collections.Generic; - -namespace ClosedXML.Excel -{ - internal abstract class AbstractPivotFieldReference - { - public Boolean DefaultSubtotal { get; set; } - - internal abstract UInt32Value GetFieldOffset(); - - /// - ///

Helper function used during saving to calculate the indices of the filtered values

- ///
- /// Indices of the filtered values - internal abstract IEnumerable Match(XLWorkbook.PivotTableInfo pti, IXLPivotTable pt); - } -} diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotFieldStyleFormats.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotFieldStyleFormats.cs index 31d24542b..9da9e2ec2 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotFieldStyleFormats.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotFieldStyleFormats.cs @@ -1,11 +1,27 @@ -// Keep this file CodeMaid organised and cleaned -namespace ClosedXML.Excel +// Keep this file CodeMaid organised and cleaned +namespace ClosedXML.Excel; + +/// +/// Interface to change the style of a or its parts. +/// +public interface IXLPivotFieldStyleFormats { - public interface IXLPivotFieldStyleFormats - { - IXLPivotValueStyleFormat DataValuesFormat { get; } - IXLPivotStyleFormat Header { get; } - IXLPivotStyleFormat Label { get; } - IXLPivotStyleFormat Subtotal { get; } - } + /// + /// Pivot table style of the field values displayed in the data area of the pivot table. + /// + IXLPivotValueStyleFormat DataValuesFormat { get; } + + /// + /// Get the style of the pivot field header. The head usually contains a name of the field. + /// In some layouts, header is not individually displayed (e.g. compact), while in others + /// it is (e.g. tabular). + /// + IXLPivotStyleFormat Header { get; } + + /// + /// Get the style of the pivot field label values on horizontal or vertical axis. + /// + IXLPivotStyleFormat Label { get; } + + IXLPivotStyleFormat Subtotal { get; } } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotStyleFormat.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotStyleFormat.cs index 848ea04b2..adf1c12e1 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotStyleFormat.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotStyleFormat.cs @@ -1,10 +1,23 @@ // Keep this file CodeMaid organised and cleaned -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +/// +/// A interface for styling various parts of a pivot table, e.g. the whole table, specific +/// area or just a field. Use and +/// to access it. +/// +public interface IXLPivotStyleFormat { - public interface IXLPivotStyleFormat - { - XLPivotStyleFormatElement AppliesTo { get; } - IXLPivotField PivotField { get; } - IXLStyle Style { get; set; } - } + /// + /// To what part of the pivot table part will the style apply to. + /// + XLPivotStyleFormatElement AppliesTo { get; } + + /// + /// The differential style of the part. + /// + /// + /// The final displayed style is done by composing all differential styles that overlap the element. + /// + IXLStyle Style { get; set; } } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotStyleFormats.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotStyleFormats.cs index 4f2b8436e..2c1712d07 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotStyleFormats.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotStyleFormats.cs @@ -1,10 +1,22 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned + +using System; using System.Collections.Generic; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +/// +/// An API for setting style of parts consisting of , e.g. grand +/// totals. The enumerator enumerates only existing formats, it doesn't add them. +/// +public interface IXLPivotStyleFormats : IEnumerable { - public interface IXLPivotStyleFormats : IEnumerable - { - IXLPivotStyleFormat ForElement(XLPivotStyleFormatElement element); - } + /// + /// Get styling object for specified . + /// + /// Which part do we want style for? + /// An API to inspect/modify style of the . + /// When is + /// passed as an argument. + IXLPivotStyleFormat ForElement(XLPivotStyleFormatElement element); } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotTableStyleFormats.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotTableStyleFormats.cs index a1f0456b1..53e20fb03 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotTableStyleFormats.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotTableStyleFormats.cs @@ -1,9 +1,18 @@ -// Keep this file CodeMaid organised and cleaned -namespace ClosedXML.Excel +// Keep this file CodeMaid organised and cleaned +namespace ClosedXML.Excel; + +/// +/// An API for modifying the pivot table styles that affect whole . +/// +public interface IXLPivotTableStyleFormats { - public interface IXLPivotTableStyleFormats - { - IXLPivotStyleFormats ColumnGrandTotalFormats { get; } - IXLPivotStyleFormats RowGrandTotalFormats { get; } - } + /// + /// Get style formats of a grand total column in a pivot table (i.e. the right column a pivot table). + /// + IXLPivotStyleFormats ColumnGrandTotalFormats { get; } + + /// + /// Get style formats of a grand total row in a pivot table (i.e. the bottom row of a pivot table). + /// + IXLPivotStyleFormats RowGrandTotalFormats { get; } } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotValueStyleFormat.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotValueStyleFormat.cs index c12c71928..1088e4235 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotValueStyleFormat.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/IXLPivotValueStyleFormat.cs @@ -1,14 +1,37 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using System; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +/// +/// A fluent API for styling a field of a . +/// +public interface IXLPivotValueStyleFormat : IXLPivotStyleFormat { - public interface IXLPivotValueStyleFormat : IXLPivotStyleFormat - { - IXLPivotValueStyleFormat AndWith(IXLPivotField field); + /// + /// Adds a limitation so the is only applied to cells in a pivot table + /// that also belong to the (label or data). + /// + /// Only cells in a pivot table under this field will be styled. + IXLPivotValueStyleFormat AndWith(IXLPivotField field); - IXLPivotValueStyleFormat AndWith(IXLPivotField field, Predicate predicate); + /// + /// Adds a limitation so the is only applied to cells in a pivot table + /// that also belong to the data cells. The cell values also must satisfy the . + /// + /// + /// The pivot style is bound by the field index in a pivot table, not field value. E.g. if field values + /// are Jan, Feb and the predicate marks Feb (offset 1) = second field (Feb) will be highlighted. + /// If user later reverses order in Excel to Feb, Jan, the style would still apply to the second value - Jan. + /// + /// Only cells in a pivot table under this field will be styled. + /// A predicate to determine which index of the field should be styled. + IXLPivotValueStyleFormat AndWith(IXLPivotField field, Predicate predicate); - IXLPivotValueStyleFormat ForValueField(IXLPivotValue valueField); - } + /// + /// Adds a limitation so the is only applied to cells in a pivot table + /// that display values for cells (i.e. data cells and grand total). + /// + /// One of value fields of the pivot table. + IXLPivotValueStyleFormat ForValueField(IXLPivotValue valueField); } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/PivotLabelFieldReference.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/PivotLabelFieldReference.cs deleted file mode 100644 index 09570fd72..000000000 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/PivotLabelFieldReference.cs +++ /dev/null @@ -1,42 +0,0 @@ -using DocumentFormat.OpenXml; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ClosedXML.Excel -{ - internal class PivotLabelFieldReference : AbstractPivotFieldReference - { - private readonly Predicate predicate; - - public PivotLabelFieldReference(IXLPivotField pivotField) - : this(pivotField, null) - { } - - public PivotLabelFieldReference(IXLPivotField pivotField, Predicate predicate) - { - this.PivotField = pivotField ?? throw new ArgumentNullException(nameof(pivotField)); - this.predicate = predicate; - } - - public IXLPivotField PivotField { get; set; } - - internal override UInt32Value GetFieldOffset() - { - return UInt32Value.FromUInt32((uint)PivotField.Offset); - } - - internal override IEnumerable Match(XLWorkbook.PivotTableInfo pti, IXLPivotTable pt) - { - var values = pti.Fields[PivotField.SourceName].DistinctValues.ToList(); - - if (predicate == null) - return new Int32[] { }; - - return values.Select((Value, Index) => new { Value, Index }) - .Where(v => predicate.Invoke(v.Value)) - .Select(v => v.Index) - .ToList(); - } - } -} diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/PivotValueFieldReference.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/PivotValueFieldReference.cs deleted file mode 100644 index 36294467a..000000000 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/PivotValueFieldReference.cs +++ /dev/null @@ -1,29 +0,0 @@ -using DocumentFormat.OpenXml; -using System; -using System.Collections.Generic; - -namespace ClosedXML.Excel -{ - internal class PivotValueFieldReference : AbstractPivotFieldReference - { - public PivotValueFieldReference(String value) - { - this.Value = value; - } - - public String Value { get; } - - internal override UInt32Value GetFieldOffset() - { - return UInt32Value.FromUInt32(unchecked((uint)-2)); - } - - internal override IEnumerable Match(XLWorkbook.PivotTableInfo pti, IXLPivotTable pt) - { - return new Int32[] - { - pt.Values.IndexOf(Value.ToString()) - }; - } - } -} diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotFieldStyleFormats.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotFieldStyleFormats.cs deleted file mode 100644 index 4e2840d44..000000000 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotFieldStyleFormats.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Keep this file CodeMaid organised and cleaned - -namespace ClosedXML.Excel -{ - internal class XLPivotFieldStyleFormats : IXLPivotFieldStyleFormats - { - private IXLPivotValueStyleFormat dataValuesFormat; - private IXLPivotStyleFormat headerFormat; - private IXLPivotStyleFormat labelFormat; - private IXLPivotStyleFormat subtotalFormat; - - public XLPivotFieldStyleFormats(IXLPivotField field) - { - this.PivotField = field; - } - - public IXLPivotField PivotField { get; } - - #region IXLPivotFieldStyleFormats - - public IXLPivotValueStyleFormat DataValuesFormat - { - get - { - if (dataValuesFormat == null) - { - dataValuesFormat = new XLPivotValueStyleFormat(PivotField) - { - AppliesTo = XLPivotStyleFormatElement.Data - }; - } - return dataValuesFormat; - } - set { dataValuesFormat = value; } - } - - public IXLPivotStyleFormat Header - { - get - { - if (headerFormat == null) - { - headerFormat = new XLPivotStyleFormat(PivotField); - } - return headerFormat; - } - set { headerFormat = value; } - } - - public IXLPivotStyleFormat Label - { - get - { - if (labelFormat == null) - { - labelFormat = new XLPivotStyleFormat(PivotField) - { - AppliesTo = XLPivotStyleFormatElement.Label - }; - } - return labelFormat; - } - set { labelFormat = value; } - } - - public IXLPivotStyleFormat Subtotal - { - get - { - if (subtotalFormat == null) - { - subtotalFormat = new XLPivotStyleFormat(PivotField); - } - - return subtotalFormat; - } - set { subtotalFormat = value; } - } - - #endregion IXLPivotFieldStyleFormats - } -} diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormat.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormat.cs index 7d05cfb40..afb09cd55 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormat.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormat.cs @@ -1,27 +1,26 @@ -// Keep this file CodeMaid organised and cleaned -using System.Collections.Generic; +using System; -namespace ClosedXML.Excel -{ - internal class XLPivotStyleFormat : IXLPivotStyleFormat - { - public XLPivotStyleFormat(IXLPivotField field = null, IXLStyle style = null) - { - PivotField = field; - Style = style ?? XLStyle.Default; - } +namespace ClosedXML.Excel; - #region IXLPivotStyleFormat members +internal class XLPivotStyleFormat : XLPivotStyleFormatBase +{ + private readonly Func _filter; + private readonly Func _factory; - public XLPivotStyleFormatElement AppliesTo { get; set; } = XLPivotStyleFormatElement.Data; - public IXLPivotField PivotField { get; set; } - public IXLStyle Style { get; set; } + public XLPivotStyleFormat(XLPivotTable pivotTable, Func filter, Func factory) + : base(pivotTable) + { + _filter = filter; + _factory = factory; + } - #endregion IXLPivotStyleFormat members + internal override XLPivotArea GetCurrentArea() + { + return _factory(); + } - internal XLPivotAreaValues AreaType { get; set; } = XLPivotAreaValues.Normal; - internal bool CollapsedLevelsAreSubtotals { get; set; } = false; - internal IList FieldReferences { get; } = new List(); - internal bool Outline { get; set; } = true; + internal override bool Filter(XLPivotArea area) + { + return _filter(area); } } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormatBase.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormatBase.cs new file mode 100644 index 000000000..8b1e022f8 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormatBase.cs @@ -0,0 +1,109 @@ +// Keep this file CodeMaid organised and cleaned + +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel; + +/// +/// A base class for pivot styling API. It has takes a selected +/// and applies the style using .Style* API. The derived classes are responsible for +/// exposing API so user can define an area and then create the desired area (from what user +/// specified) through method. +/// +internal abstract class XLPivotStyleFormatBase : IXLPivotStyleFormat, IXLStylized +{ + protected readonly XLPivotTable PivotTable; + private XLStyleValue _styleValue; + + protected XLPivotStyleFormatBase(XLPivotTable pivotTable) + { + PivotTable = pivotTable; + + // Value is Default, because it's a differential style that can't be represented yet. + _styleValue = XLStyle.Default.Value; + } + + #region IXLPivotStyleFormat members + + public XLPivotStyleFormatElement AppliesTo { get; init; } = XLPivotStyleFormatElement.Data; + + public IXLStyle Style + { + get => InnerStyle; + set => InnerStyle = value; + } + + #endregion IXLPivotStyleFormat members + + #region IXLStylized + + public IXLStyle InnerStyle + { + get => new XLStyle(this, StyleValue); + set + { + var styleKey = XLStyle.GenerateKey(value); + StyleValue = XLStyleValue.FromKey(ref styleKey); + } + } + public IXLRanges RangesUsed { get; } = new XLRanges(); + + public XLStyleValue StyleValue + { + get => _styleValue; + set + { + // This sets the style of everything to the passed style, while ModifyStyle + // is for fluent API that can modify format styles individually. Because initial + // value of _styleValue is Default, this setter shouldn't be used as a basis + // for modifying the DxStyleValue. + _styleValue = value; + foreach (var format in GetFormats()) + format.DxfStyleValue = value; + } + } + + public void ModifyStyle(Func modification) + { + var styleKey = modification(_styleValue.Key); + _styleValue = XLStyleValue.FromKey(ref styleKey); + + // Do not use StyleValue setter, because some formats might have different formats and + // we should only modify them, not replace other potentially different style props of formats. + foreach (var format in GetFormats()) + { + var formatStyleValue = modification(format.DxfStyleValue.Key); + format.DxfStyleValue = XLStyleValue.FromKey(ref formatStyleValue); + } + } + + #endregion IXLStylized + + internal abstract XLPivotArea GetCurrentArea(); + + internal abstract bool Filter(XLPivotArea area); + + private IEnumerable GetFormats() + { + var exists = false; + foreach (var format in PivotTable.Formats) + { + if (format.Action == XLPivotFormatAction.Formatting && Filter(format.PivotArea)) + { + exists = true; + yield return format; + } + } + + if (!exists) + { + var format = new XLPivotFormat(GetCurrentArea()) + { + DxfStyleValue = _styleValue + }; + PivotTable.AddFormat(format); + yield return format; + } + } +} diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormatEnums.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormatEnums.cs index 20a9a4012..5097b8ffa 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormatEnums.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormatEnums.cs @@ -1,24 +1,13 @@ -using System; +using System; -namespace ClosedXML.Excel -{ - [Flags] - public enum XLPivotStyleFormatElement - { - None = 0, - Label = 1 << 1, - Data = 1 << 2, +namespace ClosedXML.Excel; - All = Label | Data - } +[Flags] +public enum XLPivotStyleFormatElement +{ + None = 0, + Label = 1 << 1, + Data = 1 << 2, - internal enum XLPivotStyleFormatTarget - { - PivotTable, - GrandTotal, - Subtotal, - Header, - Label, - Data - } + All = Label | Data } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormats.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormats.cs index 3f559fa32..c1dd64c89 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormats.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotStyleFormats.cs @@ -1,61 +1,97 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using System; using System.Collections; using System.Collections.Generic; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +/// +/// An API for grand totals from . +/// +internal class XLPivotStyleFormats : IXLPivotStyleFormats { - internal class XLPivotStyleFormats : IXLPivotStyleFormats + private readonly XLPivotTable _pivotTable; + private readonly bool _isRowGrand; + + internal XLPivotStyleFormats(XLPivotTable pivotTable, bool isRowGrand) { - private readonly IXLPivotField _pivotField; - private readonly Dictionary _styleFormats = new Dictionary(); + _pivotTable = pivotTable; + _isRowGrand = isRowGrand; + } - public XLPivotStyleFormats() - : this(null) - { } + #region IXLPivotStyleFormats members - public XLPivotStyleFormats(IXLPivotField pivotField) - { - this._pivotField = pivotField; - } + public IXLPivotStyleFormat ForElement(XLPivotStyleFormatElement element) + { + if (element == XLPivotStyleFormatElement.None) + throw new ArgumentException("Choose an enum value that represents an element", nameof(element)); - #region IXLPivotStyleFormats members + return GetPivotStyleFormatFor(element); + } - public IXLPivotStyleFormat ForElement(XLPivotStyleFormatElement element) + public IEnumerator GetEnumerator() + { + var elements = new[] { - if (element == XLPivotStyleFormatElement.None) - throw new ArgumentException("Choose an enum value that represents an element", nameof(element)); + XLPivotStyleFormatElement.Label, + XLPivotStyleFormatElement.Data, + XLPivotStyleFormatElement.All, + }; - if (!_styleFormats.TryGetValue(element, out IXLPivotStyleFormat pivotStyleFormat)) + foreach (var element in elements) + { + foreach (var format in _pivotTable.Formats) { - pivotStyleFormat = new XLPivotStyleFormat(_pivotField) { AppliesTo = element }; - _styleFormats.Add(element, pivotStyleFormat); + if (AreaBelongsToGrandTotal(format.PivotArea, element)) + { + // Each pivot style format modifies all formats, so return only once per element. + yield return GetPivotStyleFormatFor(element); + break; + } } - - return pivotStyleFormat; } + } - public IEnumerator GetEnumerator() - { - return _styleFormats.Values.GetEnumerator(); - } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + #endregion IXLPivotStyleFormats members - IEnumerator IEnumerable.GetEnumerator() + private XLPivotStyleFormat GetPivotStyleFormatFor(XLPivotStyleFormatElement element) + { + return new XLPivotStyleFormat(_pivotTable, FilterElement, ElementFactory) { - return GetEnumerator(); - } + AppliesTo = element + }; - #endregion IXLPivotStyleFormats members + bool FilterElement(XLPivotArea pivotArea) => AreaBelongsToGrandTotal(pivotArea, element); + XLPivotArea ElementFactory() => CreateGrandArea(element); + } - public void Add(IXLPivotStyleFormat styleFormat) - { - _styleFormats.Add(styleFormat.AppliesTo, styleFormat); - } + private bool AreaBelongsToGrandTotal(XLPivotArea area, XLPivotStyleFormatElement element) + { + return + area.References.Count == 0 && + area.Field is null && + area.Type == XLPivotAreaType.Normal && + area.DataOnly == (element == XLPivotStyleFormatElement.Data) && + area.LabelOnly == (element == XLPivotStyleFormatElement.Label) && + area.GrandRow == _isRowGrand && + area.GrandCol == !_isRowGrand && + area.CacheIndex == false && + area.Offset is null && + !area.CollapsedLevelsAreSubtotals && + area.Axis is null && + area.FieldPosition is null; + } - public void AddRange(IEnumerable styleFormats) + private XLPivotArea CreateGrandArea(XLPivotStyleFormatElement element) + { + return new XLPivotArea { - foreach (var styleFormat in styleFormats) - Add(styleFormat); - } + DataOnly = (element == XLPivotStyleFormatElement.Data), + LabelOnly = (element == XLPivotStyleFormatElement.Label), + GrandRow = _isRowGrand, + GrandCol = !_isRowGrand, + }; } } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotTableAxisFieldStyleFormats.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotTableAxisFieldStyleFormats.cs new file mode 100644 index 000000000..c3a5baaed --- /dev/null +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotTableAxisFieldStyleFormats.cs @@ -0,0 +1,117 @@ +#nullable disable + +namespace ClosedXML.Excel; + +internal class XLPivotTableAxisFieldStyleFormats : IXLPivotFieldStyleFormats +{ + private readonly XLPivotTable _pivotTable; + private readonly XLPivotTableAxisField _axisField; + + public XLPivotTableAxisFieldStyleFormats(XLPivotTable pivotTable, XLPivotTableAxisField axisField) + { + _pivotTable = pivotTable; + _axisField = axisField; + } + + #region IXLPivotFieldStyleFormats + + public IXLPivotValueStyleFormat DataValuesFormat => new XLPivotValueStyleFormat(_pivotTable, _axisField.Offset); + + public IXLPivotStyleFormat Header + { + get + { + /* + * + * + * The area must have field position and axis, otherwise the style is not correctly + * displayed. + */ + // If table is not compact, each field has it's own header and thus pivot area must + // contain correct position of the field on the axis. If table is compact, there is + // only one header and its position is first in axis, because it's the only one. + var fieldPosition = _pivotTable.Compact ? 0 : _axisField.Position; + var fieldAxis = _axisField.Axis; + var headerArea = new XLPivotArea + { + Field = _axisField.Offset, + Type = XLPivotAreaType.Button, + Axis = fieldAxis, + FieldPosition = (uint)fieldPosition, + }; + + return new XLPivotStyleFormat( + _pivotTable, + area => XLPivotAreaComparer.Instance.Equals(area, headerArea), + () => headerArea); + } + } + + public IXLPivotStyleFormat Label + { + get + { + /* + * + * + * + * + */ + var labelArea = new XLPivotArea + { + DataOnly = false, + LabelOnly = true, + }; + labelArea.AddReference(new XLPivotReference + { + Field = (uint)_axisField.Offset, + }); + + return new XLPivotStyleFormat( + _pivotTable, + area => XLPivotAreaComparer.Instance.Equals(area, labelArea), + () => labelArea); + } + } + + public IXLPivotStyleFormat Subtotal + { + get + { + /* + * + * + * + * + */ + // Subtotal fields in reference can't mix default and custom subtotals. It always must + // reference only one type. Excel doesn't select correct area if they are mixed. + // The outline flag has weird behavior, but is required for subtotals of last field in + // an axis with multiple fields (i.e. subtotals are displayed at the bottom). + var subtotals = _axisField.Subtotals.ToHashSet(); + var subtotalArea = new XLPivotArea + { + Outline = false + }; + subtotalArea.AddReference(new XLPivotReference + { + Field = unchecked((uint)_axisField.Offset), + Subtotals = subtotals, + }); + + return new XLPivotStyleFormat( + _pivotTable, + area => XLPivotAreaComparer.Instance.Equals(area, subtotalArea), + () => subtotalArea); + } + } + + #endregion +} diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotTableStyleFormats.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotTableStyleFormats.cs index 718b650e5..ad00eacd0 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotTableStyleFormats.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotTableStyleFormats.cs @@ -1,25 +1,20 @@ -// Keep this file CodeMaid organised and cleaned -namespace ClosedXML.Excel +// Keep this file CodeMaid organised and cleaned +namespace ClosedXML.Excel; + +internal class XLPivotTableStyleFormats : IXLPivotTableStyleFormats { - internal class XLPivotTableStyleFormats : IXLPivotTableStyleFormats + private readonly XLPivotTable _pivotTable; + + public XLPivotTableStyleFormats(XLPivotTable pivotTable) { - private IXLPivotStyleFormats columnGrandTotalFormats; - private IXLPivotStyleFormats rowGrandTotalFormats; + _pivotTable = pivotTable; + } - #region IXLPivotTableStyleFormats members + #region IXLPivotTableStyleFormats members - public IXLPivotStyleFormats ColumnGrandTotalFormats - { - get { return columnGrandTotalFormats ?? (columnGrandTotalFormats = new XLPivotStyleFormats()); } - set { columnGrandTotalFormats = value; } - } + public IXLPivotStyleFormats ColumnGrandTotalFormats => new XLPivotStyleFormats(_pivotTable, isRowGrand: false); - public IXLPivotStyleFormats RowGrandTotalFormats - { - get { return rowGrandTotalFormats ?? (rowGrandTotalFormats = new XLPivotStyleFormats()); } - set { rowGrandTotalFormats = value; } - } + public IXLPivotStyleFormats RowGrandTotalFormats => new XLPivotStyleFormats(_pivotTable, isRowGrand: true); - #endregion IXLPivotTableStyleFormats members - } + #endregion IXLPivotTableStyleFormats members } diff --git a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotValueStyleFormat.cs b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotValueStyleFormat.cs index 05721c3d7..00b479bcc 100644 --- a/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotValueStyleFormat.cs +++ b/ClosedXML/Excel/PivotTables/PivotStyleFormats/XLPivotValueStyleFormat.cs @@ -1,33 +1,91 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using System; +using System.Collections.Generic; +using System.Linq; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +internal class XLPivotValueStyleFormat : XLPivotStyleFormatBase, IXLPivotValueStyleFormat { - internal class XLPivotValueStyleFormat : XLPivotStyleFormat, IXLPivotValueStyleFormat + /// + /// A list of references that specify which data cells will be styled. + /// A data cell will be styled, if it lies on all referenced fields. + /// The term "lie on" means that either column or a row of data cell + /// intersects a label cell of referenced field. + /// + private readonly List _fieldReferences = new(); + + public XLPivotValueStyleFormat(XLPivotTable pivotTable, FieldIndex fieldIndex) + : base(pivotTable) { - public XLPivotValueStyleFormat(IXLPivotField field = null, IXLStyle style = null) - : base(field, style) - { } + _fieldReferences.Add(new FieldReference(fieldIndex)); + } - #region IXLPivotValueStyleFormat members + #region IXLPivotValueStyleFormat members - public IXLPivotValueStyleFormat AndWith(IXLPivotField field) - { - return AndWith(field, null); - } + public IXLPivotValueStyleFormat AndWith(IXLPivotField field) + { + _fieldReferences.Add(new FieldReference(field.Offset)); + return this; + } - public IXLPivotValueStyleFormat AndWith(IXLPivotField field, Predicate predicate) - { - FieldReferences.Add(new PivotLabelFieldReference(field, predicate)); - return this; - } + public IXLPivotValueStyleFormat AndWith(IXLPivotField field, Predicate? predicate) + { + FieldIndex fieldIndex = field.Offset; + if (fieldIndex.IsDataField) + throw new ArgumentException("Field is a 'data' field.", nameof(field)); - public IXLPivotValueStyleFormat ForValueField(IXLPivotValue valueField) + if (predicate is null) + return AndWith(field); + + var pivotField = PivotTable.PivotFields[fieldIndex]; + var filteredItems = pivotField.GetAllItems(predicate) + .WhereNotNull(fieldItem => fieldItem.ItemIndex) + .Select(itemIndex => (uint)itemIndex) + .ToList(); + + _fieldReferences.Add(new FieldReference(field.Offset, filteredItems)); + return this; + } + + public IXLPivotValueStyleFormat ForValueField(IXLPivotValue valueField) + { + var valuesIndex = PivotTable.DataFields.IndexOf(valueField); + if (valuesIndex == -1) + throw new ArgumentOutOfRangeException($"Field '{valueField.CustomName}' is not among value fields of the pivot table."); + + _fieldReferences.Add(new FieldReference(FieldIndex.DataField, new[] { (uint)valuesIndex })); + return this; + } + + #endregion IXLPivotValueStyleFormat members + + internal override XLPivotArea GetCurrentArea() + { + var area = new XLPivotArea(); + foreach (var fieldReference in _fieldReferences) { - FieldReferences.Add(new PivotValueFieldReference(valueField.SourceName)); - return this; + var reference = new XLPivotReference + { + Field = unchecked((uint?)fieldReference.FieldIndex.Value) + }; + if (fieldReference.Items is not null) + { + foreach (var item in fieldReference.Items) + reference.AddFieldItem(item); + } + + area.AddReference(reference); } - #endregion IXLPivotValueStyleFormat members + return area; + } + + internal override bool Filter(XLPivotArea area) + { + var currentArea = GetCurrentArea(); + return XLPivotAreaComparer.Instance.Equals(area, currentArea); } + + private record FieldReference(FieldIndex FieldIndex, IReadOnlyList? Items = null); } diff --git a/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValue.cs b/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValue.cs index 3723e8236..5225e10b5 100644 --- a/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValue.cs +++ b/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValue.cs @@ -1,27 +1,20 @@ +#nullable disable + using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { - public enum XLPivotSummary - { - Sum, - Count, - Average, - Minimum, - Maximum, - Product, - CountNumbers, - StandardDeviation, - PopulationStandardDeviation, - Variance, - PopulationVariance, - } - + /// + /// Enum describing how is a pivot field values (i.e. in data area) displayed. + /// + /// + /// [ISO-29500] 18.18.70 ST_ShowDataAs + /// public enum XLPivotCalculation { + /// + /// Field values are displayed normally. + /// Normal, DifferenceFrom, PercentageOf, @@ -30,42 +23,121 @@ public enum XLPivotCalculation PercentageOfRow, PercentageOfColumn, PercentageOfTotal, + + /// + /// Basically a relative importance of a value. Closer the value to 1.0 is, the less + /// important it is. Calculated as (value-in-cell * grand-total-of-grand-totals) / + /// (grand-total-row * grand-total-column). + /// Index } + + /// + /// Some calculation from need a value as another an argument + /// of a calculation (e.g. difference from). This enum specifies how to find the reference value. + /// public enum XLPivotCalculationItem { Value, Previous, Next } + /// + /// An enum that specifies how are grouped pivot field values summed up in a single cell of a + /// pivot table. + /// + /// + /// [ISO-29500] 18.18.17 ST_DataConsolidateFunction + /// + public enum XLPivotSummary + { + /// + /// Values are summed up. + /// + Sum, + Count, + Average, + Minimum, + Maximum, + Product, + CountNumbers, + StandardDeviation, + PopulationStandardDeviation, + Variance, + PopulationVariance, + } + + /// + /// A pivot value field, it is basically a specification of how to determine and + /// format values from source to display in the pivot table. + /// public interface IXLPivotValue { - String SourceName { get; } + /// + /// Specifies the index to the base field when the ShowDataAs calculation is in use. + /// Instead of base item, previous or next value can be used through + /// + /// Used only if the value should be showed Show Values As in the value field settings. + /// + /// The name of the column of the relevant base field. + /// + /// + /// Show values as a percent of a specific value of a different field, e.g. as a % of units sold from Q1 (quarts is a base field and Q1 is a base item). + /// + String BaseFieldName { get; set; } + + /// + /// The value of a base item to calculate a value to show in the pivot table. The base item is selected from values of a base field. + /// Returns blank, when value can't be determined. + /// + /// Used only if the value should be showed Show Values As in the value field settings. + /// + /// The value of the referenced base field item. + /// + /// + /// Show values as a percent of a specific value of a different field, e.g. as a % of units sold from Q1 (quarts is a base field and Q1 is a base item). + /// + XLCellValue BaseItemValue { get; set; } + + XLPivotCalculation Calculation { get; set; } + XLPivotCalculationItem CalculationItem { get; set; } + + /// + /// Get custom name of pivot value. If custom name is not specified, return source name as + /// a fallback. + /// String CustomName { get; set; } IXLPivotValueFormat NumberFormat { get; } + String SourceName { get; } XLPivotSummary SummaryFormula { get; set; } - XLPivotCalculation Calculation { get; set; } - String BaseField { get; set; } - String BaseItem { get; set; } - XLPivotCalculationItem CalculationItem { get; set; } - IXLPivotValue SetSummaryFormula(XLPivotSummary value); + IXLPivotValue SetBaseFieldName(String value); + + IXLPivotValue SetBaseItemValue(XLCellValue value); + IXLPivotValue SetCalculation(XLPivotCalculation value); - IXLPivotValue SetBaseField(String value); - IXLPivotValue SetBaseItem(String value); + IXLPivotValue SetCalculationItem(XLPivotCalculationItem value); + IXLPivotValue SetSummaryFormula(XLPivotSummary value); - IXLPivotValue ShowAsNormal(); IXLPivotValueCombination ShowAsDifferenceFrom(String fieldSourceName); - IXLPivotValueCombination ShowAsPercentageFrom(String fieldSourceName); + + IXLPivotValue ShowAsIndex(); + + IXLPivotValue ShowAsNormal(); + IXLPivotValueCombination ShowAsPercentageDifferenceFrom(String fieldSourceName); - IXLPivotValue ShowAsRunningTotalIn(String fieldSourceName); - IXLPivotValue ShowAsPercentageOfRow(); + + IXLPivotValueCombination ShowAsPercentageFrom(String fieldSourceName); + IXLPivotValue ShowAsPercentageOfColumn(); + + IXLPivotValue ShowAsPercentageOfRow(); + IXLPivotValue ShowAsPercentageOfTotal(); - IXLPivotValue ShowAsIndex(); + IXLPivotValue ShowAsRunningTotalIn(String fieldSourceName); } } diff --git a/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValueCombination.cs b/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValueCombination.cs index a6e3a72f7..e1c24900c 100644 --- a/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValueCombination.cs +++ b/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValueCombination.cs @@ -1,14 +1,36 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +#nullable disable namespace ClosedXML.Excel { + /// + /// An interface for fluent configuration of how to show , + /// when the value should be displayed not as a value itself, but in relation to another + /// value (e.g. percentage difference in relation to different value). + /// public interface IXLPivotValueCombination { - IXLPivotValue And(String item); - IXLPivotValue AndPrevious(); + IXLPivotValue And(XLCellValue item); + IXLPivotValue AndNext(); + + /// + /// The base item value for calculation will be the value of the previous row of base + /// field, depending on the order of base field values in a row/column. If there isn't + /// a previous value, the same value will be used. + /// + /// This only affects display how are values displayed, not the values themselves. + /// + /// + /// Example: + /// We have a table of sales and a pivot table, where sales are summed per month. + /// The months are sorted from Jan to Dec. To display a percentage increase of + /// sales per month (the base value is previous month): + /// + /// IXLPivotValue sales; + /// sales.SetSummaryFormula(XLPivotSummary.Sum).ShowAsPercentageDifferenceFrom("Month").AndPrevious(); + /// + /// + /// + IXLPivotValue AndPrevious(); } } diff --git a/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValueFormat.cs b/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValueFormat.cs index 2923cb581..ae11d4d5d 100644 --- a/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValueFormat.cs +++ b/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValueFormat.cs @@ -2,8 +2,17 @@ namespace ClosedXML.Excel { - public interface IXLPivotValueFormat : IXLNumberFormatBase, IEquatable + /// + /// An API for manipulating a format of one + /// data field. + /// + public interface IXLPivotValueFormat : IXLNumberFormatBase { + /// + /// Set number formatting using one of predefined codes. Predefined codes are described in + /// the . + /// + /// A numeric value describing how should the number be formatted. IXLPivotValue SetNumberFormatId(Int32 value); IXLPivotValue SetFormat(String value); diff --git a/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValues.cs b/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValues.cs index 68b9c9f16..e4c336b59 100644 --- a/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValues.cs +++ b/ClosedXML/Excel/PivotTables/PivotValues/IXLPivotValues.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; @@ -6,24 +8,42 @@ namespace ClosedXML.Excel { public interface IXLPivotValues : IEnumerable { + /// + /// Add a new value field to the pivot table. If addition would cause, the + /// field is added to the + /// . The added field will use passed + /// as the . + /// + /// The that is used as a + /// data. Multiple data fields can use same source (e.g. sum and count). + /// Newly added field. IXLPivotValue Add(String sourceName); + /// + /// Add a new value field to the pivot table. If addition would cause, the + /// field is added to the + /// . + /// + /// The that is used as a + /// data. Multiple data fields can use same source (e.g. sum and count). + /// The added data field . + /// Newly added field. IXLPivotValue Add(String sourceName, String customName); void Clear(); - Boolean Contains(String sourceName); + Boolean Contains(String customName); Boolean Contains(IXLPivotValue pivotValue); - IXLPivotValue Get(String sourceName); + IXLPivotValue Get(String customName); IXLPivotValue Get(Int32 index); - Int32 IndexOf(String sourceName); + Int32 IndexOf(String customName); Int32 IndexOf(IXLPivotValue pivotValue); - void Remove(String sourceName); + void Remove(String customName); } } diff --git a/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValue.cs b/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValue.cs deleted file mode 100644 index baed68cd4..000000000 --- a/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValue.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace ClosedXML.Excel -{ - internal class XLPivotValue: IXLPivotValue - { - public XLPivotValue(string sourceName) - { - SourceName = sourceName; - NumberFormat = new XLPivotValueFormat(this); - } - - public IXLPivotValueFormat NumberFormat { get; private set; } - public String SourceName { get; private set; } - public String CustomName { get; set; } public IXLPivotValue SetCustomName(String value) { CustomName = value; return this; } - - public XLPivotSummary SummaryFormula { get; set; } public IXLPivotValue SetSummaryFormula(XLPivotSummary value) { SummaryFormula = value; return this; } - public XLPivotCalculation Calculation { get; set; } public IXLPivotValue SetCalculation(XLPivotCalculation value) { Calculation = value; return this; } - public String BaseField { get; set; } public IXLPivotValue SetBaseField(String value) { BaseField = value; return this; } - public String BaseItem { get; set; } public IXLPivotValue SetBaseItem(String value) { BaseItem = value; return this; } - public XLPivotCalculationItem CalculationItem { get; set; } public IXLPivotValue SetCalculationItem(XLPivotCalculationItem value) { CalculationItem = value; return this; } - - - public IXLPivotValue ShowAsNormal() - { - return SetCalculation(XLPivotCalculation.Normal); - } - public IXLPivotValueCombination ShowAsDifferenceFrom(String fieldSourceName) - { - BaseField = fieldSourceName; - SetCalculation(XLPivotCalculation.DifferenceFrom); - return new XLPivotValueCombination(this); - } - public IXLPivotValueCombination ShowAsPercentageFrom(String fieldSourceName) - { - BaseField = fieldSourceName; - SetCalculation(XLPivotCalculation.PercentageOf); - return new XLPivotValueCombination(this); - } - public IXLPivotValueCombination ShowAsPercentageDifferenceFrom(String fieldSourceName) - { - BaseField = fieldSourceName; - SetCalculation(XLPivotCalculation.PercentageDifferenceFrom); - return new XLPivotValueCombination(this); - } - public IXLPivotValue ShowAsRunningTotalIn(String fieldSourceName) - { - BaseField = fieldSourceName; - return SetCalculation(XLPivotCalculation.RunningTotal); - } - public IXLPivotValue ShowAsPercentageOfRow() - { - return SetCalculation(XLPivotCalculation.PercentageOfRow); - } - - public IXLPivotValue ShowAsPercentageOfColumn() - { - return SetCalculation(XLPivotCalculation.PercentageOfColumn); - } - - public IXLPivotValue ShowAsPercentageOfTotal() - { - return SetCalculation(XLPivotCalculation.PercentageOfTotal); - } - - public IXLPivotValue ShowAsIndex() - { - return SetCalculation(XLPivotCalculation.Index); - } - } -} diff --git a/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValueCombination.cs b/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValueCombination.cs index 5d8c124a9..485d90cbd 100644 --- a/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValueCombination.cs +++ b/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValueCombination.cs @@ -1,32 +1,32 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +#nullable disable namespace ClosedXML.Excel { - internal class XLPivotValueCombination: IXLPivotValueCombination + internal class XLPivotValueCombination : IXLPivotValueCombination { private readonly IXLPivotValue _pivotValue; + public XLPivotValueCombination(IXLPivotValue pivotValue) { _pivotValue = pivotValue; } - public IXLPivotValue And(String item) + public IXLPivotValue And(XLCellValue item) { - _pivotValue.BaseItem = item; - _pivotValue.CalculationItem = XLPivotCalculationItem.Value; - return _pivotValue; + return _pivotValue + .SetBaseItemValue(item) + .SetCalculationItem(XLPivotCalculationItem.Value); } - public IXLPivotValue AndPrevious() + + public IXLPivotValue AndNext() { - _pivotValue.CalculationItem = XLPivotCalculationItem.Previous; - return _pivotValue; + return _pivotValue + .SetCalculationItem(XLPivotCalculationItem.Next); } - public IXLPivotValue AndNext() + + public IXLPivotValue AndPrevious() { - _pivotValue.CalculationItem = XLPivotCalculationItem.Next; - return _pivotValue; + return _pivotValue + .SetCalculationItem(XLPivotCalculationItem.Previous); } } } diff --git a/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValueFormat.cs b/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValueFormat.cs index 8ab869be6..ecfa59a2a 100644 --- a/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValueFormat.cs +++ b/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValueFormat.cs @@ -1,39 +1,53 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ClosedXML.Excel { - internal class XLPivotValueFormat: IXLPivotValueFormat + internal class XLPivotValueFormat : IXLPivotValueFormat { - private readonly XLPivotValue _pivotValue; - public XLPivotValueFormat(XLPivotValue pivotValue) + private readonly XLPivotDataField _pivotValue; + + public XLPivotValueFormat(XLPivotDataField pivotValue) { _pivotValue = pivotValue; - _format = "General"; - _numberFormatId = 0; } - private Int32 _numberFormatId = -1; public Int32 NumberFormatId { - get { return _numberFormatId; } + get => _pivotValue.NumberFormatValue?.NumberFormatId ?? -1; set { - _numberFormatId = value; - _format = string.Empty; + if (value == -1) + { + _pivotValue.NumberFormatValue = null; + return; + } + + var key = new XLNumberFormatKey + { + NumberFormatId = value, + Format = string.Empty, + }; + _pivotValue.NumberFormatValue = XLNumberFormatValue.FromKey(ref key); } } - private String _format = String.Empty; public String Format { - get { return _format; } + get => _pivotValue.NumberFormatValue?.Format ?? string.Empty; set { - _format = value; - _numberFormatId = -1; + if (string.IsNullOrEmpty(value)) + { + _pivotValue.NumberFormatValue = null; + return; + } + + var key = new XLNumberFormatKey + { + NumberFormatId = -1, + Format = value, + }; + _pivotValue.NumberFormatValue = XLNumberFormatValue.FromKey(ref key); } } @@ -42,63 +56,24 @@ public IXLPivotValue SetNumberFormatId(Int32 value) NumberFormatId = value; return _pivotValue; } + public IXLPivotValue SetFormat(String value) { Format = value; - - switch (value) + NumberFormatId = value switch { - case "General": - _numberFormatId = 0; - break; - case "0": - _numberFormatId = 1; - break; - case "0.00": - _numberFormatId = 2; - break; - case "#,##0": - _numberFormatId = 3; - break; - case "#,##0.00": - _numberFormatId = 4; - break; - case "0%": - _numberFormatId = 9; - break; - case "0.00%": - _numberFormatId = 10; - break; - case "0.00E+00": - _numberFormatId = 11; - break; - } - + "General" => 0, + "0" => 1, + "0.00" => 2, + "#,##0" => 3, + "#,##0.00" => 4, + "0%" => 9, + "0.00%" => 10, + "0.00E+00" => 11, + _ => -1, + }; return _pivotValue; } - - #region Overrides - public bool Equals(IXLNumberFormatBase other) - { - return - _numberFormatId == other.NumberFormatId - && _format == other.Format - ; - } - - public override bool Equals(object obj) - { - return Equals((IXLNumberFormatBase)obj); - } - - public override int GetHashCode() - { - return NumberFormatId - ^ Format.GetHashCode(); - } - - #endregion - } } diff --git a/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValues.cs b/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValues.cs deleted file mode 100644 index 186eb5e3a..000000000 --- a/ClosedXML/Excel/PivotTables/PivotValues/XLPivotValues.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Keep this file CodeMaid organised and cleaned -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; - -namespace ClosedXML.Excel -{ - internal class XLPivotValues : IXLPivotValues - { - private readonly IXLPivotTable _pivotTable; - private readonly Dictionary _pivotValues = new Dictionary(StringComparer.OrdinalIgnoreCase); - - internal XLPivotValues(IXLPivotTable pivotTable) - { - this._pivotTable = pivotTable; - } - - public IXLPivotValue Add(String sourceName) - { - return Add(sourceName, sourceName); - } - - public IXLPivotValue Add(String sourceName, String customName) - { - if (sourceName != XLConstants.PivotTable.ValuesSentinalLabel && !this._pivotTable.SourceRangeFieldsAvailable.Contains(sourceName)) - throw new ArgumentOutOfRangeException(nameof(sourceName), String.Format("The column '{0}' does not appear in the source range.", sourceName)); - - var pivotValue = new XLPivotValue(sourceName) { CustomName = customName }; - _pivotValues.Add(customName, pivotValue); - - if (_pivotValues.Count > 1 && this._pivotTable.ColumnLabels.All(cl => cl.SourceName != XLConstants.PivotTable.ValuesSentinalLabel) && this._pivotTable.RowLabels.All(rl => rl.SourceName != XLConstants.PivotTable.ValuesSentinalLabel)) - _pivotTable.ColumnLabels.Add(XLConstants.PivotTable.ValuesSentinalLabel); - - return pivotValue; - } - - public void Clear() - { - _pivotValues.Clear(); - } - - public Boolean Contains(String sourceName) - { - return _pivotValues.ContainsKey(sourceName); - } - - public Boolean Contains(IXLPivotValue pivotValue) - { - return _pivotValues.ContainsKey(pivotValue.SourceName); - } - - public IXLPivotValue Get(String sourceName) - { - return _pivotValues[sourceName]; - } - - public IXLPivotValue Get(Int32 index) - { - return _pivotValues.Values.ElementAt(index); - } - - public IEnumerator GetEnumerator() - { - return _pivotValues.Values.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - public Int32 IndexOf(String sourceName) - { - var selectedItem = _pivotValues.Select((item, index) => new { Item = item, Position = index }).FirstOrDefault(i => i.Item.Key == sourceName); - if (selectedItem == null) - throw new ArgumentNullException(nameof(sourceName), "Invalid field name."); - - return selectedItem.Position; - } - - public Int32 IndexOf(IXLPivotValue pivotValue) - { - return IndexOf(pivotValue.SourceName); - } - - public void Remove(String sourceName) - { - _pivotValues.Remove(sourceName); - } - } -} diff --git a/ClosedXML/Excel/PivotTables/README.md b/ClosedXML/Excel/PivotTables/README.md new file mode 100644 index 000000000..24613ee8d --- /dev/null +++ b/ClosedXML/Excel/PivotTables/README.md @@ -0,0 +1,15 @@ +# Invariants + +Pivot table invariants that often cause Excel repair/crash: + +Repair: +* When a field is in `pivotTableDefintion.dataFields` collection <=> `pivotField.dataField` is set. +* When a field is in `pivotTableDefintion.rowFields` collection => `pivotField.axis` must be set to `axisRow`. +* When a field is in `pivotTableDefintion.colFields` collection => `pivotField.axis` must be set to `axisCol`. +* When a field is in `pivotTableDefintion.pageFields` collection <=> `pivotField.axis` must be set to `axisPage`. +* When pivot table has a page fields, but ref specifies an area that doesn't have enough space above it to fit the page fields. + +Crash: +* `pivotField` doesn't contain even `default` item, but is used in other an axis (row/col/page). +* axis fields can't contain `ΣValues` field If there is only one `pivotTableDefintion.dataFields`, +* If `pivotTableDefintion.rowFields` or `pivotTableDefintion.colFields` reference `ΣValues` field, the `dataPosition` must be set. diff --git a/ClosedXML/Excel/PivotTables/XLPivotArea.cs b/ClosedXML/Excel/PivotTables/XLPivotArea.cs new file mode 100644 index 000000000..7cfa97da7 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotArea.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; + +namespace ClosedXML.Excel; + +/// +/// A rule describing a subset of pivot table. Used mostly for styling through . +/// +/// +/// [ISO-29500] 18.3.1.68 PivotArea +/// +internal class XLPivotArea +{ + private readonly List _references = new(); + + /// + /// A subset of field values that are part of the pivot area. + /// + internal IReadOnlyList References => _references; + + /// + /// Index of the field that this selection rule refers to. + /// + internal FieldIndex? Field { get; init; } + + /// + /// An area of aspect of pivot table that is part of the pivot area. + /// + internal XLPivotAreaType Type { get; init; } = XLPivotAreaType.Normal; + + /// + /// Flag indicating whether only the data values (in the data area of the view) for an item + /// selection are selected and does not include the item labels. Can't be set with together + /// with . + /// + internal bool DataOnly { get; init; } = true; + + /// + /// Flag indicating whether only the item labels for an item selection are selected and does + /// not include the data values(in the data area of the view). Can't be set with together + /// with . + /// + internal bool LabelOnly { get; init; } = false; + + /// + /// Flag indicating whether the row grand total is included. + /// + internal bool GrandRow { get; init; } = false; + + /// + /// Flag indicating whether the column grand total is included. + /// + internal bool GrandCol { get; init; } = false; + + /// + /// Flag indicating whether indexes refer to fields or items in the pivot cache and not the + /// view. + /// + internal bool CacheIndex { get; init; } = false; + + /// + /// Flag indicating whether the rule refers to an area that is in outline mode. + /// + internal bool Outline { get; init; } = true; + + /// + /// A reference that specifies a subset of the selection area. Points are relative to the top + /// left of the selection area. + /// + internal XLSheetRange? Offset { get; init; } + + /// + /// Flag indicating if collapsed levels/dimensions are considered subtotals. + /// + internal bool CollapsedLevelsAreSubtotals { get; init; } = false; + + /// + /// The region of the pivot table to which this rule applies. + /// + internal XLPivotAxis? Axis { get; init; } + + /// + /// Position of the field within the axis to which this rule applies. + /// + internal uint? FieldPosition { get; init; } + + internal void AddReference(XLPivotReference reference) + { + _references.Add(reference); + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotAreaComparer.cs b/ClosedXML/Excel/PivotTables/XLPivotAreaComparer.cs new file mode 100644 index 000000000..460aec5f4 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotAreaComparer.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel; + +internal class XLPivotAreaComparer : IEqualityComparer +{ + private readonly XLPivotReferenceComparer _referenceComparer = new(); + + public static readonly XLPivotAreaComparer Instance = new(); + + public bool Equals(XLPivotArea? x, XLPivotArea? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x is null) + return false; + + if (y is null) + return false; + + return x.References.SequenceEqual(y.References, _referenceComparer) && + Nullable.Equals(x.Field, y.Field) && + x.Type == y.Type && + x.DataOnly == y.DataOnly && + x.LabelOnly == y.LabelOnly && + x.GrandRow == y.GrandRow && + x.GrandCol == y.GrandCol && + x.CacheIndex == y.CacheIndex && + x.Outline == y.Outline && + Nullable.Equals(x.Offset, y.Offset) && + x.CollapsedLevelsAreSubtotals == y.CollapsedLevelsAreSubtotals && + x.Axis == y.Axis && + x.FieldPosition == y.FieldPosition; + } + + public int GetHashCode(XLPivotArea obj) + { + var hashCode = new HashCode(); + foreach (var reference in obj.References) + hashCode.Add(reference, _referenceComparer); + + hashCode.Add(obj.Field); + hashCode.Add(obj.Type); + hashCode.Add(obj.DataOnly); + hashCode.Add(obj.LabelOnly); + hashCode.Add(obj.GrandRow); + hashCode.Add(obj.GrandCol); + hashCode.Add(obj.CacheIndex); + hashCode.Add(obj.Outline); + hashCode.Add(obj.Offset); + hashCode.Add(obj.CollapsedLevelsAreSubtotals); + hashCode.Add(obj.Axis); + hashCode.Add(obj.FieldPosition); + return hashCode.ToHashCode(); + } + + private class XLPivotReferenceComparer : IEqualityComparer + { + public bool Equals(XLPivotReference? x, XLPivotReference? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x is null) + return false; + + if (y is null) + return false; + + return x.FieldItems.SequenceEqual(y.FieldItems) && + x.Field == y.Field && + x.Selected == y.Selected && + x.ByPosition == y.ByPosition && + x.Relative == y.Relative && + x.Subtotals.SetEquals(y.Subtotals); + } + + public int GetHashCode(XLPivotReference obj) + { + var hashCode = new HashCode(); + foreach (var item in obj.FieldItems) + hashCode.Add(item); + + hashCode.Add(obj.Field); + hashCode.Add(obj.Selected); + hashCode.Add(obj.ByPosition); + hashCode.Add(obj.Relative); + + foreach (var subtotal in obj.Subtotals) + hashCode.Add(subtotal); + + return hashCode.ToHashCode(); + } + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotAreaType.cs b/ClosedXML/Excel/PivotTables/XLPivotAreaType.cs new file mode 100644 index 000000000..4dee2e6d8 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotAreaType.cs @@ -0,0 +1,22 @@ +namespace ClosedXML.Excel +{ + /// + /// An area of aspect of pivot table that is part of the . + /// + /// + /// [ISO-29500] 18.18.58 ST_PivotAreaType + /// + internal enum XLPivotAreaType + { + None = 0, + Normal = 1, + Data = 2, + All = 3, + Origin = 4, + Button = 5, + + // Top right has been removed between ISO-29500:2006 and ISO-29500:2016. + TopRight = 6, + TopEnd = 7 + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotAreaValues.cs b/ClosedXML/Excel/PivotTables/XLPivotAreaValues.cs deleted file mode 100644 index 6609b4a23..000000000 --- a/ClosedXML/Excel/PivotTables/XLPivotAreaValues.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ClosedXML.Excel -{ - internal enum XLPivotAreaValues - { - None = 0, - Normal = 1, - Data = 2, - All = 3, - Origin = 4, - Button = 5, - TopRight = 6, - TopEnd = 7 - } -} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCache.cs b/ClosedXML/Excel/PivotTables/XLPivotCache.cs new file mode 100644 index 000000000..7005319fe --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCache.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using ClosedXML.Excel.Exceptions; + +namespace ClosedXML.Excel +{ + internal class XLPivotCache : IXLPivotCache + { + private readonly XLWorkbook _workbook; + private readonly Dictionary _fieldIndexes = new(XLHelper.NameComparer); + private readonly List _fieldNames = new(); + + /// + /// Length is a number of fields, in same order as . + /// + private readonly List _values = new(); + + internal XLPivotCache(IXLPivotSource source, XLWorkbook workbook) + { + _workbook = workbook; + Guid = Guid.NewGuid(); + SetExcelDefaults(); + Source = source; + } + + #region IXLPivotCache members + + public IReadOnlyList FieldNames => _fieldNames; + + public XLItemsToRetain ItemsToRetainPerField { get; set; } + + public Boolean RefreshDataOnOpen { get; set; } + + public Boolean SaveSourceData { get; set; } + + /// + /// Number of fields in the cache. + /// + internal int FieldCount => _fieldNames.Count; + + internal int RecordCount => _fieldNames.Count > 0 ? _values[0].Count : 0; + + public IXLPivotCache Refresh() + { + // Refresh can only happen if the reference is valid. + if (!Source.TryGetSource(_workbook, out var sheet, out var foundArea)) + throw new InvalidReferenceException(); + + Debug.Assert(sheet is not null && foundArea is not null); + var oldFieldNames = _fieldNames.ToList(); + _fieldIndexes.Clear(); + _fieldNames.Clear(); + _values.Clear(); + + var valueSlice = sheet.Internals.CellsCollection.ValueSlice; + var area = foundArea.Value; + for (var column = area.LeftColumn; column <= area.RightColumn; ++column) + { + var header = sheet.Cell(area.TopRow, column).GetFormattedString(); + + var fieldRecords = new XLPivotCacheValues(valueSlice, column, area); + + AddField(AdjustedFieldName(header), fieldRecords); + } + + UpdatePivotTables(); + return this; + + void UpdatePivotTables() + { + foreach (var worksheet in _workbook.WorksheetsInternal) + { + foreach (var pivotTable in worksheet.PivotTables) + { + if (pivotTable.PivotCache == this) + pivotTable.UpdateCacheFields(oldFieldNames); + } + } + } + } + + public IXLPivotCache SetItemsToRetainPerField(XLItemsToRetain value) { ItemsToRetainPerField = value; return this; } + + public IXLPivotCache SetRefreshDataOnOpen() => SetRefreshDataOnOpen(true); + + public IXLPivotCache SetRefreshDataOnOpen(Boolean value) { RefreshDataOnOpen = value; return this; } + + public IXLPivotCache SetSaveSourceData() => SetSaveSourceData(true); + + public IXLPivotCache SetSaveSourceData(Boolean value) { SaveSourceData = value; return this; } + + #endregion + + /// + /// Pivot cache definition id from the file. + /// + internal uint? CacheId { get; set; } + + internal Guid Guid { get; } + + /// + /// A source of the in the cache. Can be used to refresh the cache. May not always be + /// available (e.g. external source) + /// + internal IXLPivotSource Source { get; set; } + + internal String? WorkbookCacheRelId { get; set; } + + internal XLPivotCache AddCachedField(String fieldName, XLPivotCacheValues fieldValues) + { + if (_fieldNames.Contains(fieldName, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Source already contains field {fieldName}."); + } + + AddField(fieldName, fieldValues); + return this; + } + + /// + /// Try to get a field index for a field name. + /// + /// Name of the field. + /// The found index, start at 0. + /// True if source contains the field. + internal bool TryGetFieldIndex(String fieldName, out int index) + { + return _fieldIndexes.TryGetValue(fieldName, out index); + } + + internal bool ContainsField(String fieldName) => _fieldIndexes.ContainsKey(fieldName); + + internal XLPivotCacheValues GetFieldValues(int fieldIndex) + { + return _values[fieldIndex]; + } + + internal XLPivotCacheSharedItems GetFieldSharedItems(int fieldIndex) + { + return _values[fieldIndex].SharedItems; + } + + internal void AllocateRecordCapacity(int recordCount) + { + foreach (var fieldValues in _values) + { + fieldValues.AllocateCapacity(recordCount); + } + } + + private String AdjustedFieldName(String header) + { + var modifiedHeader = header; + var i = 1; + while (_fieldNames.Contains(modifiedHeader, StringComparer.OrdinalIgnoreCase)) + { + i++; + modifiedHeader = header + i.ToInvariantString(); + } + + return modifiedHeader; + } + + private void AddField(String fieldName, XLPivotCacheValues fieldValues) + { + _fieldIndexes.Add(fieldName, _fieldNames.Count); + _fieldNames.Add(fieldName); + _values.Add(fieldValues); + } + + private void SetExcelDefaults() + { + SaveSourceData = true; + } + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCacheSharedItems.cs b/ClosedXML/Excel/PivotTables/XLPivotCacheSharedItems.cs new file mode 100644 index 000000000..986603fce --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCacheSharedItems.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using ClosedXML.Excel.Cells; + +namespace ClosedXML.Excel +{ + /// + /// + /// A list of in the pivot table cache + /// definition. Generally, it contains all strings of the field records + /// (record just indexes them through ) + /// and also values used directly in pivot table (e.g. filter field reference + /// the table definition, not record). + /// + /// + /// Shared items can't contain . + /// + /// + internal class XLPivotCacheSharedItems + { + private readonly List _values = new(); + + /// + /// Storage of strings to save 8 bytes per XLPivotCacheValue + /// (reference can't be aliased with a number). + /// + private readonly List _stringStorage = new(); + + /// + /// Strings in a pivot table are case-insensitive. + /// + private readonly Dictionary _stringMap = new(StringComparer.OrdinalIgnoreCase); + + internal XLCellValue this[uint index] => GetValue(index).GetCellValue(_stringStorage, this); + + internal int Count => _values.Count; + + internal void Add(XLCellValue value) + { + switch (value.Type) + { + case XLDataType.Blank: + AddMissing(); + break; + case XLDataType.Boolean: + AddBoolean(value.GetBoolean()); + break; + case XLDataType.Number: + AddNumber(value.GetNumber()); + break; + case XLDataType.Text: + AddString(value.GetText()); + break; + case XLDataType.Error: + AddError(value.GetError()); + break; + case XLDataType.DateTime: + AddDateTime(value.GetDateTime()); + break; + case XLDataType.TimeSpan: + var timeSpan = value.GetTimeSpan().ToSerialDateTime().ToSerialDateTime(); + AddDateTime(timeSpan); + break; + default: + throw new UnreachableException(); + } + } + + internal void AddMissing() + { + _values.Add(XLPivotCacheValue.ForMissing()); + } + + internal void AddNumber(double number) + { + _values.Add(XLPivotCacheValue.ForNumber(number)); + } + + internal void AddBoolean(bool boolean) + { + _values.Add(XLPivotCacheValue.ForBoolean(boolean)); + } + + internal void AddError(XLError error) + { + _values.Add(XLPivotCacheValue.ForError(error)); + } + + internal void AddString(string text) + { + // Shared items doesn't distinguish between two texts that differ only in case. + if (!_stringMap.ContainsKey(text)) + { + var index = _stringStorage.Count; + _values.Add(XLPivotCacheValue.ForText(text, _stringStorage)); + _stringMap.Add(text, index); + } + } + + internal void AddDateTime(DateTime dateTime) + { + _values.Add(XLPivotCacheValue.ForDateTime(dateTime)); + } + + internal IEnumerable GetCellValues() + { + foreach (var value in _values) + { + yield return value.GetCellValue(_stringStorage, this); + } + } + + internal XLPivotCacheValue GetValue(uint index) + { + return _values[checked((int)index)]; + } + + internal string GetStringValue(uint index) + { + var value = GetValue(index); + return value.GetText(_stringStorage); + } + + /// + /// Get index of value or -1 if not among shared items. + /// + internal int IndexOf(XLCellValue value) + { + for (var index = 0; index < _values.Count; ++index) + { + var sharedValue = _values[index]; + var cacheValue = sharedValue.GetCellValue(_stringStorage, this); + if (XLCellValueComparer.OrdinalIgnoreCase.Equals(cacheValue, value)) + return index; + } + + return -1; + } + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCacheSourceConsolidationPage.cs b/ClosedXML/Excel/PivotTables/XLPivotCacheSourceConsolidationPage.cs new file mode 100644 index 000000000..9bdd25c03 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCacheSourceConsolidationPage.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace ClosedXML.Excel; + +/// +/// A page filter for pivot table that uses as the source +/// of data. It is basically a container of strings that are displayed in a page filter above +/// the pivot table. +/// +internal class XLPivotCacheSourceConsolidationPage +{ + internal XLPivotCacheSourceConsolidationPage(List pageItems) + { + PageItems = pageItems; + } + + /// + /// Page items (=names) displayed in the filter. The value is referenced + /// through index by . + /// + internal IReadOnlyList PageItems { get; } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCacheSourceConsolidationRangeSet.cs b/ClosedXML/Excel/PivotTables/XLPivotCacheSourceConsolidationRangeSet.cs new file mode 100644 index 000000000..7b38ee7a6 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCacheSourceConsolidationRangeSet.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace ClosedXML.Excel; + +/// +/// One of ranges that form a source for a . +/// +internal class XLPivotCacheSourceConsolidationRangeSet +{ + /// + /// Indexes into the . If the value is null + /// and page filter exists, it is displayed as a blank. There can be at most 4 indexes, because + /// there can be at most 4 page filters. + /// + public IReadOnlyList Indexes { get; init; } = Array.Empty(); + + /// + /// If range set is from another workbook, a relationship id to the workbook from cache definition. + /// + internal string? RelId { get; init; } + + [MemberNotNullWhen(true, nameof(TableOrName))] + [MemberNotNullWhen(false, nameof(Area))] + internal bool UsesName => TableOrName is not null; + + internal string? TableOrName { get; init; } + + internal XLBookArea? Area { get; init; } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCacheValue.cs b/ClosedXML/Excel/PivotTables/XLPivotCacheValue.cs new file mode 100644 index 000000000..1ed2b5de3 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCacheValue.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel +{ + /// + /// Represents a single value in a pivot cache record. + /// + internal readonly struct XLPivotCacheValue + { + /// + /// A memory used to hold value of a . Its + /// interpretation depends on the type. It doesn't hold value + /// for strings directly, because GC doesn't allow aliasing + /// same 8 bytes for number or references. For strings, it contains + /// an index to a string storage array that is stored separately. + /// + private readonly double _value; + + private XLPivotCacheValue(XLPivotCacheValueType type, double value) + { + Type = type; + _value = value; + } + + internal XLPivotCacheValueType Type { get; } + + internal static XLPivotCacheValue ForMissing() + { + return new XLPivotCacheValue(XLPivotCacheValueType.Missing, 0); + } + + internal static XLPivotCacheValue ForNumber(double number) + { + if (double.IsNaN(number) || double.IsInfinity(number)) + throw new ArgumentOutOfRangeException(); + + return new XLPivotCacheValue(XLPivotCacheValueType.Number, number); + } + + internal static XLPivotCacheValue ForBoolean(bool boolean) + { + return new XLPivotCacheValue(XLPivotCacheValueType.Boolean, boolean ? 1 : 0); + } + + internal static XLPivotCacheValue ForError(XLError error) + { + return new XLPivotCacheValue(XLPivotCacheValueType.Error, (int)error); + } + + internal static XLPivotCacheValue ForText(string text, List storage) + { + var index = storage.Count; + storage.Add(text); + return new XLPivotCacheValue(XLPivotCacheValueType.String, BitConverter.Int64BitsToDouble(index)); + } + + internal static XLPivotCacheValue ForText(string text, Dictionary stringMap, List storage) + { + if (!stringMap.TryGetValue(text, out var index)) + { + index = storage.Count; + storage.Add(text); + stringMap.Add(text, index); + return new XLPivotCacheValue(XLPivotCacheValueType.String, BitConverter.Int64BitsToDouble(index)); + } + + return new XLPivotCacheValue(XLPivotCacheValueType.String, BitConverter.Int64BitsToDouble(index)); + } + + internal static XLPivotCacheValue ForDateTime(DateTime dateTime) + { + return new XLPivotCacheValue(XLPivotCacheValueType.DateTime, BitConverter.Int64BitsToDouble(dateTime.Ticks)); + } + + internal static XLPivotCacheValue ForIndex(uint index) + { + return new XLPivotCacheValue(XLPivotCacheValueType.Index, BitConverter.Int64BitsToDouble(index)); + } + + internal XLCellValue GetCellValue(List stringStorage, XLPivotCacheSharedItems sharedItems) + { + switch (Type) + { + case XLPivotCacheValueType.Missing: + return Blank.Value; + + case XLPivotCacheValueType.Number: + return _value; + + case XLPivotCacheValueType.Boolean: + return _value != 0; + + case XLPivotCacheValueType.Error: + return (XLError)_value; + + case XLPivotCacheValueType.String: + return GetText(stringStorage); + + case XLPivotCacheValueType.DateTime: + return GetDateTime(); + + case XLPivotCacheValueType.Index: + var intIndex = unchecked((uint)BitConverter.DoubleToInt64Bits(_value)); + return sharedItems[intIndex]; + + default: + throw new NotSupportedException(); + } + } + + internal double GetNumber() => _value; + + internal Boolean GetBoolean() + { + return _value != 0; + } + + internal XLError GetError() + { + return (XLError)_value; + } + + internal string GetText(IReadOnlyList stringStorage) + { + var stringIndex = unchecked((int)BitConverter.DoubleToInt64Bits(_value)); + return stringStorage[stringIndex]; + } + + internal DateTime GetDateTime() + { + var ticks = BitConverter.DoubleToInt64Bits(_value); + return new DateTime(ticks); + } + + internal uint GetIndex() + { + return unchecked((uint)BitConverter.DoubleToInt64Bits(_value)); + } + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCacheValueType.cs b/ClosedXML/Excel/PivotTables/XLPivotCacheValueType.cs new file mode 100644 index 000000000..037d4d802 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCacheValueType.cs @@ -0,0 +1,47 @@ +namespace ClosedXML.Excel +{ + /// + /// An enum that represents types of values in pivot cache records. It represents + /// values under CT_Record type. + /// + internal enum XLPivotCacheValueType + { + /// + /// A blank value. Keep at 0 so newly allocated arrays of values have a value of missing. + /// + Missing = 0, + + /// + /// Double precision number, not NaN or infinity. + /// + Number, + + /// + /// Bool value. + /// + Boolean, + + /// + /// value. + /// + Error, + + /// + /// Cache value is a string. Because references can't be converted to number (GC would not accept it), + /// the value is an index into a table of strings in the cache. + /// + String, + + /// + /// Value is a date time. Although the value can be in theory csd:dateTime (i.e. with offsets and zulu), + /// the time offsets are not permitted (Excel refused to load cache data) and zulu is ignored. + /// + DateTime, + + /// + /// Value is a reference to the shared item. The index value is an + /// index into the shared items array of the field. + /// + Index, + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCacheValues.cs b/ClosedXML/Excel/PivotTables/XLPivotCacheValues.cs new file mode 100644 index 000000000..356819f68 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCacheValues.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using ClosedXML.Excel.Cells; + +namespace ClosedXML.Excel +{ + /// + /// All values of a cache field for a pivot table. + /// + internal class XLPivotCacheValues + { + private readonly XLPivotCacheSharedItems _sharedItems; + + private readonly List _values; + + private readonly List _stringStorage; + + private bool _containsBlank; + + private bool _containsNumber; + + private double? _minValue; + + private double? _maxValue; + + /// + private bool _containsInteger; + + /// + private bool _containsString; + + /// + private bool _longText; + + /// + private bool _containsDate; + + private long? _minDateTicks; + + private long? _maxDateTicks; + + internal XLPivotCacheValues(ValueSlice valueSlice, int column, XLSheetRange area) + { + _sharedItems = new XLPivotCacheSharedItems(); + _values = new List(); + _stringStorage = new List(); + + Initialize(valueSlice, column, area); + } + + internal XLPivotCacheValues(XLPivotCacheSharedItems sharedItems, XLPivotCacheValuesStats stats) + { + _sharedItems = sharedItems; + _values = new List(); + _stringStorage = new List(); + + // Have a separate fields instead of one large struct. That way, + // the flags are more easily set when record values are being added. + _containsBlank = stats.ContainsBlank; + _containsNumber = stats.ContainsNumber; + _containsInteger = stats.ContainsInteger; + _minValue = stats.MinValue; + _maxValue = stats.MaxValue; + _containsString = stats.ContainsString; + _longText = stats.LongText; + _containsDate = stats.ContainsDate; + _minDateTicks = stats.MinDate?.Ticks; + _maxDateTicks = stats.MaxDate?.Ticks; + } + + internal XLPivotCacheValuesStats Stats + { + get + { + DateTime? minDate = _containsDate && _minDateTicks is not null ? new DateTime(_minDateTicks.Value) : null; + DateTime? maxDate = _containsDate && _maxDateTicks is not null ? new DateTime(_maxDateTicks.Value) : null; + + return new XLPivotCacheValuesStats( + _containsBlank, + _containsNumber, + _containsInteger, + _minValue, + _maxValue, + _containsString, + _longText, + _containsDate, + minDate, + maxDate); + } + } + + internal int Count => _values.Count; + + internal int SharedCount => _sharedItems.Count; + + internal XLPivotCacheSharedItems SharedItems => _sharedItems; + + internal void AddMissing() + { + _values.Add(XLPivotCacheValue.ForMissing()); + _containsBlank = true; + } + + internal void AddNumber(double number) + { + _values.Add(XLPivotCacheValue.ForNumber(number)); + AdjustStats(number); + } + + internal void AddBoolean(bool boolean) + { + _values.Add(XLPivotCacheValue.ForBoolean(boolean)); + + // [MS-OI29500]: In Office, boolean and error are considered strings in the context of the containsString attribute. + _containsString = true; + } + + internal void AddError(XLError error) + { + _values.Add(XLPivotCacheValue.ForError(error)); + + // [MS-OI29500]: In Office, boolean and error are considered strings in the context of the containsString attribute. + _containsString = true; + } + + internal void AddString(string text) + { + _values.Add(XLPivotCacheValue.ForText(text, _stringStorage)); + AdjustStats(text); + } + + internal void AddDateTime(DateTime dateTime) + { + _values.Add(XLPivotCacheValue.ForDateTime(dateTime)); + AdjustStats(dateTime); + } + + internal void AddIndex(uint index) + { + if (index >= _sharedItems.Count) + throw new ArgumentException("Index is referencing non-existent shared item."); + + _values.Add(XLPivotCacheValue.ForIndex(index)); + + // Get value referenced by added index value, so stats can be updated. + var cacheValue = _sharedItems.GetValue(index); + switch (cacheValue.Type) + { + case XLPivotCacheValueType.Missing: + _containsBlank = true; + break; + case XLPivotCacheValueType.Number: + AdjustStats(cacheValue.GetNumber()); + break; + case XLPivotCacheValueType.Boolean: + _containsString = true; + break; + case XLPivotCacheValueType.Error: + _containsString = true; + break; + case XLPivotCacheValueType.String: + AdjustStats(_sharedItems.GetStringValue(index)); + break; + case XLPivotCacheValueType.DateTime: + AdjustStats(cacheValue.GetDateTime()); + break; + default: + throw new NotSupportedException(); + } + } + + internal XLPivotCacheValue GetValue(int recordIdx) + { + return _values[recordIdx]; + } + + internal string GetText(XLPivotCacheValue value) + { + Debug.Assert(value.Type == XLPivotCacheValueType.String); + return value.GetText(_stringStorage); + } + + internal void AllocateCapacity(int recordCount) + { + _values.Capacity = recordCount; + } + + internal IEnumerable GetCellValues() + { + foreach (var value in _values) + { + yield return value.GetCellValue(_stringStorage, _sharedItems); + } + } + + /// + /// Get or add a value to the shared items. Throw, if value is not in items. + /// + /// Index in shared items. + internal int GetOrAddSharedItem(XLCellValue value) + { + var sharedItemsIndex = _sharedItems.IndexOf(value); + if (sharedItemsIndex >= 0) + return sharedItemsIndex; + + // Not in shared items, make sure it actually is present. + if (!ContainsRecord(value)) + throw new ArgumentException($"Value '{value}' not among cache field values."); + + _sharedItems.Add(value); + + return _sharedItems.Count - 1; + } + + /// + /// Is among the value among values of the record. + /// + private bool ContainsRecord(XLCellValue value) + { + for (var index = 0; index < _values.Count; ++index) + { + var recordValue = GetValue(index); + var cacheValue = recordValue.GetCellValue(_stringStorage, _sharedItems); + if (XLCellValueComparer.OrdinalIgnoreCase.Equals(cacheValue, value)) + return true; + } + + return false; + } + + private void Initialize(ValueSlice valueSlice, int column, XLSheetRange area) + { + var uniqueItems = new HashSet(XLCellValueComparer.OrdinalIgnoreCase); + for (var row = area.TopRow + 1; row <= area.BottomRow; ++row) + { + var value = valueSlice.GetCellValue(new XLSheetPoint(row, column)); + + // Add to shared items first, because value can be an index to shared items. + if (uniqueItems.Add(value)) + _sharedItems.Add(value); + + switch (value.Type) + { + case XLDataType.Blank: + AddMissing(); + break; + case XLDataType.Boolean: + AddBoolean(value.GetBoolean()); + break; + case XLDataType.Number: + AddNumber(value.GetNumber()); + break; + case XLDataType.Text: + AddString(value.GetText()); + break; + case XLDataType.Error: + AddError(value.GetError()); + break; + case XLDataType.DateTime: + AddDateTime(value.GetDateTime()); + break; + case XLDataType.TimeSpan: + // TimeSpan is represented as datetime in pivot cache, e.g. 14:30 into 1899-12-30T14:30:00 + var adjustedTimeSpan = DateTime.FromOADate(0).Add(value.GetTimeSpan()); + AddDateTime(adjustedTimeSpan); + break; + default: + throw new UnreachableException(); + } + } + } + + [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "double.IsInteger() in NET7 uses same method.")] + private void AdjustStats(double number) + { + // containsInt is true only if all numbers are integers. + _containsInteger = + // First ever number is an integer. + (!_containsNumber && number == Math.Truncate(number)) + || + // Subsequent number is an integer. + (_containsInteger && number == Math.Truncate(number)); + _containsNumber = true; + _minValue = _minValue is null ? number : Math.Min(_minValue.Value, number); + _maxValue = _maxValue is null ? number : Math.Max(_maxValue.Value, number); + } + + private void AdjustStats(string text) + { + _containsString = true; + _longText = _longText || text.Length > 255; + } + + private void AdjustStats(DateTime dateTime) + { + _containsDate = true; + var dateTicks = dateTime.Ticks; + _minDateTicks = _minDateTicks is null ? dateTicks : Math.Min(_minDateTicks.Value, dateTicks); + _maxDateTicks = _maxDateTicks is null ? dateTicks : Math.Max(_maxDateTicks.Value, dateTicks); + } + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCacheValuesStats.cs b/ClosedXML/Excel/PivotTables/XLPivotCacheValuesStats.cs new file mode 100644 index 000000000..bc1bcd54d --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCacheValuesStats.cs @@ -0,0 +1,70 @@ +using System; + +namespace ClosedXML.Excel +{ + /// + /// Statistics about a pivot cache field + /// values. These statistics are available, even if cache field + /// doesn't have any record values. + /// + internal readonly struct XLPivotCacheValuesStats + { + internal XLPivotCacheValuesStats( + bool containsBlank, + bool containsNumber, + bool containsInteger, + double? minValue, + double? maxValue, + bool containsString, + bool longText, + bool containsDate, + DateTime? minDate, + DateTime? maxDate) + { + ContainsBlank = containsBlank; + ContainsNumber = containsNumber; + ContainsInteger = containsInteger; + MinValue = minValue; + MaxValue = maxValue; + ContainsString = containsString; + LongText = longText; + ContainsDate = containsDate; + MinDate = minDate; + MaxDate = maxDate; + } + + internal bool ContainsBlank { get; } + + internal bool ContainsNumber { get; } + + /// + /// Are all numbers in the field integers? Doesn't + /// have to fit into int32/64, just no fractions. + /// + internal bool ContainsInteger { get; } + + internal double? MinValue { get; } + + internal double? MaxValue { get; } + + /// + /// Does field contain any string, boolean or error? + /// + internal bool ContainsString { get; } + + /// + /// Is any text longer than 255 chars? + /// + internal bool LongText { get; } + + /// + /// Is any value DateTime or TimeSpan? TimeSpan is + /// converted to 1899-12-31TXX:XX:XX date. + /// + internal bool ContainsDate { get; } + + internal DateTime? MinDate { get; } + + internal DateTime? MaxDate { get; } + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCaches.cs b/ClosedXML/Excel/PivotTables/XLPivotCaches.cs new file mode 100644 index 000000000..27795ff56 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCaches.cs @@ -0,0 +1,76 @@ +using System.Collections; +using System.Collections.Generic; + +namespace ClosedXML.Excel +{ + internal class XLPivotCaches : IXLPivotCaches, IEnumerable + { + private readonly XLWorkbook _workbook; + private readonly List _caches = new(); + + public XLPivotCaches(XLWorkbook workbook) + { + _workbook = workbook; + } + + IXLPivotCache IXLPivotCaches.Add(IXLRange range) => Add(XLBookArea.From(range)); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public List.Enumerator GetEnumerator() => _caches.GetEnumerator(); + + internal XLPivotCache Add(XLBookArea area) + { + var source = _workbook.TryGetTable(area, out var table) + ? new XLPivotSourceReference(table.Name) + : new XLPivotSourceReference(area); + + var newPivotCache = new XLPivotCache(source, _workbook); + newPivotCache.Refresh(); + _caches.Add(newPivotCache); + return newPivotCache; + } + + internal XLPivotCache Add(IXLPivotSource source) + { + var newPivotCache = new XLPivotCache(source, _workbook); + _caches.Add(newPivotCache); + return newPivotCache; + } + + /// + /// Try to find an existing pivot cache for the passed area. The area + /// is checked against both types of source references (tables and + /// ranges) and if area matches, the cache is returned. + /// + internal XLPivotCache? Find(XLBookArea area) + { + // This method mimics behavior of Excel. + // If there is a table for the area and there is a cache for the table, return cache for the table. + if (_workbook.TryGetTable(area, out var table)) + { + // Table exists, so try to find it and match with the source reference. + var tableSource = new XLPivotSourceReference(table.Name); + foreach (var cache in _caches) + { + if (cache.Source.Equals(tableSource)) + return cache; + } + } + + // Try to find a cache with area source. + var areaSource = new XLPivotSourceReference(area); + foreach (var cache in _caches) + { + if (cache.Source.Equals(areaSource)) + return cache; + } + + return null; + } + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCfRuleType.cs b/ClosedXML/Excel/PivotTables/XLPivotCfRuleType.cs new file mode 100644 index 000000000..b0c1f9dd0 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCfRuleType.cs @@ -0,0 +1,15 @@ +namespace ClosedXML.Excel; + +/// +/// Specifies how to apply conditional formatting rule +/// on a pivot table . Avoid if possible, doesn't seem to +/// work and row/column causes Excel to repair file. +/// +/// 18.18.84 ST_Type. +internal enum XLPivotCfRuleType +{ + All, + Column, + None, + Row +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotCfScope.cs b/ClosedXML/Excel/PivotTables/XLPivotCfScope.cs new file mode 100644 index 000000000..cedb27afc --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotCfScope.cs @@ -0,0 +1,31 @@ +namespace ClosedXML.Excel; + +/// +/// Defines a scope of conditional formatting applied to . The scope is +/// more of a "user preference", it doesn't determine actual scope. The actual scope is determined +/// by . The scope determines what is in GUI and when +/// reapplied, it updates the according to selected +/// values. +/// +/// 18.18.67 ST_Scope +internal enum XLPivotCfScope +{ + /// + /// Conditional formatting is applied to selected cells. When scope is applied, CF areas are be + /// updated to contain currently selected cells in GUI. + /// + SelectedCells, + + /// + /// Conditional formatting is applied to selected data fields. When scope is applied, CF areas + /// are be updated to contain data fields of selected cells in GUI. + /// + DataFields, + + /// + /// Conditional formatting is applied to selected pivot fields intersections. When scope is + /// applied, CF areas are be updated to contain row/column intersection of currently selected + /// cell in GUI. + /// + FieldIntersections, +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotConditionalFormat.cs b/ClosedXML/Excel/PivotTables/XLPivotConditionalFormat.cs new file mode 100644 index 000000000..d37d31aba --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotConditionalFormat.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; + +namespace ClosedXML.Excel; + +/// +/// Specification of conditional formatting of a pivot table. +/// +internal class XLPivotConditionalFormat +{ + private readonly List _area = new(); + + internal XLPivotConditionalFormat(XLConditionalFormat format) + { + Format = format; + } + + /// + /// An option to display in GUI on how to update . + /// + internal XLPivotCfScope Scope { get; init; } = XLPivotCfScope.SelectedCells; + + /// + /// A rule that determines how should CF be applied to . + /// + /// Doesn't seem to work, Excel has no dialogue, nothing found on web and Excel tries + /// to repair on row/column values. Avoid if possible. + internal XLPivotCfRuleType Type { get; init; } = XLPivotCfRuleType.None; + + /// + /// Areas of pivot table the rule should be applied. The areas are projected to the sheet + /// that Excel actually uses to display CF. + /// + internal IReadOnlyList Areas => _area; + + /// + /// Conditional format applied to the . + /// + /// + /// The of the format is used as a identifier used + /// to connect pivot CF element and sheet CF element. Pivot CF is ultimately part of sheet CFs + /// and the priority determines order of CF application (note that CF has + /// flag). + /// + internal XLConditionalFormat Format { get; } + + internal void AddArea(XLPivotArea pivotArea) + { + _area.Add(pivotArea); + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotField.cs b/ClosedXML/Excel/PivotTables/XLPivotField.cs deleted file mode 100644 index 3e9114a8d..000000000 --- a/ClosedXML/Excel/PivotTables/XLPivotField.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; - -namespace ClosedXML.Excel -{ - [DebuggerDisplay("{SourceName}")] - internal class XLPivotField : IXLPivotField - { - private readonly IXLPivotTable _pivotTable; - public XLPivotField(IXLPivotTable pivotTable, string sourceName) - { - this._pivotTable = pivotTable; - SourceName = sourceName; - Subtotals = new List(); - SelectedValues = new List(); - SortType = XLPivotSortType.Default; - SetExcelDefaults(); - - StyleFormats = new XLPivotFieldStyleFormats(this); - } - - public String SourceName { get; private set; } - public String CustomName { get; set; } - - public IXLPivotField SetCustomName(String value) { CustomName = value; return this; } - - public String SubtotalCaption { get; set; } - - public IXLPivotField SetSubtotalCaption(String value) { SubtotalCaption = value; return this; } - - public List Subtotals { get; private set; } - - public IXLPivotField AddSubtotal(XLSubtotalFunction value) { Subtotals.Add(value); return this; } - - public Boolean IncludeNewItemsInFilter { get; set; } - - public IXLPivotField SetIncludeNewItemsInFilter() { IncludeNewItemsInFilter = true; return this; } - - public IXLPivotField SetIncludeNewItemsInFilter(Boolean value) { IncludeNewItemsInFilter = value; return this; } - - public bool Outline { get; set; } - public bool Compact { get; set; } - - public IXLPivotField SetLayout(XLPivotLayout value) - { - Compact = false; - Outline = false; - switch (value) - { - case XLPivotLayout.Compact: Compact = true; break; - case XLPivotLayout.Outline: Outline = true; break; - } - return this; - } - - public Boolean? SubtotalsAtTop { get; set; } - - public IXLPivotField SetSubtotalsAtTop() { SubtotalsAtTop = true; return this; } - - public IXLPivotField SetSubtotalsAtTop(Boolean value) { SubtotalsAtTop = value; return this; } - - public Boolean RepeatItemLabels { get; set; } - - public IXLPivotField SetRepeatItemLabels() { RepeatItemLabels = true; return this; } - - public IXLPivotField SetRepeatItemLabels(Boolean value) { RepeatItemLabels = value; return this; } - - public Boolean InsertBlankLines { get; set; } - - public IXLPivotField SetInsertBlankLines() { InsertBlankLines = true; return this; } - - public IXLPivotField SetInsertBlankLines(Boolean value) { InsertBlankLines = value; return this; } - - public Boolean ShowBlankItems { get; set; } - - public IXLPivotField SetShowBlankItems() { ShowBlankItems = true; return this; } - - public IXLPivotField SetShowBlankItems(Boolean value) { ShowBlankItems = value; return this; } - - public Boolean InsertPageBreaks { get; set; } - - public IXLPivotField SetInsertPageBreaks() { InsertPageBreaks = true; return this; } - - public IXLPivotField SetInsertPageBreaks(Boolean value) { InsertPageBreaks = value; return this; } - - public Boolean Collapsed { get; set; } - - public IXLPivotField SetCollapsed() { Collapsed = true; return this; } - - public IXLPivotField SetCollapsed(Boolean value) { Collapsed = value; return this; } - - public XLPivotSortType SortType { get; set; } - - public IXLPivotField SetSort(XLPivotSortType value) { SortType = value; return this; } - - public IList SelectedValues { get; private set; } - - public IXLPivotField AddSelectedValue(Object value) - { - SelectedValues.Add(value); - return this; - } - - public IXLPivotField AddSelectedValues(IEnumerable values) - { - ((List)SelectedValues).AddRange(values); - return this; - } - - private void SetExcelDefaults() - { - IncludeNewItemsInFilter = false; - Outline = true; - Compact = true; - InsertBlankLines = false; - ShowBlankItems = false; - InsertPageBreaks = false; - RepeatItemLabels = false; - SubtotalsAtTop = true; - Collapsed = false; - } - - public IXLPivotFieldStyleFormats StyleFormats { get; set; } - - public Boolean IsOnRowAxis => _pivotTable.RowLabels.Contains(this.SourceName); - - public Boolean IsOnColumnAxis => _pivotTable.ColumnLabels.Contains(this.SourceName); - - public Boolean IsInFilterList => _pivotTable.ReportFilters.Contains(this.SourceName); - - public Int32 Offset => _pivotTable.SourceRangeFieldsAvailable.ToList().IndexOf(this.SourceName); - } -} diff --git a/ClosedXML/Excel/PivotTables/XLPivotFields.cs b/ClosedXML/Excel/PivotTables/XLPivotFields.cs deleted file mode 100644 index bc3b07b48..000000000 --- a/ClosedXML/Excel/PivotTables/XLPivotFields.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Keep this file CodeMaid organised and cleaned -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ClosedXML.Excel -{ - internal class XLPivotFields : IXLPivotFields - { - private readonly Dictionary _pivotFields = new Dictionary(StringComparer.OrdinalIgnoreCase); - private readonly IXLPivotTable _pivotTable; - - internal XLPivotFields(IXLPivotTable pivotTable) - { - this._pivotTable = pivotTable; - } - - #region IXLPivotFields members - - public IXLPivotField Add(String sourceName) - { - return Add(sourceName, sourceName); - } - - public IXLPivotField Add(String sourceName, String customName) - { - if (sourceName != XLConstants.PivotTable.ValuesSentinalLabel && !this._pivotTable.SourceRangeFieldsAvailable.Contains(sourceName)) - throw new ArgumentOutOfRangeException(nameof(sourceName), String.Format("The column '{0}' does not appear in the source range.", sourceName)); - - var pivotField = new XLPivotField(_pivotTable, sourceName) { CustomName = customName }; - _pivotFields.Add(sourceName, pivotField); - return pivotField; - } - - public void Clear() - { - _pivotFields.Clear(); - } - - public Boolean Contains(String sourceName) - { - return _pivotFields.ContainsKey(sourceName); - } - - public bool Contains(IXLPivotField pivotField) - { - return _pivotFields.ContainsKey(pivotField.SourceName); - } - - public IXLPivotField Get(String sourceName) - { - return _pivotFields[sourceName]; - } - - public IXLPivotField Get(Int32 index) - { - return _pivotFields.Values.ElementAt(index); - } - - public IEnumerator GetEnumerator() - { - return _pivotFields.Values.GetEnumerator(); - } - - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - public Int32 IndexOf(String sourceName) - { - var selectedItem = _pivotFields.Select((item, index) => new { Item = item, Position = index }).FirstOrDefault(i => i.Item.Key == sourceName); - if (selectedItem == null) - throw new ArgumentNullException(nameof(sourceName), "Invalid field name."); - - return selectedItem.Position; - } - - public Int32 IndexOf(IXLPivotField pf) - { - return IndexOf(pf.SourceName); - } - - public void Remove(String sourceName) - { - _pivotFields.Remove(sourceName); - } - - #endregion IXLPivotFields members - } -} diff --git a/ClosedXML/Excel/PivotTables/XLPivotFormat.cs b/ClosedXML/Excel/PivotTables/XLPivotFormat.cs new file mode 100644 index 000000000..d36a42f40 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotFormat.cs @@ -0,0 +1,29 @@ +namespace ClosedXML.Excel; + +/// +/// A description of formatting that should be applied to a . +/// +internal class XLPivotFormat +{ + internal XLPivotFormat(XLPivotArea pivotArea) + { + PivotArea = pivotArea; + } + + /// + /// Pivot area that should be formatted. + /// + internal XLPivotArea PivotArea { get; } + + /// + /// Should the formatting (determined by ) be applied or not? + /// + internal XLPivotFormatAction Action { get; init; } = XLPivotFormatAction.Formatting; + + /// + /// Differential formatting to apply to the . It can be empty, e.g. if + /// is blank. Empty dxf is represented by , + /// until we get better dxf representation. + /// + internal XLStyleValue DxfStyleValue { get; set; } = XLStyle.Default.Value; +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotFormatAction.cs b/ClosedXML/Excel/PivotTables/XLPivotFormatAction.cs new file mode 100644 index 000000000..1bbd59a97 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotFormatAction.cs @@ -0,0 +1,29 @@ +namespace ClosedXML.Excel; + +/// +/// An enum describing if applies formatting to the cells of pivot +/// table or not. +/// +/// +/// +/// [ISO-29500] 18.18.34 ST_FormatAction +/// +/// +/// [MS-OI29500] 2.1.761 Excel does not support the Drill and Formula values for the +/// action attribute. Therefore, neither do we, although Drill and Formula values +/// are present in the ISO ST_FormatAction enum. +/// +/// +internal enum XLPivotFormatAction +{ + /// + /// No format is applied to the pivot table. This is used when formatting is cleared from + /// already formatted cells of pivot table. + /// + Blank, + + /// + /// Pivot table has formatting. This is the default value. + /// + Formatting, +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotItemType.cs b/ClosedXML/Excel/PivotTables/XLPivotItemType.cs new file mode 100644 index 000000000..f7faffa04 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotItemType.cs @@ -0,0 +1,86 @@ +namespace ClosedXML.Excel; + +/// +/// A categorization of or . +/// +/// +/// 18.18.43 ST_ItemType (PivotItem Type). +/// > +internal enum XLPivotItemType +{ + /// + /// The pivot item represents an "average" aggregate function. + /// + Avg, + + /// + /// The pivot item represents a blank line. + /// + Blank, + + /// + /// The pivot item represents custom the "count numbers" aggregate. + /// + Count, + + /// + /// The pivot item represents the "count" aggregate function (i.e. number, text and everything + /// else, except blanks). + /// + CountA, + + /// + /// The pivot item represents data. + /// + Data, + + /// + /// The pivot item represents the default type for this pivot table, i.e. the "total" aggregate function. + /// + Default, + + /// + /// The pivot items represents the grand total line. + /// + Grand, + + /// + /// The pivot item represents the "maximum" aggregate function. + /// + Max, + + /// + /// The pivot item represents the "minimum" aggregate function. + /// + Min, + + /// + /// The pivot item represents the "product" function. + /// + Product, + + /// + /// The pivot item represents the "standard deviation" aggregate function. + /// + StdDev, + + /// + /// The pivot item represents the "standard deviation population" aggregate function. + /// + StdDevP, + + /// + /// The pivot item represents the "sum" aggregate value. + /// + Sum, + + /// + /// The pivot item represents the "variance" aggregate value. + /// + Var, + + /// + /// The pivot item represents the "variance population" aggregate value. + /// + VarP +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotPageField.cs b/ClosedXML/Excel/PivotTables/XLPivotPageField.cs new file mode 100644 index 000000000..9c0ad8210 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotPageField.cs @@ -0,0 +1,41 @@ +using System; + +namespace ClosedXML.Excel; + +/// +/// A field displayed in the filters part of a pivot table. +/// +internal class XLPivotPageField +{ + internal XLPivotPageField(int field) + { + if (field < 0) + throw new ArgumentOutOfRangeException(); + + Field = field; + } + + /// + /// Field index to . Can't contain + /// 'data' + /// field -2. + /// + internal int Field { get; } + + /// + /// If a single item is selected, item index. Null, if nothing selected or multiple selected. + /// Multiple selected values are indicated directly in + /// through flags. Items that are not selected are hidden, + /// rest isn't. + /// + internal int? ItemIndex { get; set; } + + // OLAP + internal int? HierarchyIndex { get; init; } + + // OLAP + internal string? HierarchyUniqueName { get; init; } + + // OLAP + internal string? HierarchyDisplayName { get; init; } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotReference.cs b/ClosedXML/Excel/PivotTables/XLPivotReference.cs new file mode 100644 index 000000000..6b3708553 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotReference.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; + +namespace ClosedXML.Excel; + +/// +/// Represents a set of selected fields and selected items within those fields. It's used to select +/// an area for . +/// +internal class XLPivotReference +{ + private readonly List _fieldItems = new(); + + /// + /// + /// If is false, then it is index into pivot fields + /// items of pivot field (unless is true). + /// + /// + /// If is true, then it is index into shared items + /// of a cached field with index (unless is + /// true). + /// + /// + internal List FieldItems => _fieldItems; + + /// + /// Specifies the index of the field to which this filter refers. A value of -2/4294967294 + /// indicates the 'data' field. It can represent pivot field or cache field, depending on + /// . + /// + internal uint? Field { get; init; } + + /// + /// Flag indicating whether this field has selection. This attribute is used when the + /// pivot table is in outline view. It is also used when both header and data + /// cells have selection. + /// + internal bool Selected { get; init; } = true; + + /// + /// Flag indicating whether the item in is referred to by position rather + /// than item index. + /// + internal bool ByPosition { get; init; } = false; + + /// + /// Flag indicating whether the item is referred to by a relative reference rather than an + /// absolute reference. This attribute is used if posRef is set to true. + /// + internal bool Relative { get; init; } = false; + + internal HashSet Subtotals { get; init; }= new(); + + internal void AddFieldItem(uint fieldItem) + { + // TODO: Check value by area.CacheIndex and ByPosition + _fieldItems.Add(fieldItem); + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotSourceConnection.cs b/ClosedXML/Excel/PivotTables/XLPivotSourceConnection.cs new file mode 100644 index 000000000..6ddc767a0 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotSourceConnection.cs @@ -0,0 +1,44 @@ +using System; + +namespace ClosedXML.Excel; + +/// +/// Source of data for a that takes data from a connection +/// to external source of data (e.g. database or a workbook). +/// +internal sealed class XLPivotSourceConnection : IXLPivotSource +{ + public XLPivotSourceConnection(uint connectionId) + { + ConnectionId = connectionId; + } + + public uint ConnectionId { get; } + + public bool Equals(IXLPivotSource otherSource) + { + var other = otherSource as XLPivotSourceConnection; + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + return ConnectionId == other.ConnectionId; + } + + public override bool Equals(object? obj) + { + return obj is IXLPivotSource other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(ConnectionId).GetHashCode(); + } + + public bool TryGetSource(XLWorkbook workbook, out XLWorksheet? sheet, out XLSheetRange? sheetArea) + { + throw new NotImplementedException("Pivot cache source using a connection is not supported."); + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotSourceConsolidation.cs b/ClosedXML/Excel/PivotTables/XLPivotSourceConsolidation.cs new file mode 100644 index 000000000..178e7d164 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotSourceConsolidation.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel; + +/// +/// Source of data for a that takes uses a union of multiple scenarios in the workbook to +/// create data. +/// +internal sealed class XLPivotSourceConsolidation : IXLPivotSource +{ + /// + /// Will application automatically create additional page filter in addition to the . + /// + internal bool AutoPage { get; init; } + + /// + /// + /// Custom page filters that toggle whether to display data from a particular + /// range set. + /// There can be 0..4 page filters. Each can have a different combination + /// of range sets. + /// + /// + /// Example: + /// + /// The range sets are months and one page is Q1,Q2,Q3,Q4 + /// and second page filter is Last month of quarter and Other months. These + /// page items are referenced by . + /// + /// + /// + public IReadOnlyList Pages { get; init; } = Array.Empty(); + + /// + /// Range sets that consists the cache source. + /// + public IReadOnlyList RangeSets { get; init; } = Array.Empty(); + + public bool Equals(IXLPivotSource otherSource) + { + var other = otherSource as XLPivotSourceConsolidation; + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + // This source should likely never be unified, so when there are two instances, mark them as different. + return false; + } + + public bool TryGetSource(XLWorkbook workbook, out XLWorksheet? sheet, out XLSheetRange? sheetArea) + { + throw new NotImplementedException("Consolidation pivot cache data source is not supported."); + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotSourceExternalWorkbook.cs b/ClosedXML/Excel/PivotTables/XLPivotSourceExternalWorkbook.cs new file mode 100644 index 000000000..ac817fbe9 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotSourceExternalWorkbook.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace ClosedXML.Excel; + +/// +/// Source of data for a that takes data from external workbook. +/// +internal sealed class XLPivotSourceExternalWorkbook : IXLPivotSource +{ + /// + /// External workbook relId. If relationships of cache definition changes, make sure to either keep same or update it. + /// + internal string RelId { get; } + + /// + /// Are source data in external workbook defined by a or by cell area. + /// + [MemberNotNullWhen(true, nameof(TableOrName))] + [MemberNotNullWhen(false, nameof(Area))] + internal bool UsesName => TableOrName is not null; + + /// + /// A table or defined name in an external workbook that contains source data. + /// + internal string? TableOrName { get; } + + /// + /// An area in an external workbook that contains source data. + /// + internal XLBookArea? Area { get; } + + internal XLPivotSourceExternalWorkbook(string relId, XLBookArea area) + { + RelId = relId; + Area = area; + } + + internal XLPivotSourceExternalWorkbook(string relId, string tableOrName) + { + RelId = relId; + TableOrName = tableOrName; + } + + public bool TryGetSource(XLWorkbook workbook, out XLWorksheet? sheet, out XLSheetRange? sheetArea) + { + throw new NotImplementedException("External workbook source is not supported."); + } + + public bool Equals(IXLPivotSource otherSource) + { + var other = otherSource as XLPivotSourceExternalWorkbook; + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + // Two same RelIds could in theory point to different workbooks. I am not supporting + // external sources for now anyway, so no unification through equality. + return false; + } + + public override bool Equals(object? other) + { + return other is IXLPivotSource source && Equals(source); + } + + public override int GetHashCode() + { + return HashCode.Combine(RelId, Area).GetHashCode(); + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotSourceReference.cs b/ClosedXML/Excel/PivotTables/XLPivotSourceReference.cs new file mode 100644 index 000000000..bd4abf617 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotSourceReference.cs @@ -0,0 +1,100 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace ClosedXML.Excel +{ + /// + /// A reference to the source data of . The source might exist + /// or not, that is evaluated during pivot cache record refresh. + /// + internal sealed class XLPivotSourceReference : IXLPivotSource + { + internal XLPivotSourceReference(XLBookArea area) + { + Area = area; + Name = null; + } + + internal XLPivotSourceReference(string namedRangeOrTable) + { + Area = null; + Name = namedRangeOrTable; + } + + /// + /// Are source data in external workbook defined by a or by cell area. + /// + [MemberNotNullWhen(true, nameof(Name))] + [MemberNotNullWhen(false, nameof(Area))] + internal bool UsesName => Name is not null; + + /// + /// Book area with the source data. Either this or is set. + /// + internal XLBookArea? Area { get; } + + /// + /// Name of a table or a book-scoped named range that contain the source data. + /// Either this or is set. + /// + internal string? Name { get; } + + public bool Equals(IXLPivotSource otherSource) + { + var other = otherSource as XLPivotSourceReference; + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + return Nullable.Equals(Area, other.Area) && XLHelper.NameComparer.Equals(Name, other.Name); + } + + public override bool Equals(object? obj) + { + return obj is IXLPivotSource other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + return (Area.GetHashCode() * 397) ^ (Name is not null ? XLHelper.NameComparer.GetHashCode(Name) : 0); + } + } + + /// + /// Try to determine actual area of the source reference in the + /// workbook. Source reference might not be valid in the workbook. + /// + public bool TryGetSource(XLWorkbook workbook, out XLWorksheet? sheet, out XLSheetRange? sheetArea) + { + if (Name is not null) + { + // TODO: Named ranges are currently unusable, so only check tables. + if (workbook.TryGetTable(Name, out var table)) + { + sheet = table.Worksheet; + sheetArea = table.Area; + return true; + } + + sheet = null; + sheetArea = null; + return false; + } + + Debug.Assert(Area is not null); + if (workbook.WorksheetsInternal.TryGetWorksheet(Area.Value.Name, out sheet)) + { + sheetArea = Area.Value.Area; + return true; + } + + sheetArea = default; + return false; + } + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotSourceScenario.cs b/ClosedXML/Excel/PivotTables/XLPivotSourceScenario.cs new file mode 100644 index 000000000..ae4ab2c87 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotSourceScenario.cs @@ -0,0 +1,30 @@ +using System; + +namespace ClosedXML.Excel; + +/// +/// Source of data for a that takes uses scenarios in the workbook to +/// create data. +/// +internal sealed class XLPivotSourceScenario : IXLPivotSource +{ + public bool Equals(IXLPivotSource other) + { + return other is XLPivotSourceScenario; + } + + public override bool Equals(object? obj) + { + return obj is IXLPivotSource other && Equals(other); + } + + public override int GetHashCode() + { + return 0; + } + + public bool TryGetSource(XLWorkbook workbook, out XLWorksheet? sheet, out XLSheetRange? sheetArea) + { + throw new NotImplementedException("Scenario pivot cache data source is not supported."); + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotTable.cs b/ClosedXML/Excel/PivotTables/XLPivotTable.cs index beef7c88f..eb8936f3f 100644 --- a/ClosedXML/Excel/PivotTables/XLPivotTable.cs +++ b/ClosedXML/Excel/PivotTables/XLPivotTable.cs @@ -1,3 +1,5 @@ +#nullable disable + using ClosedXML.Excel.CalcEngine; using System; using System.Collections.Generic; @@ -9,60 +11,76 @@ namespace ClosedXML.Excel [DebuggerDisplay("{Name}")] internal class XLPivotTable : IXLPivotTable { + private readonly XLWorksheet _worksheet; private String _name; - public Guid Guid { get; private set; } - - public XLPivotTable(IXLWorksheet worksheet) - { - this.Worksheet = worksheet ?? throw new ArgumentNullException(nameof(worksheet)); - this.Guid = Guid.NewGuid(); - Fields = new XLPivotFields(this); - ReportFilters = new XLPivotFields(this); - ColumnLabels = new XLPivotFields(this); - RowLabels = new XLPivotFields(this); - Values = new XLPivotValues(this); + /// + /// List of all fields in the pivot table, roughly represents pivotTableDefinition. + /// pivotFields. Contains info about each field, mostly page/axis info (data field can + /// reference same field multiple times, so it mostly stores data in data fields). + /// + private readonly List _fields = new(); + private readonly List _formats = new(); + private readonly List _conditionalFormats = new(); + private XLPivotCache _cache; + private int _filterFieldsPageWrap; + private bool _outline = true; + private bool _outlineData = false; + private bool _compact = true; + private bool _compactData = true; + + internal XLPivotTable(XLWorksheet worksheet, XLPivotCache cache) + { + _worksheet = worksheet; + + Filters = new XLPivotTableFilters(this); + RowAxis = new XLPivotTableAxis(this, XLPivotAxis.AxisRow); + ColumnAxis = new XLPivotTableAxis(this, XLPivotAxis.AxisCol); + DataFields = new XLPivotDataFields(this); Theme = XLPivotTableTheme.PivotStyleLight16; + _cache = cache; SetExcelDefaults(); } - public IXLCell TargetCell { get; set; } + IXLPivotCache IXLPivotTable.PivotCache { get => PivotCache; set => PivotCache = (XLPivotCache)value; } - private IXLRange sourceRange; - - public IXLRange SourceRange + public IXLCell TargetCell { - get { return sourceRange; } + get + { + var filterRows = Filters.GetSizeWithGap().Height; + var tableCorner = Area.FirstPoint; + var targetPoint = tableCorner.ShiftRow(-filterRows); + return _worksheet.Internals.CellsCollection.GetCell(targetPoint); + } set { - if (value is IXLTable) - SourceType = XLPivotTableSourceType.Table; - else - SourceType = XLPivotTableSourceType.Range; - - sourceRange = value; + var filterRows = Filters.GetSizeWithGap().Height; + var valuePoint = ((XLCell)value).SheetPoint; + var tableCorner = valuePoint.ShiftRow(filterRows); + Area = Area.At(tableCorner); } } - public IXLTable SourceTable + public XLPivotCache PivotCache { - get { return SourceRange as IXLTable; } - set { SourceRange = value; } + get => _cache; + set + { + var oldNames = _cache.FieldNames; + _cache = value; + UpdateCacheFields(oldNames); + } } - public XLPivotTableSourceType SourceType { get; private set; } + public IXLPivotFields ReportFilters => Filters; - public IEnumerable SourceRangeFieldsAvailable - { - get { return this.SourceRange.FirstRow().Cells().Select(c => c.GetString()); } - } + public IXLPivotFields ColumnLabels => ColumnAxis; + + public IXLPivotFields RowLabels => RowAxis; - public IXLPivotFields Fields { get; private set; } - public IXLPivotFields ReportFilters { get; private set; } - public IXLPivotFields ColumnLabels { get; private set; } - public IXLPivotFields RowLabels { get; private set; } - public IXLPivotValues Values { get; private set; } + public IXLPivotValues Values => DataFields; public IEnumerable ImplementedFields { @@ -79,8 +97,29 @@ public IEnumerable ImplementedFields } } + /// + /// Table theme this pivot table will use. + /// public XLPivotTableTheme Theme { get; set; } + /// + /// All fields reflected in the pivot cache. + /// Order of fields is same as for in the . + /// + internal IReadOnlyList PivotFields => _fields; + + internal XLPivotTableFilters Filters { get; } + + internal XLPivotTableAxis RowAxis { get; } + + internal XLPivotTableAxis ColumnAxis { get; } + + internal XLPivotDataFields DataFields { get; } + + internal IReadOnlyList Formats => _formats; + + internal IReadOnlyList ConditionalFormats => _conditionalFormats; + public IXLPivotTable CopyTo(IXLCell targetCell) { var addressComparer = new XLAddressComparer(ignoreFixed: true); @@ -93,22 +132,17 @@ public IXLPivotTable CopyTo(IXLCell targetCell) int i = 0; var pivotTableNames = targetSheet.PivotTables.Select(pvt => pvt.Name).ToList(); - while (!XLHelper.ValidateName("pivot table", pivotTableName, "", pivotTableNames, out var _)) + while (!XLHelper.ValidateName("pivot table", pivotTableName, "", pivotTableNames, out _)) { i++; - pivotTableName = this.Name + i.ToInvariantString(); + pivotTableName = Name + i.ToInvariantString(); } - var newPivotTable = this.SourceType switch - { - XLPivotTableSourceType.Table => targetSheet.PivotTables.Add(pivotTableName, targetCell, this.SourceTable) as XLPivotTable, - XLPivotTableSourceType.Range => targetSheet.PivotTables.Add(pivotTableName, targetCell, this.SourceRange) as XLPivotTable, - _ => throw new NotImplementedException(), - }; + var newPivotTable = (XLPivotTable)targetSheet.PivotTables.Add(pivotTableName, targetCell, PivotCache); newPivotTable.RelId = null; - static void copyPivotField(IXLPivotField originalPivotField, IXLPivotField newPivotField) + static void CopyPivotField(IXLPivotField originalPivotField, IXLPivotField newPivotField) { newPivotField .SetSort(originalPivotField.SortType) @@ -127,13 +161,13 @@ static void copyPivotField(IXLPivotField originalPivotField, IXLPivotField newPi } foreach (var rf in ReportFilters) - copyPivotField(rf, newPivotTable.ReportFilters.Add(rf.SourceName, rf.CustomName)); + CopyPivotField(rf, newPivotTable.ReportFilters.Add(rf.SourceName, rf.CustomName)); foreach (var cl in ColumnLabels) - copyPivotField(cl, newPivotTable.ColumnLabels.Add(cl.SourceName, cl.CustomName)); + CopyPivotField(cl, newPivotTable.ColumnLabels.Add(cl.SourceName, cl.CustomName)); foreach (var rl in RowLabels) - copyPivotField(rl, newPivotTable.RowLabels.Add(rl.SourceName, rl.CustomName)); + CopyPivotField(rl, newPivotTable.RowLabels.Add(rl.SourceName, rl.CustomName)); foreach (var v in Values) { @@ -141,8 +175,8 @@ static void copyPivotField(IXLPivotField originalPivotField, IXLPivotField newPi .SetSummaryFormula(v.SummaryFormula) .SetCalculation(v.Calculation) .SetCalculationItem(v.CalculationItem) - .SetBaseField(v.BaseField) - .SetBaseItem(v.BaseItem); + .SetBaseFieldName(v.BaseFieldName) + .SetBaseItemValue(v.BaseItemValue); pivotValue.NumberFormat.NumberFormatId = v.NumberFormat.NumberFormatId; pivotValue.NumberFormat.Format = v.NumberFormat.Format; @@ -157,7 +191,8 @@ static void copyPivotField(IXLPivotField originalPivotField, IXLPivotField newPi newPivotTable.FilterAreaOrder = FilterAreaOrder; newPivotTable.FilterFieldsPageWrap = FilterFieldsPageWrap; newPivotTable.ErrorValueReplacement = ErrorValueReplacement; - newPivotTable.EmptyCellReplacement = EmptyCellReplacement; + newPivotTable.ShowMissing = ShowMissing; + newPivotTable.MissingCaption = MissingCaption; newPivotTable.AutofitColumns = AutofitColumns; newPivotTable.PreserveCellFormatting = PreserveCellFormatting; newPivotTable.ShowGrandTotalsColumns = ShowGrandTotalsColumns; @@ -178,10 +213,7 @@ static void copyPivotField(IXLPivotField originalPivotField, IXLPivotField newPi newPivotTable.PrintExpandCollapsedButtons = PrintExpandCollapsedButtons; newPivotTable.RepeatRowLabels = RepeatRowLabels; newPivotTable.PrintTitles = PrintTitles; - newPivotTable.SaveSourceData = SaveSourceData; newPivotTable.EnableShowDetails = EnableShowDetails; - newPivotTable.RefreshDataOnOpen = RefreshDataOnOpen; - newPivotTable.ItemsToRetainPerField = ItemsToRetainPerField; newPivotTable.EnableCellEditing = EnableCellEditing; newPivotTable.ShowRowHeaders = ShowRowHeaders; newPivotTable.ShowColumnHeaders = ShowColumnHeaders; @@ -193,11 +225,6 @@ static void copyPivotField(IXLPivotField originalPivotField, IXLPivotField newPi return newPivotTable; } - public IXLPivotTable SetTheme(XLPivotTableTheme value) - { - Theme = value; return this; - } - public String Name { get { return _name; } @@ -239,24 +266,18 @@ public IXLPivotTable SetDescription(String value) Description = value; return this; } - public String ColumnHeaderCaption { get; set; } - public IXLPivotTable SetColumnHeaderCaption(String value) { ColumnHeaderCaption = value; return this; } - public String RowHeaderCaption { get; set; } - public IXLPivotTable SetRowHeaderCaption(String value) { RowHeaderCaption = value; return this; } - public Boolean MergeAndCenterWithLabels { get; set; } - public IXLPivotTable SetMergeAndCenterWithLabels() { MergeAndCenterWithLabels = true; return this; @@ -267,43 +288,55 @@ public IXLPivotTable SetMergeAndCenterWithLabels(Boolean value) MergeAndCenterWithLabels = value; return this; } - public Int32 RowLabelIndent { get; set; } - public IXLPivotTable SetRowLabelIndent(Int32 value) { RowLabelIndent = value; return this; } - public XLFilterAreaOrder FilterAreaOrder { get; set; } - public IXLPivotTable SetFilterAreaOrder(XLFilterAreaOrder value) { FilterAreaOrder = value; return this; } - public Int32 FilterFieldsPageWrap { get; set; } - public IXLPivotTable SetFilterFieldsPageWrap(Int32 value) { FilterFieldsPageWrap = value; return this; } - public String ErrorValueReplacement { get; set; } - public IXLPivotTable SetErrorValueReplacement(String value) { ErrorValueReplacement = value; return this; } - public String EmptyCellReplacement { get; set; } + public String EmptyCellReplacement + { + get + { + if (ShowMissing) + return MissingCaption; + + return string.Empty; + } + set + { + if (string.IsNullOrEmpty(value)) + { + ShowMissing = false; + MissingCaption = string.Empty; + } + else + { + ShowMissing = true; + MissingCaption = value; + } + } + } public IXLPivotTable SetEmptyCellReplacement(String value) { EmptyCellReplacement = value; return this; } - public Boolean AutofitColumns { get; set; } - public IXLPivotTable SetAutofitColumns() { AutofitColumns = true; return this; @@ -314,8 +347,6 @@ public IXLPivotTable SetAutofitColumns(Boolean value) AutofitColumns = value; return this; } - public Boolean PreserveCellFormatting { get; set; } - public IXLPivotTable SetPreserveCellFormatting() { PreserveCellFormatting = true; return this; @@ -326,8 +357,6 @@ public IXLPivotTable SetPreserveCellFormatting(Boolean value) PreserveCellFormatting = value; return this; } - public Boolean ShowGrandTotalsRows { get; set; } - public IXLPivotTable SetShowGrandTotalsRows() { ShowGrandTotalsRows = true; return this; @@ -338,8 +367,6 @@ public IXLPivotTable SetShowGrandTotalsRows(Boolean value) ShowGrandTotalsRows = value; return this; } - public Boolean ShowGrandTotalsColumns { get; set; } - public IXLPivotTable SetShowGrandTotalsColumns() { ShowGrandTotalsColumns = true; return this; @@ -350,8 +377,6 @@ public IXLPivotTable SetShowGrandTotalsColumns(Boolean value) ShowGrandTotalsColumns = value; return this; } - public Boolean FilteredItemsInSubtotals { get; set; } - public IXLPivotTable SetFilteredItemsInSubtotals() { FilteredItemsInSubtotals = true; return this; @@ -362,8 +387,6 @@ public IXLPivotTable SetFilteredItemsInSubtotals(Boolean value) FilteredItemsInSubtotals = value; return this; } - public Boolean AllowMultipleFilters { get; set; } - public IXLPivotTable SetAllowMultipleFilters() { AllowMultipleFilters = true; return this; @@ -374,8 +397,6 @@ public IXLPivotTable SetAllowMultipleFilters(Boolean value) AllowMultipleFilters = value; return this; } - public Boolean UseCustomListsForSorting { get; set; } - public IXLPivotTable SetUseCustomListsForSorting() { UseCustomListsForSorting = true; return this; @@ -386,8 +407,6 @@ public IXLPivotTable SetUseCustomListsForSorting(Boolean value) UseCustomListsForSorting = value; return this; } - public Boolean ShowExpandCollapseButtons { get; set; } - public IXLPivotTable SetShowExpandCollapseButtons() { ShowExpandCollapseButtons = true; return this; @@ -398,8 +417,6 @@ public IXLPivotTable SetShowExpandCollapseButtons(Boolean value) ShowExpandCollapseButtons = value; return this; } - public Boolean ShowContextualTooltips { get; set; } - public IXLPivotTable SetShowContextualTooltips() { ShowContextualTooltips = true; return this; @@ -410,8 +427,6 @@ public IXLPivotTable SetShowContextualTooltips(Boolean value) ShowContextualTooltips = value; return this; } - public Boolean ShowPropertiesInTooltips { get; set; } - public IXLPivotTable SetShowPropertiesInTooltips() { ShowPropertiesInTooltips = true; return this; @@ -422,8 +437,6 @@ public IXLPivotTable SetShowPropertiesInTooltips(Boolean value) ShowPropertiesInTooltips = value; return this; } - public Boolean DisplayCaptionsAndDropdowns { get; set; } - public IXLPivotTable SetDisplayCaptionsAndDropdowns() { DisplayCaptionsAndDropdowns = true; return this; @@ -434,8 +447,6 @@ public IXLPivotTable SetDisplayCaptionsAndDropdowns(Boolean value) DisplayCaptionsAndDropdowns = value; return this; } - public Boolean ClassicPivotTableLayout { get; set; } - public IXLPivotTable SetClassicPivotTableLayout() { ClassicPivotTableLayout = true; return this; @@ -458,8 +469,6 @@ public IXLPivotTable SetShowValuesRow(Boolean value) ShowValuesRow = value; return this; } - public Boolean ShowEmptyItemsOnRows { get; set; } - public IXLPivotTable SetShowEmptyItemsOnRows() { ShowEmptyItemsOnRows = true; return this; @@ -470,8 +479,6 @@ public IXLPivotTable SetShowEmptyItemsOnRows(Boolean value) ShowEmptyItemsOnRows = value; return this; } - public Boolean ShowEmptyItemsOnColumns { get; set; } - public IXLPivotTable SetShowEmptyItemsOnColumns() { ShowEmptyItemsOnColumns = true; return this; @@ -482,8 +489,6 @@ public IXLPivotTable SetShowEmptyItemsOnColumns(Boolean value) ShowEmptyItemsOnColumns = value; return this; } - public Boolean DisplayItemLabels { get; set; } - public IXLPivotTable SetDisplayItemLabels() { DisplayItemLabels = true; return this; @@ -494,8 +499,6 @@ public IXLPivotTable SetDisplayItemLabels(Boolean value) DisplayItemLabels = value; return this; } - public Boolean SortFieldsAtoZ { get; set; } - public IXLPivotTable SetSortFieldsAtoZ() { SortFieldsAtoZ = true; return this; @@ -506,8 +509,6 @@ public IXLPivotTable SetSortFieldsAtoZ(Boolean value) SortFieldsAtoZ = value; return this; } - public Boolean PrintExpandCollapsedButtons { get; set; } - public IXLPivotTable SetPrintExpandCollapsedButtons() { PrintExpandCollapsedButtons = true; return this; @@ -518,8 +519,6 @@ public IXLPivotTable SetPrintExpandCollapsedButtons(Boolean value) PrintExpandCollapsedButtons = value; return this; } - public Boolean RepeatRowLabels { get; set; } - public IXLPivotTable SetRepeatRowLabels() { RepeatRowLabels = true; return this; @@ -530,8 +529,6 @@ public IXLPivotTable SetRepeatRowLabels(Boolean value) RepeatRowLabels = value; return this; } - public Boolean PrintTitles { get; set; } - public IXLPivotTable SetPrintTitles() { PrintTitles = true; return this; @@ -542,20 +539,6 @@ public IXLPivotTable SetPrintTitles(Boolean value) PrintTitles = value; return this; } - public Boolean SaveSourceData { get; set; } - - public IXLPivotTable SetSaveSourceData() - { - SaveSourceData = true; return this; - } - - public IXLPivotTable SetSaveSourceData(Boolean value) - { - SaveSourceData = value; return this; - } - - public Boolean EnableShowDetails { get; set; } - public IXLPivotTable SetEnableShowDetails() { EnableShowDetails = true; return this; @@ -566,24 +549,6 @@ public IXLPivotTable SetEnableShowDetails(Boolean value) EnableShowDetails = value; return this; } - public Boolean RefreshDataOnOpen { get; set; } - - public IXLPivotTable SetRefreshDataOnOpen() - { - RefreshDataOnOpen = true; return this; - } - - public IXLPivotTable SetRefreshDataOnOpen(Boolean value) - { - RefreshDataOnOpen = value; return this; - } - - public XLItemsToRetain ItemsToRetainPerField { get; set; } - - public IXLPivotTable SetItemsToRetainPerField(XLItemsToRetain value) - { - ItemsToRetainPerField = value; return this; - } public Boolean EnableCellEditing { get; set; } @@ -645,6 +610,11 @@ public IXLPivotTable SetShowColumnStripes(Boolean value) ShowColumnStripes = value; return this; } + /// + /// Part of the pivot table style. + /// + internal Boolean ShowLastColumn { get; set; } = false; + public XLPivotSubtotals Subtotals { get; set; } public IXLPivotTable SetSubtotals(XLPivotSubtotals value) @@ -654,7 +624,30 @@ public IXLPivotTable SetSubtotals(XLPivotSubtotals value) public XLPivotLayout Layout { - set { Fields.ForEach(f => f.SetLayout(value)); } + set + { + switch (value) + { + case XLPivotLayout.Compact: + _compact = _compactData = true; + _outline = _outlineData = false; + break; + case XLPivotLayout.Outline: + _compact = _compactData = false; + _outline = _outlineData = true; + break; + case XLPivotLayout.Tabular: + _compact = _compactData = false; + _outline = _outlineData = false; + break; + default: + throw new UnreachableException(); + } + + // It is necessary to set layout for each pivot field, even ones that are not displayed on an axis. Without it, the tabular layout + // doesn't display headers for axis fields and only display one "Column labels" button instead. + PivotFields.ForEach(f => f.SetLayout(value)); + } } public IXLPivotTable SetLayout(XLPivotLayout value) @@ -664,7 +657,7 @@ public IXLPivotTable SetLayout(XLPivotLayout value) public Boolean InsertBlankLines { - set { Fields.ForEach(f => f.SetInsertBlankLines(value)); } + set { ImplementedFields.ForEach(f => f.SetInsertBlankLines(value)); } } public IXLPivotTable SetInsertBlankLines() @@ -679,12 +672,11 @@ public IXLPivotTable SetInsertBlankLines(Boolean value) internal String RelId { get; set; } internal String CacheDefinitionRelId { get; set; } - internal String WorkbookCacheRelId { get; set; } private void SetExcelDefaults() { - EmptyCellReplacement = String.Empty; - SaveSourceData = true; + ShowMissing = true; + MissingCaption = string.Empty; ShowColumnHeaders = true; ShowRowHeaders = true; @@ -714,9 +706,9 @@ private void SetExcelDefaults() UseCustomListsForSorting = true; // Custom List AutoSort } - public IXLWorksheet Worksheet { get; } + public IXLWorksheet Worksheet => _worksheet; - public IXLPivotTableStyleFormats StyleFormats { get; } = new XLPivotTableStyleFormats(); + public IXLPivotTableStyleFormats StyleFormats => new XLPivotTableStyleFormats(this); public IEnumerable AllStyleFormats { @@ -728,14 +720,764 @@ public IEnumerable AllStyleFormats foreach (var styleFormat in this.StyleFormats.ColumnGrandTotalFormats) yield return styleFormat; - foreach (var pivotField in ImplementedFields) + // TODO: Skipped for now, until I implement stubs + //foreach (var pivotField in ImplementedFields) + //{ + // yield return pivotField.StyleFormats.Subtotal; + // yield return pivotField.StyleFormats.Header; + // yield return pivotField.StyleFormats.Label; + // yield return pivotField.StyleFormats.DataValuesFormat; + //} + } + } +#nullable enable + internal void AddField(XLPivotTableField field) + { + _fields.Add(field); + } + + internal void AddFormat(XLPivotFormat pivotFormat) + { + _formats.Add(pivotFormat); + } + + internal void AddConditionalFormat(XLPivotConditionalFormat conditionalFormat) + { + _conditionalFormats.Add(conditionalFormat); + } + + #region location + + /// + /// Area of a pivot table. Area doesn't include page fields, they are above the area with + /// one empty row between area and filters. + /// + internal XLSheetRange Area { get; set; } = new(1, 1, 1, 1); + + /// + /// First row of pivot table header, relative to the . + /// + internal uint FirstHeaderRow { get; set; } + + /// + /// First row of pivot table data area, relative to the . + /// + internal uint FirstDataRow { get; set; } + + /// + /// First column of pivot table data area, relative to the . + /// + internal uint FirstDataCol { get; set; } + + #endregion + + #region Attributes of PivotTableDefinition in same order as XSD + + /// + /// Determines the whether 'data' field is on (true) or + /// (false). + /// + internal bool DataOnRows { get; set; } = false; + + /// + /// Determines the default 'data' field position, when it is automatically added to row/column fields. + /// 0 = first (e.g. before all column/row fields), 1 = second (i.e. after first row/column field) and so on. + /// > number of fields or null indicates the last position. + /// + internal int? DataPosition { get; set; } + + /// + /// + /// An identification of legacy table auto-format to apply to the pivot table. The + /// Apply*Formats properties specifies which parts of auto-format to apply. If + /// null or is not true, legacy auto-format is + /// not applied. + /// + /// + /// The value must be less than 21 or greater than 4096 and less than or equal to 4117. See + /// ISO-29500 Annex G.3 for how auto formats look like. + /// + /// + internal uint? AutoFormatId { get; init; } + + /// + /// If auto-format should be applied ( and + /// are set), apply legacy auto-format number format properties. + /// + internal bool ApplyNumberFormats { get; init; } = false; + + /// + /// If auto-format should be applied ( and + /// are set), apply legacy auto-format border properties. + /// + internal bool ApplyBorderFormats { get; init; } = false; + + /// + /// If auto-format should be applied ( and + /// are set), apply legacy auto-format font properties. + /// + internal bool ApplyFontFormats { get; init; } = false; + + /// + /// If auto-format should be applied ( and + /// are set), apply legacy auto-format pattern properties. + /// + internal bool ApplyPatternFormats { get; init; } = false; + + /// + /// If auto-format should be applied ( and + /// are set), apply legacy auto-format alignment properties. + /// + internal bool ApplyAlignmentFormats { get; init; } = false; + + /// + /// If auto-format should be applied ( and + /// are set), apply legacy auto-format width/height properties. + /// + internal bool ApplyWidthHeightFormats { get; init; } = false; + + /// + /// Initial text of 'data' field. This is doesn't do anything, Excel always displays + /// dynamically a text 'Values', translated to current culture. + /// + internal string DataCaption { get; set; } = "Values"; + + internal string? GrandTotalCaption { get; init; } + + /// + /// Text to display when in cells that contain error. + /// + public String? ErrorValueReplacement { get; set; } + + /// + /// Flag indicating if should be shown when cell contain an error. + /// + internal bool ShowError { get; init; } = false; + + /// + /// Test to display for missing items, when is true. + /// + internal string MissingCaption { get; set; } + + /// + /// Flag indicating if should be shown when cell has no value. + /// + /// Doesn't seem to work in Excel. + internal bool ShowMissing { get; set; } = true; + + /// + /// Name of style to apply to items headers in . + /// + internal string? PageStyle { get; init; } + + /// Doesn't seem to work in Excel. + internal string? PivotTableStyleName { get; init; } + + /// + /// Name of a style to apply to the cells left blank when a pivot table shrinks during a refresh operation. + /// + internal string? VacatedStyle { get; init; } + + internal string? Tag { get; init; } + + /// + /// Version of the application that last updated the pivot table. Application-dependent. + /// + internal byte UpdatedVersion { get; init; } + + /// + /// Minimum version of the application required to update the pivot table. Application-dependent. + /// + internal byte MinRefreshableVersion { get; init; } + + /// OLAP related. + internal bool AsteriskTotals { get; init; } = false; + + /// + /// + /// Should field items be displayed on the axis despite pivot table not having any value + /// field? true will display items even without data field, false won't. + /// + /// + /// Example: There is an empty pivot table with no value fields. Add field 'Name' + /// to row fields. Should names be displayed on row despite not having any value field? + /// + /// + /// Also called ShowItems + public bool DisplayItemLabels { get; set; } = true; + + /// + /// Flag indicating if user is allowed to edit cells in data area. + /// + internal bool EditData { get; init; } = false; + + /// + /// Flag indicating if UI to modify the fields of pivot table is disabled. In Excel, the + /// whole field area is hidden. + /// + internal bool DisableFieldList { get; init; } = false; + + /// OLAP only. + internal bool ShowCalculatedMembers { get; init; } = true; + + /// OLAP only. + internal bool VisualTotals { get; init; } = true; + + /// + /// A flag indicating whether a page field that has selected multiple items (but not + /// necessarily all) display "(multiple items)" instead of "All"? If value is false. + /// page fields will display "All" regardless of whether only item subset is selected or + /// all items are selected. + /// + internal bool ShowMultipleLabel { get; init; } = true; + + /// + /// Doesn't seem to do anything. Should hide drop down filters. + /// + internal bool ShowDataDropDown { get; init; } = true; + + /// + /// A flag indicating whether UI should display collapse/expand (drill) buttons in pivot + /// table axes. + /// + /// Also called ShowDrill. + public Boolean ShowExpandCollapseButtons { get; set; } = true; + + /// + /// A flag indicating whether collapse/expand (drill) buttons in pivot table axes should + /// be printed. + /// + /// Also called PrintDrill. + public Boolean PrintExpandCollapsedButtons { get; set; } = false; + + /// OLAP only. Also called ShowMemberPropertyTips. + public Boolean ShowPropertiesInTooltips { get; set; } + + /// + /// A flag indicating whether UI should display a tooltip on data items of pivot table. The + /// tooltip contain info about value field name, row/col items used to aggregate the value + /// ect. Note that this tooltip generally hides cell notes, because mouseover displays data + /// tool tip, rather than the note. + /// + /// Also called ShowDataTips. + public Boolean ShowContextualTooltips { get; set; } + + /// + /// A flag indicating whether UI should provide a mechanism to edit the pivot table. If the + /// value is false, Excel provides ability to refresh data through context menu, but + /// ribbon or other options to manipulate field or pivot table settings are not present. + /// + /// Also called enableWizard. + internal bool EnableEditingMechanism { get; set; } = true; + + /// Likely OLAP only. Do not confuse with collapse/expand buttons. + public Boolean EnableShowDetails { get; set; } = true; + + /// + /// A flag indicating whether the user is prevented from displaying PivotField properties. + /// Not very consistent in Excel, e.g. can't display field properties through context menu + /// of a pivot table, but can display properties menu through context menu in editing wizard. + /// + internal bool EnableFieldProperties { get; init; } = true; + + /// + /// A flag that indicates whether the formatting applied by the user to the pivot table + /// cells is preserved on refresh. + /// + /// Once again, ISO-29500 is buggy and says the opposite. Also called + /// PreserveFormatting + public Boolean PreserveCellFormatting { get; set; } = true; + + /// + /// A flag that indicates whether legacy auto formatting has been applied to the PivotTable + /// view. + /// + /// Also called UseAutoFormatting. + public Boolean AutofitColumns { get; set; } = false; + + /// + /// Also called PageWrap. + public Int32 FilterFieldsPageWrap + { + get => _filterFieldsPageWrap; + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(); + + _filterFieldsPageWrap = value; + } + } + + /// + /// Also called PageOverThenDown. + public XLFilterAreaOrder FilterAreaOrder { get; set; } = XLFilterAreaOrder.DownThenOver; + + /// + /// A flag that indicates whether hidden pivot items should be included in subtotal + /// calculated values. If true, data for hidden items are included in subtotals + /// calculated values. If false, hidden values are not included in subtotal + /// calculations. + /// + /// Also called SubtotalHiddenItems. OLAP only. Option in Excel is grayed + /// out and does nothing. The option is un-grayed out when pivot cache is part of data + /// model. + public bool FilteredItemsInSubtotals { get; set; } = false; + + /// + /// A flag indicating whether grand totals should be displayed for the PivotTable rows. + /// + /// Also called RowGrandTotals. + public Boolean ShowGrandTotalsRows { get; set; } = true; + + /// + /// A flag indicating whether grand totals should be displayed for the PivotTable columns. + /// + /// Also called ColumnGrandTotals. + public Boolean ShowGrandTotalsColumns { get; set; } = true; + + /// + /// A flag indicating whether when a field name should be printed on all pages. + /// + /// Also called FieldPrintTitles. + public Boolean PrintTitles { get; set; } = false; + + /// + /// A flag indicating whether whether PivotItem names should be repeated at the top of each + /// printed page (e.g. if axis item spans multiple pages, it will be repeated an all pages). + /// + /// Also called ItemPrintTitles. + public Boolean RepeatRowLabels { get; set; } = false; + + /// + /// A flag indicating whether row or column titles that span multiple cells should be + /// merged into a single cell. Useful only in in tabular layout, titles in other layouts + /// don't span across multiple cells. + /// + /// Also called MergeItem. + public Boolean MergeAndCenterWithLabels { get; set; } = false; + + /// + /// A flag indicating whether UI for the pivot table should display large text in field + /// drop zones when there are no fields in the data region (e.g. Drop Value Fields + /// Here). Only works in legacy layout mode (i.e. + /// is true). + /// + internal bool ShowDropZones { get; init; } = true; + + /// + /// Specifies the version of the application that created the pivot cache. Application-dependent. + /// + /// Also called CreatedVersion. + internal byte PivotCacheCreatedVersion { get; init; } = 0; + + /// + /// A row indentation increment for row axis when pivot table is in compact layout. Units + /// are characters. + /// + /// Also called Indent. + public Int32 RowLabelIndent { get; set; } = 1; + + /// + /// A flag indicating whether to include empty rows in the pivot table (i.e. row axis items + /// are blank and data items are blank). + /// + /// Also called ShowEmptyRow. + public Boolean ShowEmptyItemsOnRows { get; set; } = false; + + /// + /// A flag indicating whether to include empty columns in the table (i.e. column axis items + /// are blank and data items are blank). + /// + /// Also called ShowEmptyColumn. + public Boolean ShowEmptyItemsOnColumns { get; set; } + + /// + /// A flag indicating whether to show field names on axis. The axis items are still + /// displayed, only field names are not. The dropdowns next to the axis field names + /// are also displayed/hidden based on the flag. + /// + /// Also called ShowHeaders. + public Boolean DisplayCaptionsAndDropdowns { get; set; } = true; + + /// + /// A flag indicating whether new fields should have their + /// flag set to true. By new, it means field + /// added to page, axes or data fields, not a new field from cache. + /// + internal bool Compact + { + get => _compact; + init => _compact = value; + } + + /// + /// A flag indicating whether new fields should have their + /// flag set to true. By new, it means field + /// added to page, axes or data fields, not a new field from cache. + /// + internal bool Outline + { + get => _outline; + init => _outline = value; + } + + /// + /// + /// A flag that indicates whether 'data'/-2 fields in the PivotTable should be displayed in + /// outline next column of the sheet. This is basically an equivalent of + /// property for the 'data' fields, because 'data' + /// field is implicit. + /// + /// + /// When true, the labels from the next field (as ordered by + /// for row or column) are displayed in the next + /// column. Has no effect if 'data' field is last field. + /// + /// + /// Doesn't seem to do much in column axis, only in row axis. Also, Excel + /// sometimes seems to favor flag instead (likely some less used + /// paths in the Excel code). + internal bool OutlineData + { + get => _outlineData; + init => _outlineData = value; + } + + /// + /// + /// A flag that indicates whether 'data'/-2 fields in the PivotTable should be displayed in + /// compact mode (=same column of the sheet). This is basically an equivalent of + /// property for the 'data' fields, because 'data' + /// field is implicit. + /// + /// + /// When true, the labels from the next field (as ordered by + /// for row or column) are displayed in the same + /// column (one row below). Has no effect if 'data' field is last field. + /// + /// + /// Doesn't seem to do much in column axis, only in row axis. Also, Excel + /// sometimes seems to favor flag instead (likely some less used + /// paths in the Excel code). + internal bool CompactData + { + get => _compactData; + init => _compactData = value; + } + + /// + /// A flag that indicates whether data fields in the pivot table are published and + /// available for viewing in a server rendering environment. + /// + /// No idea what this does. Likely flag for other components that display table + /// on a web page. + internal bool Published { get; init; } = false; + + /// + /// A flag that indicates whether to apply the classic layout. Classic layout displays the + /// grid zones in UI where user can drop fields (unless disabled through + /// ). + /// + /// Also called GridDropZones. + public Boolean ClassicPivotTableLayout { get; set; } = false; + + /// + /// Likely a flag whether immersive reader should be turned off. Not sure if immersive + /// reader was ever used outside Word, though Excel for Web added some support in 2023. + /// + internal bool StopImmersiveUi { get; init; } = true; + + /// + /// + /// A flag indicating whether field can have at most most one filter type used. This flag + /// doesn't allow multiple filters of same type, only multiple different filter types. + /// + /// + /// If false, field can have at most one filter, if user tries to set multiple, previous + /// one is cleared. + /// + /// + /// Also called multipleFieldFilters. + public Boolean AllowMultipleFilters { get; set; } = true; + + /// + /// Specifies the next pivot chart formatting identifier to use on the pivot table. First + /// actually used identifier should be 1. The format is used in /chartSpace/pivotSource/ + /// fmtId/@val. + /// + internal uint ChartFormat { get; init; } = 0; + + /// + /// The text that will be displayed in row header in compact mode. It is next to drop down + /// (if enabled) of a label/values filter for fields (if + /// is set to true). Use localized text + /// Row labels if property is not specified. + /// + public String? RowHeaderCaption { get; set; } = null; + + /// + /// The text that will be displayed in column header in compact mode. It is next to drop down + /// (if enabled) of a label/values filter for fields (if + /// is set to true). Use localized text + /// Column labels if property is not specified. + /// + public String? ColumnHeaderCaption { get; set; } = null; + + /// + /// A flag that controls how are fields sorted in the field list UI. true will + /// display fields sorted alphabetically, false will display fields in the order + /// fields appear in . OLAP data sources always use alphabetical + /// sorting. + /// + /// Also called fieldListSortAscending. + public Boolean SortFieldsAtoZ { get; set; } = false; + + /// + /// A flag indicating whether MDX sub-queries are supported by OLAP data provider of this + /// pivot table. + /// + internal bool MdxSubQueries { get; init; } = false; + + /// + /// A flag that indicates whether custom lists are used for sorting items of fields, both + /// initially when the PivotField is initialized and the PivotItems are ordered by their + /// captions, and later when the user applies a sort. + /// + /// Also called customSortList. + public Boolean UseCustomListsForSorting { get; set; } + + #endregion + + /// + /// Add field to a specific axis (page/row/col). Only modified , doesn't modify + /// additional info in , or . + /// + internal FieldIndex AddFieldToAxis(string sourceName, string customName, XLPivotAxis axis) + { + // Only slices axes can be added through this method. + Debug.Assert(axis is XLPivotAxis.AxisCol or XLPivotAxis.AxisRow or XLPivotAxis.AxisPage); + if (sourceName == XLConstants.PivotTable.ValuesSentinalLabel) + { + if (axis != XLPivotAxis.AxisRow && axis != XLPivotAxis.AxisCol) + throw new ArgumentException("Data field can be used only on row or column axis.", nameof(sourceName)); + + if (RowAxis.ContainsDataField || ColumnAxis.ContainsDataField) + throw new ArgumentException("Data field is already used.", nameof(sourceName)); + + var isRowAxis = axis == XLPivotAxis.AxisRow; + + DataOnRows = isRowAxis; + DataPosition = isRowAxis ? RowAxis.Fields.Count : ColumnAxis.Fields.Count; + DataCaption = "Values"; // Custom captions don't do anything. + return FieldIndex.DataField; + } + + if (!_cache.TryGetFieldIndex(sourceName, out var fieldIndex)) + throw new InvalidOperationException($"Field '{sourceName}' not found in pivot cache."); + + // Check actual fields. + var customNameUsed = _fields.Any(f => XLHelper.NameComparer.Equals(f.Name, customName)); + if (customNameUsed) + throw new InvalidOperationException($"Custom name '{customName}' is already used."); + + var field = _fields[fieldIndex]; + field.Name = customName; + field.Axis = axis; + + // If it is an axis, all possible values to field items, because they should be referenced in items. + // Page field must have default item, otherwise Excel asks for repair. + var sharedItems = _cache.GetFieldSharedItems(fieldIndex); + for (var i = 0; i < sharedItems.Count; ++i) + field.AddItem(new XLPivotFieldItem(field, i)); + + // Subtotal items must be synchronized with subtotals. If field has a an item for + // subtotal function, but doesn't declare subtotals function, Excel will try to + // repair workbook. Subtotal items can be in any order. + foreach (var subtotalFunction in field.Subtotals) + { + var itemType = subtotalFunction switch + { + XLSubtotalFunction.Automatic => XLPivotItemType.Default, + XLSubtotalFunction.Sum => XLPivotItemType.Sum, + XLSubtotalFunction.Count => XLPivotItemType.CountA, + XLSubtotalFunction.Average => XLPivotItemType.Avg, + XLSubtotalFunction.Minimum => XLPivotItemType.Min, + XLSubtotalFunction.Maximum => XLPivotItemType.Max, + XLSubtotalFunction.Product => XLPivotItemType.Product, + XLSubtotalFunction.CountNumbers => XLPivotItemType.Count, + XLSubtotalFunction.StandardDeviation => XLPivotItemType.StdDev, + XLSubtotalFunction.PopulationStandardDeviation => XLPivotItemType.StdDevP, + XLSubtotalFunction.Variance => XLPivotItemType.Var, + XLSubtotalFunction.PopulationVariance => XLPivotItemType.VarP, + _ => throw new UnreachableException(), + }; + field.AddItem(new XLPivotFieldItem(field, null) { ItemType = itemType }); + } + + return fieldIndex; + } + + internal void RemoveFieldFromAxis(FieldIndex index) + { + if (index.IsDataField) + { + DataOnRows = false; + DataPosition = null; + DataCaption = "Values"; + } + else + { + var field = _fields[index]; + field.Name = null; + field.Axis = null; + field.DataField = false; + field.MultipleItemSelectionAllowed = false; + } + } + + internal bool TryGetSourceNameFieldIndex(String sourceName, out FieldIndex index) + { + if (XLHelper.NameComparer.Equals(sourceName, XLConstants.PivotTable.ValuesSentinalLabel)) + { + index = FieldIndex.DataField; + return true; + } + + if (PivotCache.TryGetFieldIndex(sourceName, out var fldIndex)) + { + index = fldIndex; + return true; + } + + index = default; + return false; + } + + internal bool TryGetCustomNameFieldIndex(String customName, out FieldIndex index) + { + var comparer = XLHelper.NameComparer; + if (comparer.Equals(customName, XLConstants.PivotTable.ValuesSentinalLabel)) + { + index = FieldIndex.DataField; + return true; + } + + var allFields = PivotFields; + for (var i = 0; i < allFields.Count; ++i) + { + if (comparer.Equals(customName, allFields[i].Name)) + { + index = i; + return true; + } + } + + index = default; + return false; + } + + /// + /// Refresh cache fields after cache has changed. + /// + internal void UpdateCacheFields(IReadOnlyList oldFieldNames) + { + // Should be better, but at least refresh fields. A lot of attributes are not + // kept/initialized from the table. We can't just reuse original objects, because + // all indices are wrong. Make a copy and then re-set the original properties that + // are saved before GC takes them. + var newNames = new HashSet(PivotCache.FieldNames, XLHelper.NameComparer); + + // Source and custom name might not be valid at this point, so keep them. + var keptDataFields = new List<(string SourceName, string? CustomName, XLPivotDataField Field)>(); + foreach (var dataField in DataFields) + { + var oldSourceName = oldFieldNames[dataField.Field]; + if (newNames.Contains(oldSourceName)) + { + keptDataFields.Add((oldSourceName, dataField.DataFieldName, dataField)); + } + } + + var includeValuesField = keptDataFields.Count > 1; + var keptFilterSourceNames = GetKeptNames(Filters.Fields.Select(x => (FieldIndex)x.Field).ToList(), oldFieldNames, newNames, includeValuesField); + var keptRowSourceNames = GetKeptNames(RowAxis.Fields, oldFieldNames, newNames, includeValuesField); + var keptColumnSourceNames = GetKeptNames(ColumnAxis.Fields, oldFieldNames, newNames, includeValuesField); + + Filters.Clear(); + RowAxis.Clear(); + ColumnAxis.Clear(); + DataFields.Clear(); + + _fields.Clear(); + foreach (var fieldName in PivotCache.FieldNames) + { + var field = new XLPivotTableField(this) { - yield return pivotField.StyleFormats.Subtotal; - yield return pivotField.StyleFormats.Header; - yield return pivotField.StyleFormats.Label; - yield return pivotField.StyleFormats.DataValuesFormat; + Compact = Compact, + Outline = Outline, + }; + _fields.Add(field); + } + + foreach (var filterName in keptFilterSourceNames) + Filters.Add(filterName, filterName); + + foreach (var rowName in keptRowSourceNames) + RowAxis.AddField(rowName, rowName); + + foreach (var columnName in keptColumnSourceNames) + ColumnAxis.AddField(columnName, columnName); + + foreach (var keptDataField in keptDataFields) + { + var dataField = DataFields.AddField(keptDataField.SourceName, keptDataField.CustomName); + dataField.Subtotal = keptDataField.Field.Subtotal; + } + + static List GetKeptNames( + IReadOnlyList fieldIndexes, + IReadOnlyList oldNames, + HashSet newNames, + bool includeDataField) + { + var result = new List(); + foreach (var fieldIndex in fieldIndexes) + { + if (fieldIndex.IsDataField && includeDataField) + { + result.Add(XLConstants.PivotTable.ValuesSentinalLabel); + continue; + } + + var oldName = oldNames[fieldIndex]; + if (newNames.Contains(oldName)) + result.Add(oldName); } + + return result; } } + + /// + /// Is field used by any axis (row, column, filter), but not data. + /// + internal bool IsFieldUsedOnAxis(FieldIndex fieldIndex) + { + if (fieldIndex.IsDataField) + return DataPosition is not null; + + return RowAxis.Fields.Contains(fieldIndex) || + ColumnAxis.Fields.Contains(fieldIndex) || + Filters.Contains(fieldIndex); + } + + internal int GetFieldIndex(XLPivotTableField field) + { + var index = _fields.IndexOf(field); + if (index < 0) + throw new ArgumentException($"Unable to find field '{field.Name}'."); + return index; + } } } diff --git a/ClosedXML/Excel/PivotTables/XLPivotTableEnums.cs b/ClosedXML/Excel/PivotTables/XLPivotTableEnums.cs new file mode 100644 index 000000000..8d68555c3 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotTableEnums.cs @@ -0,0 +1,147 @@ +namespace ClosedXML.Excel +{ + public enum XLFilterAreaOrder { DownThenOver, OverThenDown } + + /// + /// Specifies the number of unused items to allow in a + /// before discarding unused items. + /// + public enum XLItemsToRetain + { + /// + /// The threshold is set automatically based on the number of items. + /// + /// Default behavior. + Automatic, + + /// + /// When even one item is unused. + /// + None, + + /// + /// When all shared items of a filed are unused. + /// + Max + } + + /// + /// An enum describing how are values of a pivot field are sorted. + /// + /// + /// [ISO-29500] 18.18.28 ST_FieldSortType. + /// + public enum XLPivotSortType + { + /// + /// Field values are sorted manually. + /// + Default = 0, + + /// + /// Field values are sorted in ascending order. + /// + Ascending = 1, + + /// + /// Field values are sorted in descending order. + /// + Descending = 2 + } + + public enum XLPivotSubtotals + { + DoNotShow, + AtTop, + AtBottom + } + + public enum XLPivotTableTheme + { + None, + PivotStyleDark1, + PivotStyleDark10, + PivotStyleDark11, + PivotStyleDark12, + PivotStyleDark13, + PivotStyleDark14, + PivotStyleDark15, + PivotStyleDark16, + PivotStyleDark17, + PivotStyleDark18, + PivotStyleDark19, + PivotStyleDark2, + PivotStyleDark20, + PivotStyleDark21, + PivotStyleDark22, + PivotStyleDark23, + PivotStyleDark24, + PivotStyleDark25, + PivotStyleDark26, + PivotStyleDark27, + PivotStyleDark28, + PivotStyleDark3, + PivotStyleDark4, + PivotStyleDark5, + PivotStyleDark6, + PivotStyleDark7, + PivotStyleDark8, + PivotStyleDark9, + PivotStyleLight1, + PivotStyleLight10, + PivotStyleLight11, + PivotStyleLight12, + PivotStyleLight13, + PivotStyleLight14, + PivotStyleLight15, + PivotStyleLight16, + PivotStyleLight17, + PivotStyleLight18, + PivotStyleLight19, + PivotStyleLight2, + PivotStyleLight20, + PivotStyleLight21, + PivotStyleLight22, + PivotStyleLight23, + PivotStyleLight24, + PivotStyleLight25, + PivotStyleLight26, + PivotStyleLight27, + PivotStyleLight28, + PivotStyleLight3, + PivotStyleLight4, + PivotStyleLight5, + PivotStyleLight6, + PivotStyleLight7, + PivotStyleLight8, + PivotStyleLight9, + PivotStyleMedium1, + PivotStyleMedium10, + PivotStyleMedium11, + PivotStyleMedium12, + PivotStyleMedium13, + PivotStyleMedium14, + PivotStyleMedium15, + PivotStyleMedium16, + PivotStyleMedium17, + PivotStyleMedium18, + PivotStyleMedium19, + PivotStyleMedium2, + PivotStyleMedium20, + PivotStyleMedium21, + PivotStyleMedium22, + PivotStyleMedium23, + PivotStyleMedium24, + PivotStyleMedium25, + PivotStyleMedium26, + PivotStyleMedium27, + PivotStyleMedium28, + PivotStyleMedium3, + PivotStyleMedium4, + PivotStyleMedium5, + PivotStyleMedium6, + PivotStyleMedium7, + PivotStyleMedium8, + PivotStyleMedium9 + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotTableField.cs b/ClosedXML/Excel/PivotTables/XLPivotTableField.cs new file mode 100644 index 000000000..cb4831093 --- /dev/null +++ b/ClosedXML/Excel/PivotTables/XLPivotTableField.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace ClosedXML.Excel; + +/// +/// One field in a . Pivot table must contain field for each entry of +/// pivot cache and both are accessed through same index. Pivot field contains items, which are +/// cache field values referenced anywhere in the pivot table (e.g. caption, axis value ect.). +/// +/// +/// See [OI-29500] 18.10.1.69 pivotField(PivotTable Field) for details. +/// +internal class XLPivotTableField +{ + private readonly XLPivotTable _pivotTable; + private readonly List _items = new(); + + public XLPivotTableField(XLPivotTable pivotTable) + { + _pivotTable = pivotTable; + ShowAll = false; // The XML default value is true, but Excel always has false, so let's follow Excel. + Subtotals = new HashSet { XLSubtotalFunction.Automatic }; + } + + internal XLPivotTable PivotTable => _pivotTable; + + /// + /// Pivot field item, doesn't contain value, only indexes to shared items. + /// + internal IReadOnlyList Items => _items; + + #region XML attributes + + /// + /// Custom name of the field. + /// + /// + /// [MS-OI29500] Office requires @name to be unique for non-OLAP PivotTables. Ignored by data + /// fields that use . + /// + internal string? Name { get; set; } + + /// + /// If the value is set, the field must also be in rowFields/colFields/ + /// pageFields/dataFields collection in the pivot table part (otherwise Excel + /// will consider it a corrupt file). + /// + /// + /// [MS-OI29500] In Office, axisValues shall not be used for the axis attribute. + /// + internal XLPivotAxis? Axis { get; set; } + + /// + /// Is this field a data field (i.e. it is referenced the pivotTableDefinition. + /// dataFields)? Excel will crash, unless these two things both set correctly. + /// + internal bool DataField { get; set; } = false; + + internal string SubtotalCaption { get; set; } = string.Empty; + + internal bool ShowDropDowns { get; init; } = true; + + internal bool HiddenLevel { get; init; } = false; + + internal string? UniqueMemberProperty { get; init; } + + /// + /// + /// Should the headers be displayed in a same column. This field has meaning only when + /// is set to true. Excel displays the flag in Field settings + /// - Layout & Print - Display labels from next field in the same column. + /// + /// + /// The value doesn't affect anything individually per field. When ALL fields of the pivot table + /// are false, the headers are displayed individually. If ANY field is true, headers + /// are grouped to single column. + /// + /// + internal bool Compact { get; set; } = true; + + /// + /// Are all items expanded? + /// + internal bool AllDrilled { get; set; } = false; + + internal XLNumberFormatValue? NumberFormatValue { get; init; } + + /// + /// + /// A flag that determines if field is in tabular form (false) or outline form + /// (true). If it is in outline form, the can also switch + /// to form. + /// + /// + /// Excel displays it on Field Settings - Layout & Print as a radio box. + /// + /// + internal bool Outline { get; set; } = true; + + internal bool SubtotalTop { get; set; } = true; + + internal bool DragToRow { get; init; } = true; + + internal bool DragToColumn { get; init; } = true; + + internal bool MultipleItemSelectionAllowed { get; set; } = false; + + internal bool DragToPage { get; init; } = true; + + internal bool DragToData { get; init; } = true; + + internal bool DragOff { get; init; } = true; + + /// + /// A flag that indicates whether to show all items for this field. + /// + internal bool ShowAll { get; set; } = true; + + /// + /// Insert empty row below every item if the field is row/column axis. The last field in axis + /// doesn't add extra item at the end. If multiple fields in axis have extra item, only once + /// blank row is inserted. + /// + internal bool InsertBlankRow { get; set; } = false; + + internal bool ServerField { get; init; } = false; + + internal bool InsertPageBreak { get; set; } = false; + + internal bool AutoShow { get; init; } = false; + + internal bool TopAutoShow { get; init; } = true; + + internal bool HideNewItems { get; init; } = false; + + internal bool MeasureFilter { get; init; } = false; + + internal bool IncludeNewItemsInFilter { get; set; } = false; + + internal uint ItemPageCount { get; init; } = 10; + + internal XLPivotSortType SortType { get; set; } = XLPivotSortType.Default; + + internal bool? DataSourceSort { get; init; } + + internal bool NonAutoSortDefault { get; init; } = false; + + internal uint? RankBy { get; init; } + + /// + /// Subtotal functions represented in XML. It's kind of convoluted mess, because + /// it represents three possible results: + /// + /// None - Collection is empty. + /// Automatic - Collection contains only . + /// Custom - Collection contains subtotal functions other than . + /// The is ignored in that case, even if it is present. + /// . + /// + /// + /// Excel requires that pivot field contains a item if and only if there is a declared subtotal function. + /// The subtotal items must be kept at the end of the , otherwise Excel will try to repair + /// workbook. + /// + internal HashSet Subtotals { get; init; } + + internal bool ShowPropCell { get; init; } = false; + + internal bool ShowPropTip { get; init; } = false; + + internal bool ShowPropAsCaption { get; init; } = false; + + internal bool DefaultAttributeDrillState { get; init; } = false; + + /// + /// Are item labels on row/column axis repeated for each nested item? + /// + /// + /// Also called FillDownLabels. Attribute is ignored if both the + /// and the are true. Attribute is ignored if the field is not on + /// the or the . + /// + internal bool RepeatItemLabels { get; set; } = false; + + #endregion XML attributes + + internal bool Collapsed + { + get => !AllDrilled; + set => AllDrilled = !value; + } + + /// + /// Add an item when it is used anywhere in the pivot table. + /// + /// Item to add. + /// Index of added item. + internal uint AddItem(XLPivotFieldItem item) + { + var index = _items.Count; + _items.Add(item); + return (uint)index; + } + + internal void AddSubtotal(XLSubtotalFunction value) + { + Subtotals.Add(value); + var subtotalItemType = GetItemTypeForSubtotal(value);; + _items.Add(new XLPivotFieldItem(this, null) { ItemType = subtotalItemType }); + } + + internal void RemoveSubtotal(XLSubtotalFunction value) + { + Subtotals.Remove(value); + var subtotalItemType = GetItemTypeForSubtotal(value); + _items.RemoveAll(item => item.ItemType == subtotalItemType); + } + + internal void SetLayout(XLPivotLayout value) + { + switch (value) + { + case XLPivotLayout.Compact: + // Compact form is a subtype of outline form. In Excel GUI, it is a checkbox under + // outline mode. + Outline = true; + Compact = true; + break; + case XLPivotLayout.Outline: + Compact = false; + Outline = true; + break; + case XLPivotLayout.Tabular: + Compact = false; + Outline = false; + break; + default: + throw new UnreachableException(); + } + } + + internal XLPivotFieldItem GetOrAddItem(XLCellValue value) + { + var index = _pivotTable.GetFieldIndex(this); + var cache = _pivotTable.PivotCache; + var cacheValues = cache.GetFieldValues(index); + var sharedItemIndex = cacheValues.GetOrAddSharedItem(value); + + // Excel tries to repair workbook, when there are duplicates in pivotFields.Items + // therefore add only if necessary + var existingItem = _items.FirstOrDefault(x => x.ItemIndex == sharedItemIndex); + if (existingItem is not null) + return existingItem; + + var newItem = new XLPivotFieldItem(this, sharedItemIndex); + _items.Add(newItem); + return newItem; + } + + /// + /// + /// Filter all shared items of the field through a and return + /// all items that represent a value that satisfies the . + /// + /// + /// The don't necessarily contain all indexes to shared items + /// and if the value is missing in but is present in shared items, + /// add it (that's why method has prefix All). + /// + /// + /// Condition to satisfy. + internal List GetAllItems(Predicate predicate) + { + var fieldIndex = _pivotTable.GetFieldIndex(this); + var sharedItems = _pivotTable.PivotCache.GetFieldSharedItems(fieldIndex); + + var pivotFieldItems = Items + .WhereNotNull(fieldItem => fieldItem.ItemIndex) + .Select((itemItem, index) => (Index: index, ItemIndex: (uint)itemItem)) + .ToDictionary(x => x.ItemIndex, x => x.Index); + var filteredItems = new List(); + for (uint sharedItemIndex = 0; sharedItemIndex < sharedItems.Count; ++sharedItemIndex) + { + var sharedItemValue = sharedItems[sharedItemIndex]; + if (!predicate(sharedItemValue)) + continue; + + // Generally speaking, Excel created workbooks seem to always have all items + // for axis fields, but that is not true for workbooks created by other producers. + if (pivotFieldItems.TryGetValue(sharedItemIndex, out var index)) + { + filteredItems.Add(Items[index]); + } + else + { + var addedItem = new XLPivotFieldItem(this, (int)sharedItemIndex); + AddItem(addedItem); + filteredItems.Add(addedItem); + } + } + + return filteredItems; + } + + private static XLPivotItemType GetItemTypeForSubtotal(XLSubtotalFunction value) + { + var subtotalItemType = value switch + { + XLSubtotalFunction.Automatic => XLPivotItemType.Default, + XLSubtotalFunction.Sum => XLPivotItemType.Sum, + XLSubtotalFunction.Count => XLPivotItemType.CountA, + XLSubtotalFunction.Average => XLPivotItemType.Avg, + XLSubtotalFunction.Minimum => XLPivotItemType.Min, + XLSubtotalFunction.Maximum => XLPivotItemType.Max, + XLSubtotalFunction.Product => XLPivotItemType.Product, + XLSubtotalFunction.CountNumbers => XLPivotItemType.Count, + XLSubtotalFunction.StandardDeviation => XLPivotItemType.StdDev, + XLSubtotalFunction.PopulationStandardDeviation => XLPivotItemType.StdDevP, + XLSubtotalFunction.Variance => XLPivotItemType.Var, + XLSubtotalFunction.PopulationVariance => XLPivotItemType.VarP, + _ => throw new UnreachableException() + }; + return subtotalItemType; + } +} diff --git a/ClosedXML/Excel/PivotTables/XLPivotTables.cs b/ClosedXML/Excel/PivotTables/XLPivotTables.cs index a601d5829..b8681e0df 100644 --- a/ClosedXML/Excel/PivotTables/XLPivotTables.cs +++ b/ClosedXML/Excel/PivotTables/XLPivotTables.cs @@ -1,55 +1,55 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; namespace ClosedXML.Excel { - internal class XLPivotTables : IXLPivotTables + internal class XLPivotTables : IXLPivotTables, IEnumerable { - private readonly Dictionary _pivotTables = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _pivotTables = new(StringComparer.OrdinalIgnoreCase); - public XLPivotTables(IXLWorksheet worksheet) + public XLPivotTables(XLWorksheet worksheet) { - this.Worksheet = worksheet ?? throw new ArgumentNullException(nameof(worksheet)); + Worksheet = worksheet ?? throw new ArgumentNullException(nameof(worksheet)); } - internal void Add(String name, IXLPivotTable pivotTable) - { - _pivotTables.Add(name, (XLPivotTable)pivotTable); - } + internal XLWorksheet Worksheet { get; } - public IXLPivotTable Add(string name, IXLCell targetCell, IXLRange range) + public void Add(XLPivotTable pivotTable) { - var pivotTable = new XLPivotTable(this.Worksheet) - { - Name = name, - TargetCell = targetCell, - SourceRange = range - }; - _pivotTables.Add(name, pivotTable); - return pivotTable; + var pivotCache = pivotTable.PivotCache; + if (!pivotCache.FieldNames.Any()) + pivotCache.Refresh(); + + _pivotTables.Add(pivotTable.Name, pivotTable); } - public IXLPivotTable Add(string name, IXLCell targetCell, IXLTable table) + public IXLPivotTable Add(string name, IXLCell targetCell, IXLPivotCache pivotCache) { - var pivotTable = new XLPivotTable(this.Worksheet) + var pivotTable = new XLPivotTable(Worksheet, (XLPivotCache)pivotCache) { Name = name, - TargetCell = targetCell, - SourceTable = table + Area = new XLSheetRange(XLSheetPoint.FromAddress(targetCell.Address)), }; - _pivotTables.Add(name, pivotTable); + Add(pivotTable); + pivotTable.UpdateCacheFields(Array.Empty()); return pivotTable; } - public IXLPivotTable AddNew(string name, IXLCell targetCell, IXLRange range) + public IXLPivotTable Add(string name, IXLCell targetCell, IXLRange range) { - return Add(name, targetCell, range); + var area = XLBookArea.From(range); + var pivotCaches = Worksheet.Workbook.PivotCachesInternal; + var existingPivotCache = pivotCaches.Find(area); + var pivotCache = existingPivotCache ?? pivotCaches.Add(area); + return Add(name, targetCell, pivotCache); } - public IXLPivotTable AddNew(string name, IXLCell targetCell, IXLTable table) + public IXLPivotTable Add(string name, IXLCell targetCell, IXLTable table) { - return Add(name, targetCell, table); + return Add(name, targetCell, (IXLRange)table); } public Boolean Contains(String name) @@ -67,9 +67,19 @@ public void DeleteAll() _pivotTables.Clear(); } - public IEnumerator GetEnumerator() + IXLPivotTable IXLPivotTables.PivotTable(String name) + { + return PivotTable(name); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() { - return _pivotTables.Values.Cast().GetEnumerator(); + return GetEnumerator(); } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() @@ -77,16 +87,20 @@ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() return GetEnumerator(); } - public XLPivotTable PivotTable(String name) + public Dictionary.ValueCollection.Enumerator GetEnumerator() { - return _pivotTables[name]; + return _pivotTables.Values.GetEnumerator(); } - IXLPivotTable IXLPivotTables.PivotTable(String name) + internal void Add(String name, IXLPivotTable pivotTable) { - return PivotTable(name); + _pivotTables.Add(name, (XLPivotTable)pivotTable); } - public IXLWorksheet Worksheet { get; private set; } + /// + internal XLPivotTable PivotTable(String name) + { + return _pivotTables[name]; + } } } diff --git a/ClosedXML/Excel/Protection/IXLElementProtection.cs b/ClosedXML/Excel/Protection/IXLElementProtection.cs index c3bfc5c32..4dbf2c7f0 100644 --- a/ClosedXML/Excel/Protection/IXLElementProtection.cs +++ b/ClosedXML/Excel/Protection/IXLElementProtection.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using static ClosedXML.Excel.XLProtectionAlgorithm; @@ -21,16 +23,13 @@ public interface IXLElementProtection : IXLElementProtection IXLElementProtection AllowElement(T element, Boolean allowed = true); /// Allows all elements to be edited. - /// IXLElementProtection AllowEverything(); /// Allows no elements to be edited. Protects all elements. - /// IXLElementProtection AllowNone(); /// Copies all the protection settings from a different instance. /// The protectable. - /// IXLElementProtection CopyFrom(IXLElementProtection protectable); /// @@ -48,16 +47,13 @@ public interface IXLElementProtection : IXLElementProtection /// Protects this instance using the specified password and password hash algorithm. /// The password. /// The algorithm. - /// IXLElementProtection Protect(String password, Algorithm algorithm = DefaultProtectionAlgorithm); /// Unprotects this instance without a password. - /// IXLElementProtection Unprotect(); /// Unprotects this instance using the specified password. /// The password. - /// IXLElementProtection Unprotect(String password); } diff --git a/ClosedXML/Excel/Protection/IXLProtectable.cs b/ClosedXML/Excel/Protection/IXLProtectable.cs index 761bb83f2..4376c18b3 100644 --- a/ClosedXML/Excel/Protection/IXLProtectable.cs +++ b/ClosedXML/Excel/Protection/IXLProtectable.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using static ClosedXML.Excel.XLProtectionAlgorithm; @@ -14,35 +16,29 @@ public interface IXLProtectable : IXLProtectable TProtection Protect(TElement allowedElements); /// Protects this instance without a password. - /// new TProtection Protect(Algorithm algorithm = DefaultProtectionAlgorithm); /// Protects this instance with the specified password, password hash algorithm and set elements that the user is allowed to change. /// The algorithm. /// The allowed elements. - /// TProtection Protect(Algorithm algorithm, TElement allowedElements); /// Protects this instance using the specified password and password hash algorithm. /// The password. /// The algorithm. - /// new TProtection Protect(String password, Algorithm algorithm = DefaultProtectionAlgorithm); /// Protects this instance with the specified password, password hash algorithm and set elements that the user is allowed to change. /// The password. /// The algorithm. /// The allowed elements. - /// TProtection Protect(String password, Algorithm algorithm, TElement allowedElements); /// Unprotects this instance without a password. - /// new TProtection Unprotect(); /// Unprotects this instance using the specified password. /// The password. - /// new TProtection Unprotect(String password); } @@ -61,22 +57,18 @@ public interface IXLProtectable Boolean IsProtected { get; } /// Protects this instance without a password. - /// IXLElementProtection Protect(Algorithm algorithm = DefaultProtectionAlgorithm); /// Protects this instance using the specified password and password hash algorithm. /// The password. /// The algorithm. - /// IXLElementProtection Protect(String password, Algorithm algorithm = DefaultProtectionAlgorithm); /// Unprotects this instance without a password. - /// IXLElementProtection Unprotect(); /// Unprotects this instance using the specified password. /// The password. - /// IXLElementProtection Unprotect(String password); } } diff --git a/ClosedXML/Excel/Protection/IXLSheetProtection.cs b/ClosedXML/Excel/Protection/IXLSheetProtection.cs index 376b6478c..7a10b605f 100644 --- a/ClosedXML/Excel/Protection/IXLSheetProtection.cs +++ b/ClosedXML/Excel/Protection/IXLSheetProtection.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using static ClosedXML.Excel.XLProtectionAlgorithm; diff --git a/ClosedXML/Excel/Protection/IXLWorkbookProtection.cs b/ClosedXML/Excel/Protection/IXLWorkbookProtection.cs index 27b8ac0b8..243dd2d49 100644 --- a/ClosedXML/Excel/Protection/IXLWorkbookProtection.cs +++ b/ClosedXML/Excel/Protection/IXLWorkbookProtection.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using static ClosedXML.Excel.XLProtectionAlgorithm; diff --git a/ClosedXML/Excel/Protection/XLProtectionAlgorithm.cs b/ClosedXML/Excel/Protection/XLProtectionAlgorithm.cs index f1aeee8db..b42428ab7 100644 --- a/ClosedXML/Excel/Protection/XLProtectionAlgorithm.cs +++ b/ClosedXML/Excel/Protection/XLProtectionAlgorithm.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System.ComponentModel; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Protection/XLSheetProtection.cs b/ClosedXML/Excel/Protection/XLSheetProtection.cs index 2a58f6145..96ba95130 100644 --- a/ClosedXML/Excel/Protection/XLSheetProtection.cs +++ b/ClosedXML/Excel/Protection/XLSheetProtection.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using static ClosedXML.Excel.XLProtectionAlgorithm; @@ -116,9 +118,7 @@ public IXLSheetProtection Unprotect(String password) { if (IsProtected) { - password = password ?? ""; - - if ("" != PasswordHash && "" == password) + if (PasswordHash.Length > 0 && string.IsNullOrEmpty(password)) throw new InvalidOperationException("The worksheet is password protected"); var hash = Utils.CryptographicAlgorithms.GetPasswordHash(this.Algorithm, password, this.Base64EncodedSalt, this.SpinCount); diff --git a/ClosedXML/Excel/Protection/XLSheetProtectionElements.cs b/ClosedXML/Excel/Protection/XLSheetProtectionElements.cs index afa56db9f..09d67790b 100644 --- a/ClosedXML/Excel/Protection/XLSheetProtectionElements.cs +++ b/ClosedXML/Excel/Protection/XLSheetProtectionElements.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Protection/XLWorkbookProtection.cs b/ClosedXML/Excel/Protection/XLWorkbookProtection.cs index 95ecc846d..fe206fb43 100644 --- a/ClosedXML/Excel/Protection/XLWorkbookProtection.cs +++ b/ClosedXML/Excel/Protection/XLWorkbookProtection.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using static ClosedXML.Excel.XLProtectionAlgorithm; @@ -120,9 +122,7 @@ public IXLWorkbookProtection Unprotect(String password) { if (IsProtected) { - password = password ?? ""; - - if ("" != PasswordHash && "" == password) + if (PasswordHash.Length > 0 && string.IsNullOrEmpty(password)) throw new InvalidOperationException("The workbook structure is password protected"); var hash = Utils.CryptographicAlgorithms.GetPasswordHash(this.Algorithm, password, this.Base64EncodedSalt, this.SpinCount); diff --git a/ClosedXML/Excel/Protection/XLWorkbookProtectionElements.cs b/ClosedXML/Excel/Protection/XLWorkbookProtectionElements.cs index 799572042..259f3b92c 100644 --- a/ClosedXML/Excel/Protection/XLWorkbookProtectionElements.cs +++ b/ClosedXML/Excel/Protection/XLWorkbookProtectionElements.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Ranges/IXLAddressable.cs b/ClosedXML/Excel/Ranges/IXLAddressable.cs index b2921b435..fece57b9d 100644 --- a/ClosedXML/Excel/Ranges/IXLAddressable.cs +++ b/ClosedXML/Excel/Ranges/IXLAddressable.cs @@ -1,4 +1,6 @@ -namespace ClosedXML.Excel +#nullable disable + +namespace ClosedXML.Excel { /// /// A very lightweight interface for entities that have an address as diff --git a/ClosedXML/Excel/Ranges/IXLBaseCollection.cs b/ClosedXML/Excel/Ranges/IXLBaseCollection.cs deleted file mode 100644 index d4d6c83e8..000000000 --- a/ClosedXML/Excel/Ranges/IXLBaseCollection.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ClosedXML.Excel -{ - public interface IXLBaseCollection : IEnumerable - { - Int32 Count { get; } - - IXLStyle Style { get; set; } - - IXLDataValidation SetDataValidation(); - - /// - /// Creates a named range out of these ranges. - /// If the named range exists, it will add these ranges to that named range. - /// The default scope for the named range is Workbook. - /// - /// Name of the range. - TMultiple AddToNamed(String rangeName); - - /// - /// Creates a named range out of these ranges. - /// If the named range exists, it will add these ranges to that named range. - /// Name of the range. - /// The scope for the named range. - /// - TMultiple AddToNamed(String rangeName, XLScope scope); - - /// - /// Creates a named range out of these ranges. - /// If the named range exists, it will add these ranges to that named range. - /// Name of the range. - /// The scope for the named range. - /// The comments for the named range. - /// - TMultiple AddToNamed(String rangeName, XLScope scope, String comment); - - /// - /// Sets the cells' value. - /// If the object is an IEnumerable ClosedXML will copy the collection's data into a table starting from each cell. - /// If the object is a range ClosedXML will copy the range starting from each cell. - /// Setting the value to an object (not IEnumerable/range) will call the object's ToString() method. - /// ClosedXML will try to translate it to the corresponding type, if it can't then the value will be left as a string. - /// - /// - /// The object containing the value(s) to set. - /// - Object Value { set; } - - TMultiple SetValue(T value); - - /// - /// Returns the collection of cells. - /// - IXLCells Cells(); - - /// - /// Returns the collection of cells that have a value. - /// - IXLCells CellsUsed(); - - /// - /// Returns the collection of cells that have a value. - /// - /// if set to true will return all cells with a value or a style different than the default. - IXLCells CellsUsed(Boolean includeFormats); - - TMultiple SetDataType(XLDataType dataType); - - /// - /// Clears the contents of these ranges. - /// - /// Specify what you want to clear. - TMultiple Clear(XLClearOptions clearOptions = XLClearOptions.All); - } -} diff --git a/ClosedXML/Excel/Ranges/IXLRange.cs b/ClosedXML/Excel/Ranges/IXLRange.cs index 7bfd1bb12..d5e4ff9a0 100644 --- a/ClosedXML/Excel/Ranges/IXLRange.cs +++ b/ClosedXML/Excel/Ranges/IXLRange.cs @@ -1,10 +1,31 @@ +#nullable disable + using System; namespace ClosedXML.Excel { public enum XLShiftDeletedCells { ShiftCellsUp, ShiftCellsLeft } - public enum XLTransposeOptions { MoveCells, ReplaceCells } + /// + /// A behavior of extra outside cells for transpose operation. The option + /// is meaningful only for transposition of non-squared ranges, because + /// squared ranges can always be transposed without effecting outside cells. + /// + public enum XLTransposeOptions + { + /// + /// Shift cells of the smaller side to its direction so + /// there is a space to transpose other side (e.g. if A1:C5 + /// range is transposed, move D1:XFD5 are moved 2 columns + /// to the right). + /// + MoveCells, + + /// + /// Data of the cells are replaced by the transposed cells. + /// + ReplaceCells + } public enum XLSearchContents { Values, Formulas, ValuesAndFormulas } @@ -55,11 +76,10 @@ public interface IXLRange : IXLRangeBase IXLRangeColumn FirstColumn(Func predicate = null); /// - /// Gets the first column of the range that contains a cell with a value. + /// Gets the first non-empty column of the range that contains a cell with a value. /// - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeColumn FirstColumnUsed(Boolean includeFormats, Func predicate = null); - + /// The options to determine whether a cell is used. + /// The predicate to choose cells. IXLRangeColumn FirstColumnUsed(XLCellsUsedOptions options, Func predicate = null); IXLRangeColumn FirstColumnUsed(Func predicate = null); @@ -70,11 +90,10 @@ public interface IXLRange : IXLRangeBase IXLRangeColumn LastColumn(Func predicate = null); /// - /// Gets the last column of the range that contains a cell with a value. + /// Gets the last non-empty column of the range that contains a cell with a value. /// - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeColumn LastColumnUsed(Boolean includeFormats, Func predicate = null); - + /// The options to determine whether a cell is used. + /// The predicate to choose cells. IXLRangeColumn LastColumnUsed(XLCellsUsedOptions options, Func predicate = null); IXLRangeColumn LastColumnUsed(Func predicate = null); @@ -122,11 +141,10 @@ public interface IXLRange : IXLRangeBase IXLRangeRow FirstRow(Func predicate = null); /// - /// Gets the first row of the range that contains a cell with a value. + /// Gets the first non-empty row of the range that contains a cell with a value. /// - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeRow FirstRowUsed(Boolean includeFormats, Func predicate = null); - + /// The options to determine whether a cell is used. + /// The predicate to choose cells. IXLRangeRow FirstRowUsed(XLCellsUsedOptions options, Func predicate = null); IXLRangeRow FirstRowUsed(Func predicate = null); @@ -137,11 +155,10 @@ public interface IXLRange : IXLRangeBase IXLRangeRow LastRow(Func predicate = null); /// - /// Gets the last row of the range that contains a cell with a value. + /// Gets the last non-empty row of the range that contains a cell with a value. /// - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeRow LastRowUsed(Boolean includeFormats, Func predicate = null); - + /// The options to determine whether a cell is used. + /// The predicate to choose cells. IXLRangeRow LastRowUsed(XLCellsUsedOptions options, Func predicate = null); IXLRangeRow LastRowUsed(Func predicate = null); @@ -160,7 +177,6 @@ public interface IXLRange : IXLRangeBase /// /// The first row to return. 1-based row number relative to the first row of this range. /// The last row to return. 1-based row number relative to the first row of this range. - /// IXLRangeRows Rows(int firstRow, int lastRow); /// @@ -294,35 +310,93 @@ public interface IXLRange : IXLRangeBase IXLRange CopyTo(IXLRangeBase target); + /// + /// Rows used for sorting columns. Automatically updated each time a + /// is called. + /// IXLSortElements SortRows { get; } + + /// + /// Columns used for sorting rows. Automatically updated each time a + /// or . + /// + /// + /// User can set desired sorting order here and then call method. + /// IXLSortElements SortColumns { get; } + /// + /// Sort rows of the range using the (if non-empty) or by using + /// all columns of the range in ascending order. + /// + /// + /// This method can be used fort sorting, after user specified desired sorting order + /// in . + /// + /// This range. IXLRange Sort(); + /// + /// Sort rows of the range according to values in columns specified by . + /// + /// + /// Columns which should be used to sort the range and their order. Columns are separated + /// by a comma (,). The column can be specified either by column number or + /// by column letter. Sort order is parsed case insensitive and can be ASC or + /// DESC. The specified column is relative to the origin of the range. + /// + /// 2 DESC, 1, C asc means sort by second column of a range in descending + /// order, then by first column of a range in and then by + /// column C in ascending order.. + /// + /// + /// + /// What should be the default sorting order or columns in + /// without specified sorting order. + /// + /// + /// When cell value is a , should sorting be case insensitive + /// (false, Excel default behavior) or case sensitive (true). Doesn't affect + /// other cell value types. + /// + /// + /// When true (recommended, matches Excel behavior), blank cell values are always + /// sorted at the end regardless of sorting order. When false, blank values are + /// considered empty strings and are sorted among other cell values with a type + /// . + /// + /// This range. IXLRange Sort(String columnsToSortBy, XLSortOrder sortOrder = XLSortOrder.Ascending, Boolean matchCase = false, Boolean ignoreBlanks = true); + /// + /// Sort rows of the range according to values in column. + /// + /// Column number that will be used to sort the range rows. + /// Sorting order used by . + /// + /// + /// This range. IXLRange Sort(Int32 columnToSortBy, XLSortOrder sortOrder = XLSortOrder.Ascending, Boolean matchCase = false, Boolean ignoreBlanks = true); + /// + /// Sort columns in a range. The sorting is done using the values in each column of the range. + /// + /// In what order should columns be sorted + /// + /// + /// This range. IXLRange SortLeftToRight(XLSortOrder sortOrder = XLSortOrder.Ascending, Boolean matchCase = false, Boolean ignoreBlanks = true); - IXLRange SetDataType(XLDataType dataType); - /// /// Clears the contents of this range. /// /// Specify what you want to clear. new IXLRange Clear(XLClearOptions clearOptions = XLClearOptions.All); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeRows RowsUsed(Boolean includeFormats, Func predicate = null); - IXLRangeRows RowsUsed(XLCellsUsedOptions options, Func predicate = null); IXLRangeRows RowsUsed(Func predicate = null); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeColumns ColumnsUsed(Boolean includeFormats, Func predicate = null); - IXLRangeColumns ColumnsUsed(XLCellsUsedOptions options, Func predicate = null); IXLRangeColumns ColumnsUsed(Func predicate = null); diff --git a/ClosedXML/Excel/Ranges/IXLRangeAddress.cs b/ClosedXML/Excel/Ranges/IXLRangeAddress.cs index 1dc6d5073..41b642a20 100644 --- a/ClosedXML/Excel/Ranges/IXLRangeAddress.cs +++ b/ClosedXML/Excel/Ranges/IXLRangeAddress.cs @@ -44,10 +44,11 @@ public interface IXLRangeAddress /// int RowSpan { get; } - IXLWorksheet Worksheet { get; } + IXLWorksheet? Worksheet { get; } /// Allocates the current range address in the internal range repository and returns it - IXLRange AsRange(); + /// Range of the address or null, if the range is not a valid address. + IXLRange? AsRange(); Boolean Contains(IXLAddress address); diff --git a/ClosedXML/Excel/Ranges/IXLRangeBase.cs b/ClosedXML/Excel/Ranges/IXLRangeBase.cs index 29392b904..94d0d3277 100644 --- a/ClosedXML/Excel/Ranges/IXLRangeBase.cs +++ b/ClosedXML/Excel/Ranges/IXLRangeBase.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Globalization; @@ -14,37 +16,45 @@ public interface IXLRangeBase : IXLAddressable IXLWorksheet Worksheet { get; } /// - /// Sets a value to every cell in this range. - /// If the object is an IEnumerable ClosedXML will copy the collection's data into a table starting from each cell. - /// If the object is a range ClosedXML will copy the range starting from each cell. - /// Setting the value to an object (not IEnumerable/range) will call the object's ToString() method. - /// ClosedXML will try to translate it to the corresponding type, if it can't then the value will be left as a string. + /// Sets a value to every cell in this range. + /// + /// Setter will clear a formula, if the cell contains a formula. + /// If the value is a text that starts with a single quote, setter will prefix the value with a single quote through + /// in Excel too and the value of cell is set to to non-quoted text. + /// /// - /// - /// The object containing the value(s) to set. - /// - Object Value { set; } - - /// - /// Sets the type of the cells' data. - /// Changing the data type will cause ClosedXML to covert the current value to the new data type. - /// An exception will be thrown if the current value cannot be converted to the new data type. - /// - /// - /// The type of the cell's data. - /// - /// - XLDataType DataType { set; } + XLCellValue Value { set; } /// /// Sets the cells' formula with A1 references. /// + /// + /// Setter trims the formula and if formula starts with an =, it is removed. If the + /// formula contains unprefixed future function (e.g. CONCAT), it will be correctly + /// prefixed (e.g. _xlfn.CONCAT). + /// /// The formula with A1 references. String FormulaA1 { set; } + /// + /// Create an array formula for all cells in the range. + /// + /// + /// Setter trims the formula and if formula starts with an =, it is removed. If the + /// formula contains unprefixed future function (e.g. CONCAT), it will be correctly + /// prefixed (e.g. _xlfn.CONCAT). + /// + /// When the range overlaps with a table, pivot table, merged cells or partially overlaps another array formula. + String FormulaArrayA1 { set; } + /// /// Sets the cells' formula with R1C1 references. /// + /// + /// Setter trims the formula and if formula starts with an =, it is removed. If the + /// formula contains unprefixed future function (e.g. CONCAT), it will be correctly + /// prefixed (e.g. _xlfn.CONCAT). + /// /// The formula with R1C1 references. String FormulaR1C1 { set; } @@ -58,8 +68,6 @@ public interface IXLRangeBase : IXLAddressable /// Boolean ShareString { set; } - IXLHyperlinks Hyperlinks { get; } - /// /// Returns the collection of cells. /// @@ -67,9 +75,6 @@ public interface IXLRangeBase : IXLAddressable IXLCells Cells(Boolean usedCellsOnly); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCells Cells(Boolean usedCellsOnly, Boolean includeFormats); - IXLCells Cells(Boolean usedCellsOnly, XLCellsUsedOptions options); IXLCells Cells(String cells); @@ -82,19 +87,13 @@ public interface IXLRangeBase : IXLAddressable IXLCells CellsUsed(); /// - /// Returns the collection of cells that have a value. + /// Returns the collection of cells that have a value. /// - /// if set to true will return all cells with a value or a style different than the default. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCells CellsUsed(Boolean includeFormats); - + /// The options to determine whether a cell is used. IXLCells CellsUsed(XLCellsUsedOptions options); IXLCells CellsUsed(Func predicate); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCells CellsUsed(Boolean includeFormats, Func predicate); - IXLCells CellsUsed(XLCellsUsedOptions options, Func predicate); /// @@ -103,7 +102,6 @@ public interface IXLRangeBase : IXLAddressable /// The search text. /// The compare options. /// if set to true search formulae instead of cell values. - /// IXLCells Search(String searchText, CompareOptions compareOptions = CompareOptions.Ordinal, Boolean searchFormulae = false); /// @@ -112,26 +110,24 @@ public interface IXLRangeBase : IXLAddressable IXLCell FirstCell(); /// - /// Returns the first cell with a value of this range. Formats are ignored. + /// Returns the first non-empty cell with a value of this range. Formats are ignored. /// The cell's address is going to be ([First Row with a value], [First Column with a value]) /// IXLCell FirstCellUsed(); /// - /// Returns the first cell with a value of this range. + /// Returns the first non-empty cell with a value of this range. /// - /// The cell's address is going to be ([First Row with a value], [First Column with a value]) - /// if set to true will return all cells with a value or a style different than the default. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCell FirstCellUsed(Boolean includeFormats); - + /// The options to determine whether a cell is used. IXLCell FirstCellUsed(XLCellsUsedOptions options); IXLCell FirstCellUsed(Func predicate); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCell FirstCellUsed(Boolean includeFormats, Func predicate); - + /// + /// Returns the first non-empty cell with a value of this range. + /// + /// The options to determine whether a cell is used. + /// The predicate used to choose cells IXLCell FirstCellUsed(XLCellsUsedOptions options, Func predicate); /// @@ -140,26 +136,19 @@ public interface IXLRangeBase : IXLAddressable IXLCell LastCell(); /// - /// Returns the last cell with a value of this range. Formats are ignored. + /// Returns the last non-empty cell with a value of this range. Formats are ignored. /// The cell's address is going to be ([Last Row with a value], [Last Column with a value]) /// IXLCell LastCellUsed(); /// - /// Returns the last cell with a value of this range. + /// Returns the last non-empty cell with a value of this range. /// - /// The cell's address is going to be ([Last Row with a value], [Last Column with a value]) - /// if set to true will return all cells with a value or a style different than the default. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCell LastCellUsed(Boolean includeFormats); - + /// The options to determine whether a cell is used. IXLCell LastCellUsed(XLCellsUsedOptions options); IXLCell LastCellUsed(Func predicate); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCell LastCellUsed(Boolean includeFormats, Func predicate); - IXLCell LastCellUsed(XLCellsUsedOptions options, Func predicate); /// @@ -210,37 +199,35 @@ public interface IXLRangeBase : IXLAddressable IXLRange Unmerge(); /// - /// Merges this range. - /// The contents and style of the merged cells will be equal to the first cell. + /// Merges this range. Only the top-left cell will have a value, other values will be blank. /// IXLRange Merge(); IXLRange Merge(Boolean checkIntersect); /// - /// Creates a named range out of this range. - /// If the named range exists, it will add this range to that named range. - /// The default scope for the named range is Workbook. + /// Creates/adds this range to workbook scoped . + /// If the named range exists, it will add this range to that named range. /// - /// Name of the range. - IXLRange AddToNamed(String rangeName); + /// Name of the defined name, without sheet. + IXLRange AddToNamed(String name); /// - /// Creates a named range out of this range. - /// If the named range exists, it will add this range to that named range. - /// Name of the range. - /// The scope for the named range. + /// Creates/adds this range to . + /// If the named range exists, it will add this range to that named range. + /// Name of the defined name, without sheet. + /// The scope for the named range. /// - IXLRange AddToNamed(String rangeName, XLScope scope); + IXLRange AddToNamed(String name, XLScope scope); /// - /// Creates a named range out of this range. - /// If the named range exists, it will add this range to that named range. - /// Name of the range. - /// The scope for the named range. - /// The comments for the named range. + /// Creates/adds this range to . + /// If the named range exists, it will add this range to that named range. + /// Name of the defined name, without sheet. + /// The scope for the named range. + /// The comments for the named range. /// - IXLRange AddToNamed(String rangeName, XLScope scope, String comment); + IXLRange AddToNamed(String name, XLScope scope, String comment); /// /// Clears the contents of this range. @@ -253,7 +240,10 @@ public interface IXLRangeBase : IXLAddressable /// void DeleteComments(); - IXLRangeBase SetValue(T value); + /// + /// Set value to all cells in the range. + /// + IXLRangeBase SetValue(XLCellValue value); /// /// Converts this object to a range. @@ -264,9 +254,6 @@ public interface IXLRangeBase : IXLAddressable Boolean IsEmpty(); - [Obsolete("Use the overload with XLCellsUsedOptions")] - Boolean IsEmpty(Boolean includeFormats); - Boolean IsEmpty(XLCellsUsedOptions options); /// @@ -314,7 +301,7 @@ public interface IXLRangeBase : IXLAddressable [Obsolete("Use GetDataValidation() to access the existing rule, or CreateDataValidation() to create a new one.")] IXLDataValidation SetDataValidation(); - + IXLConditionalFormat AddConditionalFormat(); void Select(); @@ -328,7 +315,6 @@ public interface IXLRangeBase : IXLAddressable /// Grows this the current range by the specified number of cells to each side. /// /// The grow count. - /// IXLRangeBase Grow(Int32 growCount); /// @@ -340,7 +326,6 @@ public interface IXLRangeBase : IXLAddressable /// Shrinks the current range by the specified number of cells from each side. /// /// The shrink count. - /// IXLRangeBase Shrink(Int32 shrinkCount); /// @@ -375,7 +360,6 @@ public interface IXLRangeBase : IXLAddressable /// The other range. /// Predicate applied to this range's cells. /// Predicate applied to the other range's cells. - /// IXLCells Difference(IXLRangeBase otherRange, Func thisRangePredicate = null, Func otherRangePredicate = null); /// diff --git a/ClosedXML/Excel/Ranges/IXLRangeColumn.cs b/ClosedXML/Excel/Ranges/IXLRangeColumn.cs index 48094b3bd..158680626 100644 --- a/ClosedXML/Excel/Ranges/IXLRangeColumn.cs +++ b/ClosedXML/Excel/Ranges/IXLRangeColumn.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -95,8 +97,6 @@ public interface IXLRangeColumn : IXLRangeBase IXLRangeColumns Columns(String columns); - IXLRangeColumn SetDataType(XLDataType dataType); - IXLRangeColumn ColumnLeft(); IXLRangeColumn ColumnLeft(Int32 step); @@ -121,9 +121,6 @@ public interface IXLRangeColumn : IXLRangeBase /// Specify what you want to clear. new IXLRangeColumn Clear(XLClearOptions clearOptions = XLClearOptions.All); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeColumn ColumnUsed(Boolean includeFormats); - IXLRangeColumn ColumnUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents); } } diff --git a/ClosedXML/Excel/Ranges/IXLRangeColumns.cs b/ClosedXML/Excel/Ranges/IXLRangeColumns.cs index ad9711850..66e82b061 100644 --- a/ClosedXML/Excel/Ranges/IXLRangeColumns.cs +++ b/ClosedXML/Excel/Ranges/IXLRangeColumns.cs @@ -1,4 +1,5 @@ -using System; +#nullable disable + using System.Collections.Generic; namespace ClosedXML.Excel @@ -24,10 +25,7 @@ public interface IXLRangeColumns : IEnumerable /// /// Returns the collection of cells that have a value. /// - /// if set to true will return all cells with a value or a style different than the default. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCells CellsUsed(Boolean includeFormats); - + /// The options to determine whether a cell is used. IXLCells CellsUsed(XLCellsUsedOptions options); /// @@ -37,8 +35,6 @@ public interface IXLRangeColumns : IEnumerable IXLStyle Style { get; set; } - IXLRangeColumns SetDataType(XLDataType dataType); - /// /// Clears the contents of these columns. /// diff --git a/ClosedXML/Excel/Ranges/IXLRangeRow.cs b/ClosedXML/Excel/Ranges/IXLRangeRow.cs index d709a8804..84584f72a 100644 --- a/ClosedXML/Excel/Ranges/IXLRangeRow.cs +++ b/ClosedXML/Excel/Ranges/IXLRangeRow.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -105,8 +107,6 @@ public interface IXLRangeRow : IXLRangeBase IXLRangeRows Rows(String rows); - IXLRangeRow SetDataType(XLDataType dataType); - IXLRangeRow RowAbove(); IXLRangeRow RowAbove(Int32 step); @@ -123,9 +123,6 @@ public interface IXLRangeRow : IXLRangeBase /// Specify what you want to clear. new IXLRangeRow Clear(XLClearOptions clearOptions = XLClearOptions.All); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeRow RowUsed(Boolean includeFormats); - IXLRangeRow RowUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents); } } diff --git a/ClosedXML/Excel/Ranges/IXLRangeRows.cs b/ClosedXML/Excel/Ranges/IXLRangeRows.cs index 280fc8de6..bdc9c4673 100644 --- a/ClosedXML/Excel/Ranges/IXLRangeRows.cs +++ b/ClosedXML/Excel/Ranges/IXLRangeRows.cs @@ -1,4 +1,5 @@ -using System; +#nullable disable + using System.Collections.Generic; namespace ClosedXML.Excel @@ -21,13 +22,11 @@ public interface IXLRangeRows : IEnumerable /// IXLCells CellsUsed(); + /// /// Returns the collection of cells that have a value. /// - /// if set to true will return all cells with a value or a style different than the default. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCells CellsUsed(Boolean includeFormats); - + /// The options to determine whether a cell is used. IXLCells CellsUsed(XLCellsUsedOptions options); /// @@ -37,8 +36,6 @@ public interface IXLRangeRows : IEnumerable IXLStyle Style { get; set; } - IXLRangeRows SetDataType(XLDataType dataType); - /// /// Clears the contents of these rows. /// diff --git a/ClosedXML/Excel/Ranges/IXLRanges.cs b/ClosedXML/Excel/Ranges/IXLRanges.cs index e8c318d4f..61d0913b0 100644 --- a/ClosedXML/Excel/Ranges/IXLRanges.cs +++ b/ClosedXML/Excel/Ranges/IXLRanges.cs @@ -24,9 +24,9 @@ public interface IXLRanges : IEnumerable /// /// Criteria to filter ranges. Only those ranges that satisfy the criteria will be removed. /// Null means the entire collection should be cleared. - /// Specify whether or not should removed ranges be unsubscribed from + /// Specify whether or not should removed ranges be unsubscribed from /// row/column shifting events. Until ranges are unsubscribed they cannot be collected by GC. - void RemoveAll(Predicate match = null, bool releaseEventHandlers = true); + void RemoveAll(Predicate? match = null, bool releaseEventHandlers = true); Int32 Count { get; } @@ -50,7 +50,6 @@ public interface IXLRanges : IEnumerable /// IEnumerable GetIntersectedRanges(IXLCell cell); - IXLStyle Style { get; set; } /// @@ -88,17 +87,15 @@ public interface IXLRanges : IEnumerable /// /// Sets the cells' value. - /// If the object is an IEnumerable ClosedXML will copy the collection's data into a table starting from each cell. - /// If the object is a range ClosedXML will copy the range starting from each cell. - /// Setting the value to an object (not IEnumerable/range) will call the object's ToString() method. - /// ClosedXML will try to translate it to the corresponding type, if it can't then the value will be left as a string. + /// + /// Setter will clear a formula, if the cell contains a formula. + /// If the value is a text that starts with a single quote, setter will prefix the value with a single quote through + /// in Excel too and the value of cell is set to to non-quoted text. + /// /// - /// - /// The object containing the value(s) to set. - /// - Object Value { set; } + XLCellValue Value { set; } - IXLRanges SetValue(T value); + IXLRanges SetValue(XLCellValue value); /// /// Returns the collection of cells. @@ -113,14 +110,9 @@ public interface IXLRanges : IEnumerable /// /// Returns the collection of cells that have a value. /// - /// if set to true will return all cells with a value or a style different than the default. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCells CellsUsed(Boolean includeFormats); - + /// The options to determine whether a cell is used. IXLCells CellsUsed(XLCellsUsedOptions options); - IXLRanges SetDataType(XLDataType dataType); - /// /// Clears the contents of these ranges. /// diff --git a/ClosedXML/Excel/Ranges/Index/IXLRangeIndex.cs b/ClosedXML/Excel/Ranges/Index/IXLRangeIndex.cs index ecc0e88b7..23b43ffb0 100644 --- a/ClosedXML/Excel/Ranges/Index/IXLRangeIndex.cs +++ b/ClosedXML/Excel/Ranges/Index/IXLRangeIndex.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace ClosedXML.Excel.Ranges.Index @@ -12,7 +12,7 @@ internal interface IXLRangeIndex bool Remove(IXLRangeAddress rangeAddress); - int RemoveAll(Predicate predicate = null); + int RemoveAll(Predicate? predicate = null); IEnumerable GetIntersectedRanges(XLRangeAddress rangeAddress); @@ -32,7 +32,7 @@ internal interface IXLRangeIndex : IXLRangeIndex { bool Add(T range); - int RemoveAll(Predicate predicate = null); + int RemoveAll(Predicate? predicate = null); new IEnumerable GetIntersectedRanges(XLRangeAddress rangeAddress); diff --git a/ClosedXML/Excel/Ranges/Index/XLRangeIndex.cs b/ClosedXML/Excel/Ranges/Index/XLRangeIndex.cs index fd920684b..84c5391fb 100644 --- a/ClosedXML/Excel/Ranges/Index/XLRangeIndex.cs +++ b/ClosedXML/Excel/Ranges/Index/XLRangeIndex.cs @@ -1,4 +1,4 @@ -using ClosedXML.Excel.Patterns; +using ClosedXML.Excel.Patterns; using System; using System.Collections.Generic; using System.Linq; @@ -16,7 +16,7 @@ public XLRangeIndex(IXLWorksheet worksheet) { _worksheet = worksheet; _rangeList = new List(); - (_worksheet as XLWorksheet).RegisterRangeIndex(this); + ((XLWorksheet)_worksheet).RegisterRangeIndex(this); } #endregion Public Constructors @@ -48,7 +48,7 @@ public bool Add(IXLAddressable range) if (_quadTree == null) InitializeTree(); - return _quadTree.Add(range); + return _quadTree!.Add(range); } public bool Contains(in XLAddress address) @@ -126,7 +126,7 @@ public bool Remove(IXLRangeAddress rangeAddress) return _quadTree.Remove(rangeAddress); } - public int RemoveAll(Predicate predicate = null) + public int RemoveAll(Predicate? predicate = null) { predicate = predicate ?? (_ => true); @@ -156,13 +156,13 @@ public int RemoveAll(Predicate predicate = null) private readonly IXLWorksheet _worksheet; private int _count = 0; - protected Quadrant _quadTree; + protected Quadrant? _quadTree; #endregion Private Fields #region Private Methods - private void CheckWorksheet(IXLWorksheet worksheet) + private void CheckWorksheet(IXLWorksheet? worksheet) { if (worksheet != _worksheet) throw new ArgumentException("Range belongs to a different worksheet"); @@ -198,7 +198,7 @@ public bool Add(T range) return base.Add(range); } - public int RemoveAll(Predicate predicate) + public int RemoveAll(Predicate? predicate) { predicate = predicate ?? (_ => true); diff --git a/ClosedXML/Excel/Ranges/Sort/IXLSortElement.cs b/ClosedXML/Excel/Ranges/Sort/IXLSortElement.cs index e7ec5d908..fec4c6132 100644 --- a/ClosedXML/Excel/Ranges/Sort/IXLSortElement.cs +++ b/ClosedXML/Excel/Ranges/Sort/IXLSortElement.cs @@ -6,9 +6,29 @@ public enum XLSortOrder { Ascending, Descending } public enum XLSortOrientation { TopToBottom, LeftToRight } public interface IXLSortElement { - Int32 ElementNumber { get; set; } - XLSortOrder SortOrder { get; set; } - Boolean IgnoreBlanks { get; set; } - Boolean MatchCase { get; set; } + /// + /// Column or row number whose values will be used for sorting. + /// + Int32 ElementNumber { get; } + + /// + /// Sorting order. + /// + XLSortOrder SortOrder { get; } + + /// + /// When true (recommended, matches Excel behavior), blank cell values are always + /// sorted at the end regardless of sorting order. When false, blank values are + /// considered empty strings and are sorted among other cell values with a type + /// . + /// + Boolean IgnoreBlanks { get; } + + /// + /// When cell value is a , should sorting be case insensitive + /// (false, Excel default behavior) or case sensitive (true). Doesn't affect + /// other cell value types. + /// + Boolean MatchCase { get; } } } diff --git a/ClosedXML/Excel/Ranges/Sort/IXLSortElements.cs b/ClosedXML/Excel/Ranges/Sort/IXLSortElements.cs index 24f2fc99e..c85825a14 100644 --- a/ClosedXML/Excel/Ranges/Sort/IXLSortElements.cs +++ b/ClosedXML/Excel/Ranges/Sort/IXLSortElements.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/Ranges/Sort/XLCellValueSortComparer.cs b/ClosedXML/Excel/Ranges/Sort/XLCellValueSortComparer.cs new file mode 100644 index 000000000..ac37513bb --- /dev/null +++ b/ClosedXML/Excel/Ranges/Sort/XLCellValueSortComparer.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; + +namespace ClosedXML.Excel +{ + /// + /// A comparator of two cell value. It uses semantic of a sort feature in Excel: + /// + /// Order by type is number, text, logical, error, blank. + /// Errors are not sorted. + /// Blanks are always last, both in ascending and descending order. + /// Stable sort. + /// + /// + internal class XLCellValueSortComparer : IComparer + { + private readonly bool _isAscending; + private readonly bool _interpretBlankAsString; + private readonly StringComparer _comparer; + + public XLCellValueSortComparer(IXLSortElement sortElement) + { + // Detecting current culture is expensive, when called enough time. Keep pre-calculated comparer. + _comparer = sortElement.MatchCase ? StringComparer.CurrentCulture : StringComparer.CurrentCultureIgnoreCase; + _isAscending = sortElement.SortOrder == XLSortOrder.Ascending; + _interpretBlankAsString = !sortElement.IgnoreBlanks; + } + + public int Compare(XLCellValue x, XLCellValue y) + { + var xTypeOrder = GetTypeOrder(x, _isAscending); + var yTypeOrder = GetTypeOrder(y, _isAscending); + if (xTypeOrder != yTypeOrder) + return xTypeOrder - yTypeOrder; + + return _isAscending ? CompareAsc(x, y) : -CompareAsc(x, y); + } + + private int GetTypeOrder(XLCellValue x, bool asc) + { + // Blank is always last, both for asc and desc. + if (!_interpretBlankAsString && x.Type == XLDataType.Blank) + return 4; + + var ascOrder = x.Type switch + { + XLDataType.Number => 0, + XLDataType.DateTime => 0, + XLDataType.TimeSpan => 0, + XLDataType.Text => 1, + XLDataType.Blank => 1, // If we get here, the blank is interpreted as a text. + XLDataType.Boolean => 2, + XLDataType.Error => 3, + _ => throw new NotSupportedException() + }; + return asc ? ascOrder : -ascOrder; + } + + private int CompareAsc(XLCellValue x, XLCellValue y) + { + x = _interpretBlankAsString && x.Type == XLDataType.Blank ? string.Empty : x; + y = _interpretBlankAsString && y.Type == XLDataType.Blank ? string.Empty : y; + switch (x.Type) + { + case XLDataType.Blank: + return 0; // Blanks are not sorted. That doesn't really affect content, but cells still contain other info, e.g. style. + + case XLDataType.Text: + return _comparer.Compare(x.GetText(), y.GetText()); + + case XLDataType.Boolean: + return x.GetBoolean().CompareTo(y.GetBoolean()); + + case XLDataType.Error: + return 0; // Errors are never sorted + + case XLDataType.Number: + case XLDataType.DateTime: + case XLDataType.TimeSpan: + return x.GetUnifiedNumber().CompareTo(y.GetUnifiedNumber()); + + default: + throw new NotSupportedException(); + } + } + } +} diff --git a/ClosedXML/Excel/Ranges/Sort/XLRangeColumnsSortComparer.cs b/ClosedXML/Excel/Ranges/Sort/XLRangeColumnsSortComparer.cs new file mode 100644 index 000000000..1722a355c --- /dev/null +++ b/ClosedXML/Excel/Ranges/Sort/XLRangeColumnsSortComparer.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel +{ + internal class XLRangeColumnsSortComparer : IComparer + { + private readonly List<(int RowNumber, XLCellValueSortComparer Comparer)> _rowComparers; + private readonly ValueSlice _valueSlice; + + internal XLRangeColumnsSortComparer(XLWorksheet sheet, XLSheetRange sortRange, IXLSortElements sortRows) + { + if (!sortRows.Any()) + throw new ArgumentException("Empty sort specification."); + + if (sortRange.Width < sortRows.Max(x => x.ElementNumber)) + throw new ArgumentException("Range has fewer columns that sort specification."); + + _valueSlice = sheet.Internals.CellsCollection.ValueSlice; + _rowComparers = sortRows.Select(se => (se.ElementNumber + sortRange.TopRow - 1, new XLCellValueSortComparer(se))).ToList(); + } + + public int Compare(int colNumber1, int colNumber2) + { + foreach (var (rowNumber, comparer) in _rowComparers) + { + var col1 = _valueSlice.GetCellValue(new XLSheetPoint(rowNumber, colNumber1)); + var col2 = _valueSlice.GetCellValue(new XLSheetPoint(rowNumber, colNumber2)); + var comparison = comparer.Compare(col1, col2); + if (comparison != 0) + return comparison; + } + + // Workaround for stable sort, see XLRangeRowsSortComparer. + return colNumber1 - colNumber2; + } + } +} diff --git a/ClosedXML/Excel/Ranges/Sort/XLRangeRowsSortComparer.cs b/ClosedXML/Excel/Ranges/Sort/XLRangeRowsSortComparer.cs new file mode 100644 index 000000000..77659f607 --- /dev/null +++ b/ClosedXML/Excel/Ranges/Sort/XLRangeRowsSortComparer.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel +{ + /// + /// A comparer of rows in a range. It uses semantic of a sort feature in Excel. + /// + /// + /// The comparer should work separate from data, but it would necessitate to sort over + /// . That would require to not only instantiate a new object for each + /// sorted row, but since , it would also be be tracked in range + /// repository, slowing each subsequent operation. To improve performance, comparer has + /// reference to underlaying data and compares row numbers that can be stores in a single + /// allocated array of indexes. + /// + internal class XLRangeRowsSortComparer : IComparer + { + private readonly List<(int ColumnNumber, XLCellValueSortComparer Comparer)> _columnComparers; + private readonly ValueSlice _valueSlice; + + internal XLRangeRowsSortComparer(XLWorksheet sheet, XLSheetRange sortRange, IXLSortElements sortColumns) + { + if (!sortColumns.Any()) + throw new ArgumentException("Empty sort specification."); + + if (sortRange.Width < sortColumns.Max(x => x.ElementNumber)) + throw new ArgumentException("Range has fewer columns that sort specification."); + + _valueSlice = sheet.Internals.CellsCollection.ValueSlice; + _columnComparers = sortColumns.Select(se => (se.ElementNumber + sortRange.LeftColumn - 1, new XLCellValueSortComparer(se))).ToList(); + } + + public int Compare(int rowNumber1, int rowNumber2) + { + foreach (var (columnNumber, comparer) in _columnComparers) + { + var row1 = _valueSlice.GetCellValue(new XLSheetPoint(rowNumber1, columnNumber)); + var row2 = _valueSlice.GetCellValue(new XLSheetPoint(rowNumber2, columnNumber)); + var comparison = comparer.Compare(row1, row2); + if (comparison != 0) + return comparison; + } + + // Row sort should be stable, because otherwise we could randomly switch cells + // with different formats on subsequent sorts. BCL doesn't support in-place + // stable sort (Array/List.Sort) directly, only LINQ does it (thus extra copy). + // Note that stable sort has worse worst case O(N*log(N)^2). + // + // As a workaround for stable sort, if all values look same, use the order of rows. + return rowNumber1 - rowNumber2; + } + } +} diff --git a/ClosedXML/Excel/Ranges/Sort/XLSortElement.cs b/ClosedXML/Excel/Ranges/Sort/XLSortElement.cs index bb801d73a..2a11fa2bc 100644 --- a/ClosedXML/Excel/Ranges/Sort/XLSortElement.cs +++ b/ClosedXML/Excel/Ranges/Sort/XLSortElement.cs @@ -2,11 +2,9 @@ namespace ClosedXML.Excel { - internal class XLSortElement: IXLSortElement - { - public Int32 ElementNumber { get; set; } - public XLSortOrder SortOrder { get; set; } - public Boolean IgnoreBlanks { get; set; } - public Boolean MatchCase { get; set; } - } + internal record XLSortElement( + Int32 ElementNumber, + XLSortOrder SortOrder, + Boolean IgnoreBlanks, + Boolean MatchCase) : IXLSortElement; } diff --git a/ClosedXML/Excel/Ranges/Sort/XLSortElements.cs b/ClosedXML/Excel/Ranges/Sort/XLSortElements.cs index 7aa7c8b57..ef4afebc2 100644 --- a/ClosedXML/Excel/Ranges/Sort/XLSortElements.cs +++ b/ClosedXML/Excel/Ranges/Sort/XLSortElements.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; @@ -20,13 +22,11 @@ public void Add(Int32 elementNumber, XLSortOrder sortOrder, Boolean ignoreBlanks } public void Add(Int32 elementNumber, XLSortOrder sortOrder, Boolean ignoreBlanks, Boolean matchCase) { - elements.Add(new XLSortElement() - { - ElementNumber = elementNumber, - SortOrder = sortOrder, - IgnoreBlanks = ignoreBlanks, - MatchCase = matchCase - }); + elements.Add(new XLSortElement( + elementNumber, + sortOrder, + ignoreBlanks, + matchCase)); } public void Add(String elementNumber) @@ -43,13 +43,11 @@ public void Add(String elementNumber, XLSortOrder sortOrder, Boolean ignoreBlank } public void Add(String elementNumber, XLSortOrder sortOrder, Boolean ignoreBlanks, Boolean matchCase) { - elements.Add(new XLSortElement() - { - ElementNumber = XLHelper.GetColumnNumberFromLetter(elementNumber), - SortOrder = sortOrder, - IgnoreBlanks = ignoreBlanks, - MatchCase = matchCase - }); + elements.Add(new XLSortElement( + XLHelper.GetColumnNumberFromLetter(elementNumber), + sortOrder, + ignoreBlanks, + matchCase)); } public IEnumerator GetEnumerator() @@ -71,5 +69,7 @@ public void Remove(Int32 elementNumber) { elements.RemoveAt(elementNumber - 1); } + + internal void AddRange(IEnumerable sortElements) => elements.AddRange(sortElements); } } diff --git a/ClosedXML/Excel/Ranges/XLRange.cs b/ClosedXML/Excel/Ranges/XLRange.cs index 9551abbe4..67876476b 100644 --- a/ClosedXML/Excel/Ranges/XLRange.cs +++ b/ClosedXML/Excel/Ranges/XLRange.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -150,6 +152,8 @@ IXLRange IXLRange.Range(int firstCellRow, int firstCellColumn, int lastCellRow, return Range(firstCellRow, firstCellColumn, lastCellRow, lastCellColumn); } + IXLRanges IXLRange.Ranges(string ranges) => Ranges(ranges); + public IXLRangeRows Rows(Func predicate = null) { var retVal = new XLRangeRows(); @@ -285,9 +289,9 @@ public IXLTable CreateTable(String name, Boolean setAutofilter) return Worksheet.Table(this, name, true, setAutofilter); } - public new IXLRange CopyTo(IXLCell target) + public IXLRange CopyTo(IXLCell target) { - base.CopyTo(target); + base.CopyTo((XLCell)target); Int32 lastRowNumber = target.Address.RowNumber + RowCount() - 1; if (lastRowNumber > XLHelper.MaxRowNumber) @@ -319,12 +323,6 @@ public IXLTable CreateTable(String name, Boolean setAutofilter) lastColumnNumber); } - public IXLRange SetDataType(XLDataType dataType) - { - DataType = dataType; - return this; - } - public new IXLRange Sort() { return base.Sort().AsRange(); @@ -407,15 +405,6 @@ internal XLRangeColumn FirstColumnUsed(Func predicate = return FirstColumnUsed(XLCellsUsedOptions.AllContents, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeColumn IXLRange.FirstColumnUsed(Boolean includeFormats, Func predicate) - { - return FirstColumnUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, - predicate); - } - IXLRangeColumn IXLRange.FirstColumnUsed(XLCellsUsedOptions options, Func predicate) { return FirstColumnUsed(options, predicate); @@ -426,10 +415,7 @@ internal XLRangeColumn FirstColumnUsed(XLCellsUsedOptions options, Func predicate = return LastColumnUsed(XLCellsUsedOptions.AllContents, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeColumn IXLRange.LastColumnUsed(Boolean includeFormats, Func predicate) - { - return LastColumnUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, - predicate); - } - IXLRangeColumn IXLRange.LastColumnUsed(XLCellsUsedOptions options, Func predicate) { return LastColumnUsed(options, predicate); @@ -475,10 +452,7 @@ internal XLRangeColumn LastColumnUsed(XLCellsUsedOptions options, Func predicate = null) return FirstRowUsed(XLCellsUsedOptions.AllContents, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeRow IXLRange.FirstRowUsed(Boolean includeFormats, Func predicate) - { - return FirstRowUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, predicate); - } - IXLRangeRow IXLRange.FirstRowUsed(XLCellsUsedOptions options, Func predicate) { return FirstRowUsed(options, predicate); @@ -563,13 +529,7 @@ internal XLRangeRow FirstRowUsed(XLCellsUsedOptions options, Func predicate = null) return LastRowUsed(XLCellsUsedOptions.AllContents, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeRow IXLRange.LastRowUsed(Boolean includeFormats, Func predicate) - { - return LastRowUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, predicate); - } - IXLRangeRow IXLRange.LastRowUsed(XLCellsUsedOptions options, Func predicate) { return LastRowUsed(options, predicate); @@ -613,11 +565,7 @@ internal XLRangeRow LastRowUsed(XLCellsUsedOptions options, Func predicate) - { - return RowsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, predicate); - } - IXLRangeRows IXLRange.RowsUsed(XLCellsUsedOptions options, Func predicate) { return RowsUsed(options, predicate); @@ -671,13 +611,6 @@ internal XLRangeRows RowsUsed(Func predicate = null) return RowsUsed(XLCellsUsedOptions.AllContents, predicate); } - IXLRangeColumns IXLRange.ColumnsUsed(Boolean includeFormats, Func predicate) - { - return ColumnsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, predicate); - } - IXLRangeColumns IXLRange.ColumnsUsed(XLCellsUsedOptions options, Func predicate) { return ColumnsUsed(options, predicate); @@ -805,32 +738,17 @@ internal IEnumerable Split(IXLRangeAddress anotherRange, bool includeIn private void TransposeRange(int squareSide) { - var cellsToInsert = new Dictionary(); - var cellsToDelete = new List(); - var rngToTranspose = Worksheet.Range( - RangeAddress.FirstAddress.RowNumber, - RangeAddress.FirstAddress.ColumnNumber, - RangeAddress.FirstAddress.RowNumber + squareSide - 1, - RangeAddress.FirstAddress.ColumnNumber + squareSide - 1); - - Int32 roCount = rngToTranspose.RowCount(); - Int32 coCount = rngToTranspose.ColumnCount(); - for (Int32 ro = 1; ro <= roCount; ro++) + var rowOffset = RangeAddress.FirstAddress.RowNumber - 1; + var colOffset = RangeAddress.FirstAddress.ColumnNumber - 1; + for (var row = 1; row <= squareSide; ++row) { - for (Int32 co = 1; co <= coCount; co++) + for (var col = row + 1; col <= squareSide; ++col) { - var oldCell = rngToTranspose.Cell(ro, co); - var newKey = rngToTranspose.Cell(co, ro).Address; - // new XLAddress(Worksheet, c.Address.ColumnNumber, c.Address.RowNumber); - var newCell = new XLCell(Worksheet, newKey, oldCell.StyleValue); - newCell.CopyFrom(oldCell, XLCellCopyOptions.All); - cellsToInsert.Add(new XLSheetPoint(newKey.RowNumber, newKey.ColumnNumber), newCell); - cellsToDelete.Add(new XLSheetPoint(oldCell.Address.RowNumber, oldCell.Address.ColumnNumber)); + var oldAddress = new XLSheetPoint(row + rowOffset, col + colOffset); + var newAddress = new XLSheetPoint(col + colOffset, row + rowOffset); + Worksheet.Internals.CellsCollection.SwapCellsContent(oldAddress, newAddress); } } - - cellsToDelete.ForEach(c => Worksheet.Internals.CellsCollection.Remove(c)); - cellsToInsert.ForEach(c => Worksheet.Internals.CellsCollection.Add(c.Key, c.Value)); } private void TransposeMerged(Int32 squareSide) diff --git a/ClosedXML/Excel/Ranges/XLRangeAddress.cs b/ClosedXML/Excel/Ranges/XLRangeAddress.cs index 460751ca7..2f405e9f8 100644 --- a/ClosedXML/Excel/Ranges/XLRangeAddress.cs +++ b/ClosedXML/Excel/Ranges/XLRangeAddress.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; namespace ClosedXML.Excel { @@ -28,6 +27,14 @@ public static XLRangeAddress EntireRow(XLWorksheet worksheet, int row) new XLAddress(-1, -1, fixedRow: true, fixedColumn: true) ); + internal static XLRangeAddress FromSheetRange(XLWorksheet? worksheet, XLSheetRange range) + { + return new XLRangeAddress( + new XLAddress(worksheet, range.FirstPoint.Row, range.FirstPoint.Column, fixedRow: false, fixedColumn: false), + new XLAddress(range.LastPoint.Row, range.LastPoint.Column, fixedRow: false, fixedColumn: false) + ); + } + #endregion Static members #region Constructor @@ -39,10 +46,10 @@ public XLRangeAddress(XLAddress firstAddress, XLAddress lastAddress) : this() LastAddress = lastAddress; } - public XLRangeAddress(XLWorksheet worksheet, String rangeAddress) : this() + public XLRangeAddress(XLWorksheet? worksheet, String rangeAddress) : this() { - string addressToUse = rangeAddress.Contains("!") - ? rangeAddress.Substring(rangeAddress.LastIndexOf("!") + 1) + string addressToUse = rangeAddress.Contains('!') + ? rangeAddress.Substring(rangeAddress.LastIndexOf('!') + 1) : rangeAddress; string firstPart; @@ -87,13 +94,13 @@ public XLRangeAddress(XLWorksheet worksheet, String rangeAddress) : this() #region Public properties - public XLWorksheet Worksheet { get; } + public XLWorksheet? Worksheet { get; } public XLAddress FirstAddress { get; } public XLAddress LastAddress { get; } - IXLWorksheet IXLRangeAddress.Worksheet + IXLWorksheet? IXLRangeAddress.Worksheet { get { return Worksheet; } } @@ -125,6 +132,8 @@ public int ColumnSpan public int NumberOfCells => ColumnSpan * RowSpan; + internal long Size => ColumnSpan * (long)RowSpan; + public int RowSpan { get @@ -149,7 +158,6 @@ public int RowSpan /// Lead a range address to a normal form - when points to the top-left address and /// points to the bottom-right address. /// - /// public XLRangeAddress Normalize() { if (FirstAddress.RowNumber <= LastAddress.RowNumber && @@ -216,6 +224,21 @@ public bool Contains(IXLAddress address) return Contains(in xlAddress); } + /// + /// Does this range contains whole another range? + /// + public bool ContainsWhole(IXLRangeAddress range) + { + if (!range.IsValid) + return false; + + return + range.FirstAddress.ColumnNumber >= FirstAddress.ColumnNumber && + range.FirstAddress.RowNumber >= FirstAddress.RowNumber && + range.LastAddress.ColumnNumber <= LastAddress.ColumnNumber && + range.LastAddress.RowNumber <= LastAddress.RowNumber; + } + internal IXLRangeAddress WithoutWorksheet() { return new XLRangeAddress( @@ -267,7 +290,7 @@ public String ToStringRelative(Boolean includeSheet) if (includeSheet || WorksheetIsDeleted) return String.Concat( - WorksheetIsDeleted ? "#REF" : Worksheet.Name.EscapeSheetName(), + WorksheetIsDeleted ? "#REF" : Worksheet!.Name.EscapeSheetName(), "!", address); return address; @@ -297,7 +320,7 @@ public String ToStringFixed(XLReferenceStyle referenceStyle, Boolean includeShee if (includeSheet || WorksheetIsDeleted) return String.Concat( - WorksheetIsDeleted ? "#REF" : Worksheet.Name.EscapeSheetName(), + WorksheetIsDeleted ? "#REF" : Worksheet!.Name.EscapeSheetName(), "!", address); return address; @@ -364,7 +387,7 @@ public override bool Equals(object obj) var address = (XLRangeAddress)obj; return FirstAddress.Equals(address.FirstAddress) && LastAddress.Equals(address.LastAddress) && - EqualityComparer.Default.Equals(Worksheet, address.Worksheet); + EqualityComparer.Default.Equals(Worksheet, address.Worksheet); } public override int GetHashCode() @@ -372,7 +395,7 @@ public override int GetHashCode() var hashCode = -778064135; hashCode = hashCode * -1521134295 + FirstAddress.GetHashCode(); hashCode = hashCode * -1521134295 + LastAddress.GetHashCode(); - hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Worksheet); + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Worksheet); return hashCode; } @@ -453,7 +476,7 @@ public IXLRangeAddress Intersection(IXLRangeAddress otherRangeAddress) internal XLRangeAddress Intersection(in XLRangeAddress otherRangeAddress) { - if (!this.Worksheet.Equals(otherRangeAddress.Worksheet)) + if (!Equals(Worksheet, otherRangeAddress.Worksheet)) throw new ArgumentOutOfRangeException(nameof(otherRangeAddress), "The other range address is on a different worksheet"); var thisRangeAddressNormalized = this.Normalize(); @@ -474,7 +497,7 @@ internal XLRangeAddress Intersection(in XLRangeAddress otherRangeAddress) ); } - public IXLRange AsRange() + public IXLRange? AsRange() { if (this.Worksheet == null) throw new InvalidOperationException("The worksheet of the current range address has not been set."); diff --git a/ClosedXML/Excel/Ranges/XLRangeBase.cs b/ClosedXML/Excel/Ranges/XLRangeBase.cs index bbea01f38..32085fc5f 100644 --- a/ClosedXML/Excel/Ranges/XLRangeBase.cs +++ b/ClosedXML/Excel/Ranges/XLRangeBase.cs @@ -1,9 +1,11 @@ +#nullable disable + using System; -using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; +using ClosedXML.Excel.CalcEngine.Visitors; namespace ClosedXML.Excel { @@ -64,6 +66,17 @@ public XLWorksheet Worksheet get { return RangeAddress.Worksheet; } } + internal XLSheetRange SheetRange + { + get + { + if (!RangeAddress.IsValid) + throw new InvalidOperationException("Range address is invalid."); + + return XLSheetRange.FromRangeAddress(RangeAddress); + } + } + public IXLDataValidation CreateDataValidation() { var newRange = AsRange(); @@ -102,6 +115,44 @@ public String FormulaA1 } } + public String FormulaArrayA1 + { + set + { + var range = XLSheetRange.FromRangeAddress(RangeAddress); + if (Worksheet.MergedRanges.Any(mr => mr.Intersects(this))) + throw new InvalidOperationException("Can't create array function over a merged range."); + + if (Worksheet.Tables.Any(t => t.Intersects(this))) + throw new InvalidOperationException("Can't create array function over a table."); + + if (Cells(false).Any(c => c.HasArrayFormula && !RangeAddress.ContainsWhole(c.FormulaReference))) + throw new InvalidOperationException("Can't create array function that partially covers another array function."); + + var formula = value.TrimFormulaEqual(); + var fixedFunctionsFormula = FormulaTransformation.FixFutureFunctions(formula, Worksheet.Name, SheetRange.FirstPoint); + var arrayFormula = XLCellFormula.Array(fixedFunctionsFormula, range, false); + + var formulaSlice = Worksheet.Internals.CellsCollection.FormulaSlice; + formulaSlice.SetArray(range, arrayFormula); + + // If formula evaluates to a text, it is stored directly in a worksheet, not in SST. Thus + // when the switch to formula happens, disable shared string and enable when formula is removed. + var valueSlice = Worksheet.Internals.CellsCollection.ValueSlice; + for (var row = range.TopRow; row <= range.BottomRow; ++row) + { + for (var col = range.LeftColumn; col <= range.RightColumn; ++col) + { + valueSlice.SetShareString(new XLSheetPoint(row, col), false); + } + } + + // Formula is shared across all cells, so it's enough to invalidate master cell + var masterCell = FirstCell(); + masterCell.InvalidateFormula(); + } + } + public String FormulaR1C1 { set @@ -119,29 +170,11 @@ public Boolean ShareString set { Cells().ForEach(c => c.ShareString = value); } } - public IXLHyperlinks Hyperlinks - { - get - { - var hyperlinks = new XLHyperlinks(); - var hls = from hl in Worksheet.Hyperlinks - where RangeAddress.Contains(hl.Cell.Address) - select hl; - hls.ForEach(hyperlinks.Add); - return hyperlinks; - } - } - - public Object Value + public XLCellValue Value { set { Cells().ForEach(c => c.Value = value); } } - public XLDataType DataType - { - set { Cells().ForEach(c => c.DataType = value); } - } - #endregion IXLRangeBase Members #region IXLStylized Members @@ -164,21 +197,20 @@ protected override IEnumerable Children } } - public override IEnumerable Styles - { - get - { - foreach (IXLCell cell in Cells()) - yield return cell.Style; - } - } - #endregion IXLStylized Members #endregion Public properties #region IXLRangeBase Members + IXLCells IXLRangeBase.Cells(String cells) => Cells(cells); + + IXLCells IXLRangeBase.Cells(Boolean usedCellsOnly) => Cells(usedCellsOnly); + + IXLCells IXLRangeBase.Cells(Boolean usedCellsOnly, XLCellsUsedOptions options) => Cells(usedCellsOnly, options); + + IXLCells IXLRangeBase.CellsUsed() => CellsUsed(); + IXLCell IXLRangeBase.FirstCell() { return FirstCell(); @@ -189,18 +221,10 @@ IXLCell IXLRangeBase.LastCell() return LastCell(); } - [Obsolete("Use the overload with XLCellsUsedOptions")] IXLCell IXLRangeBase.FirstCellUsed() { - return FirstCellUsed(false); - } - - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCell IXLRangeBase.FirstCellUsed(bool includeFormats) - { - return FirstCellUsed(includeFormats); + return FirstCellUsed(XLCellsUsedOptions.AllContents); } - IXLCell IXLRangeBase.FirstCellUsed(XLCellsUsedOptions options) { return FirstCellUsed(options, null); @@ -211,29 +235,15 @@ IXLCell IXLRangeBase.FirstCellUsed(Func predicate) return FirstCellUsed(predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCell IXLRangeBase.FirstCellUsed(Boolean includeFormats, Func predicate) - { - return FirstCellUsed(includeFormats, predicate); - } - IXLCell IXLRangeBase.FirstCellUsed(XLCellsUsedOptions options, Func predicate) { return FirstCellUsed(options, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] IXLCell IXLRangeBase.LastCellUsed() { - return LastCellUsed(false); - } - - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCell IXLRangeBase.LastCellUsed(bool includeFormats) - { - return LastCellUsed(includeFormats); + return LastCellUsed(XLCellsUsedOptions.AllContents); } - IXLCell IXLRangeBase.LastCellUsed(XLCellsUsedOptions options) { return LastCellUsed(options, null); @@ -244,12 +254,6 @@ IXLCell IXLRangeBase.LastCellUsed(Func predicate) return LastCellUsed(predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCell IXLRangeBase.LastCellUsed(Boolean includeFormats, Func predicate) - { - return LastCellUsed(includeFormats, predicate); - } - IXLCell IXLRangeBase.LastCellUsed(XLCellsUsedOptions options, Func predicate) { return LastCellUsed(options, predicate); @@ -260,27 +264,18 @@ public virtual IXLCells Cells() return Cells(false); } - public virtual IXLCells Cells(Boolean usedCellsOnly) + public virtual XLCells Cells(Boolean usedCellsOnly) { return Cells(usedCellsOnly, XLCellsUsedOptions.AllContents); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLCells Cells(Boolean usedCellsOnly, Boolean includeFormats) - { - return Cells(usedCellsOnly, includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents - ); - } - - public IXLCells Cells(Boolean usedCellsOnly, XLCellsUsedOptions options) + public XLCells Cells(Boolean usedCellsOnly, XLCellsUsedOptions options) { var cells = new XLCells(usedCellsOnly, options) { RangeAddress }; return cells; } - public virtual IXLCells Cells(String cells) + public virtual XLCells Cells(String cells) { return Ranges(cells).Cells(); } @@ -291,25 +286,11 @@ public IXLCells Cells(Func predicate) return cells; } - public IXLCells CellsUsed() + public XLCells CellsUsed() { return Cells(true); } - /// - /// Return the collection of cell values not initializing empty cells. - /// - public IEnumerable CellValues() - { - for (int ro = RangeAddress.FirstAddress.RowNumber; ro <= RangeAddress.LastAddress.RowNumber; ro++) - { - for (int co = RangeAddress.FirstAddress.ColumnNumber; co <= RangeAddress.LastAddress.ColumnNumber; co++) - { - yield return Worksheet.GetCellValue(ro, co); - } - } - } - public IXLRange Merge() { return Merge(true); @@ -336,7 +317,7 @@ public IXLRange Merge(Boolean checkIntersect) var firstCellStyle = firstCell.Style; var defaultStyleKey = XLStyle.Default.Key; var cellsUsed = - CellsUsed(XLCellsUsedOptions.All & ~XLCellsUsedOptions.MergedRanges, c => c != firstCell).ToList(); + CellsUsed(XLCellsUsedOptions.All & ~XLCellsUsedOptions.MergedRanges, c => !c.Equals(firstCell)).ToList(); cellsUsed.ForEach(c => c.Clear(XLClearOptions.All & ~XLClearOptions.MergedRanges & ~XLClearOptions.NormalFormats)); @@ -394,11 +375,12 @@ public IXLRangeBase Clear(XLClearOptions clearOptions = XLClearOptions.All) var cellClearOptions = clearOptions & ~XLClearOptions.ConditionalFormats & ~XLClearOptions.DataValidation - & ~XLClearOptions.MergedRanges; + & ~XLClearOptions.MergedRanges + & ~XLClearOptions.Sparklines; var cellUsedOptions = cellClearOptions.ToCellsUsedOptions(); foreach (var cell in CellsUsed(cellUsedOptions)) { - // We'll clear the conditional formatting, data validations + // We'll clear the conditional formatting, data validations, sparklines // and merged ranges later down. (cell as XLCell).Clear(cellClearOptions, true); } @@ -420,12 +402,7 @@ public IXLRangeBase Clear(XLClearOptions clearOptions = XLClearOptions.All) if (clearOptions == XLClearOptions.All) { - Worksheet.Internals.CellsCollection.RemoveAll( - RangeAddress.FirstAddress.RowNumber, - RangeAddress.FirstAddress.ColumnNumber, - RangeAddress.LastAddress.RowNumber, - RangeAddress.LastAddress.ColumnNumber - ); + Worksheet.Internals.CellsCollection.Clear(XLSheetRange.FromRangeAddress(RangeAddress)); } return this; } @@ -564,31 +541,31 @@ public virtual XLRange AsRange() return Worksheet.Range(RangeAddress); } - public IXLRange AddToNamed(String rangeName) + public IXLRange AddToNamed(String name) { - return AddToNamed(rangeName, XLScope.Workbook); + return AddToNamed(name, XLScope.Workbook); } - public IXLRange AddToNamed(String rangeName, XLScope scope) + public IXLRange AddToNamed(String name, XLScope scope) { - return AddToNamed(rangeName, scope, null); + return AddToNamed(name, scope, null); } - public IXLRange AddToNamed(String rangeName, XLScope scope, String comment) + public IXLRange AddToNamed(String name, XLScope scope, String comment) { - var namedRanges = scope == XLScope.Workbook - ? Worksheet.Workbook.NamedRanges - : Worksheet.NamedRanges; + var definedNames = scope == XLScope.Workbook + ? Worksheet.Workbook.DefinedNamesInternal + : Worksheet.DefinedNames; - if (namedRanges.TryGetValue(rangeName, out IXLNamedRange namedRange)) - namedRange.Add(Worksheet.Workbook, RangeAddress.ToStringFixed(XLReferenceStyle.A1, true)); + if (definedNames.TryGetScopedValue(name, out var definedName)) + definedName.Add(RangeAddress.ToStringFixed(XLReferenceStyle.A1, true)); else - namedRanges.Add(rangeName, RangeAddress.ToStringFixed(XLReferenceStyle.A1, true), comment); + definedNames.Add(name, RangeAddress.ToStringFixed(XLReferenceStyle.A1, true), comment); return AsRange(); } - public IXLRangeBase SetValue(T value) + public IXLRangeBase SetValue(XLCellValue value) { Cells().ForEach(c => c.SetValue(value)); return this; @@ -601,20 +578,19 @@ public Boolean IsMerged() public virtual Boolean IsEmpty() { - return !CellsUsed().Any() || CellsUsed().Any(c => c.IsEmpty()); - } - - [Obsolete("Use the overload with XLCellsUsedOptions")] - public virtual Boolean IsEmpty(Boolean includeFormats) - { - return IsEmpty(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); + return !CellsUsed().Any() || CellsUsed().Any(c => c.IsEmpty()); } public virtual Boolean IsEmpty(XLCellsUsedOptions options) { - return CellsUsed(options).Cast().All(c => c.IsEmpty(options)); + foreach (var cell in CellsUsed(options)) + { + if (!cell.IsEmpty(options)) + { + return false; + } + } + return true; } public virtual Boolean IsEntireRow() @@ -644,9 +620,9 @@ public IXLCells Search(String searchText, CompareOptions compareOptions = Compar if (searchFormulae) return c.HasFormula && culture.CompareInfo.IndexOf(c.FormulaA1, searchText, compareOptions) >= 0 - || culture.CompareInfo.IndexOf(c.Value.ToString(), searchText, compareOptions) >= 0; + || culture.CompareInfo.IndexOf(c.Value.ToString(CultureInfo.CurrentCulture), searchText, compareOptions) >= 0; else - return culture.CompareInfo.IndexOf(c.GetFormattedString(), searchText, compareOptions) >= 0; + return culture.CompareInfo.IndexOf(c.Value.ToString(CultureInfo.CurrentCulture), searchText, compareOptions) >= 0; } catch { @@ -670,26 +646,11 @@ internal XLCell FirstCellUsed() return FirstCellUsed(XLCellsUsedOptions.AllContents, predicate: null); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - internal XLCell FirstCellUsed(Boolean includeFormats) - { - return FirstCellUsed(includeFormats, null); - } - internal XLCell FirstCellUsed(Func predicate) { return FirstCellUsed(XLCellsUsedOptions.AllContents, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - internal XLCell FirstCellUsed(Boolean includeFormats, Func predicate) - { - return FirstCellUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, - predicate); - } - internal XLCell FirstCellUsed(XLCellsUsedOptions options, Func predicate = null) { var cellsUsed = CellsUsedInternal(options, r => r.FirstCell(), predicate).ToList(); @@ -714,26 +675,11 @@ internal XLCell LastCellUsed() return LastCellUsed(XLCellsUsedOptions.AllContents, predicate: null); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - internal XLCell LastCellUsed(Boolean includeFormats) - { - return LastCellUsed(includeFormats, null); - } - internal XLCell LastCellUsed(Func predicate) { return LastCellUsed(XLCellsUsedOptions.AllContents, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - internal XLCell LastCellUsed(Boolean includeFormats, Func predicate) - { - return LastCellUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, - predicate); - } - internal XLCell LastCellUsed(XLCellsUsedOptions options, Func predicate = null) { var cellsUsed = CellsUsedInternal(options, r => r.LastCell(), predicate).ToList(); @@ -763,8 +709,8 @@ public virtual XLCell Cell(String cellAddressInRange) if (XLHelper.IsValidA1Address(cellAddressInRange)) return Cell(XLAddress.Create(Worksheet, cellAddressInRange)); - if (Worksheet.NamedRanges.TryGetValue(cellAddressInRange, out IXLNamedRange namedRange)) - return namedRange.Ranges.First().FirstCell().CastTo(); + if (Worksheet.DefinedNames.TryGetValue(cellAddressInRange, out IXLDefinedName definedName)) + return definedName.Ranges.First().FirstCell().CastTo(); return null; } @@ -800,35 +746,8 @@ public XLCell Cell(in XLAddress cellAddressInRange) ); } - var cell = Worksheet.Internals.CellsCollection.GetCell(absRow, - absColumn); - - if (cell != null) - return cell; - - var styleValue = this.StyleValue; - - if (styleValue == Worksheet.StyleValue) - { - if (Worksheet.Internals.RowsCollection.TryGetValue(absRow, out XLRow row) - && row.StyleValue != Worksheet.StyleValue) - styleValue = row.StyleValue; - else if (Worksheet.Internals.ColumnsCollection.TryGetValue(absColumn, out XLColumn column) - && column.StyleValue != Worksheet.StyleValue) - styleValue = column.StyleValue; - } - var absoluteAddress = new XLAddress(this.Worksheet, - absRow, - absColumn, - cellAddressInRange.FixedRow, - cellAddressInRange.FixedColumn); - - // If the default style for this range base is empty, but the worksheet - // has a default style, use the worksheet's default style - XLCell newCell = new XLCell(Worksheet, absoluteAddress, styleValue); - - Worksheet.Internals.CellsCollection.Add(absRow, absColumn, newCell); - return newCell; + var cell = Worksheet.Internals.CellsCollection.GetCell(new XLSheetPoint(absRow, absColumn)); + return cell; } public Int32 RowCount() @@ -996,7 +915,7 @@ internal XLRange Range(in XLRangeAddress rangeAddress) return GetRange(newFirstCellAddress, newLastCellAddress); } - public virtual IXLRanges Ranges(String ranges) + public virtual XLRanges Ranges(String ranges) { var retVal = new XLRanges(); var rangePairs = ranges.Split(','); @@ -1027,14 +946,6 @@ protected String FixRowAddress(String address) return address; } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLCells CellsUsed(bool includeFormats) - { - return CellsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - public IXLCells CellsUsed(XLCellsUsedOptions options) { var cells = new XLCells(true, options) { RangeAddress }; @@ -1047,15 +958,6 @@ public IXLCells CellsUsed(Func predicate) return cells; } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLCells CellsUsed(Boolean includeFormats, Func predicate) - { - return CellsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, - predicate); - } - public IXLCells CellsUsed(XLCellsUsedOptions options, Func predicate) { var cells = new XLCells(true, options, predicate) { RangeAddress }; @@ -1161,39 +1063,24 @@ private IXLRangeColumns InsertColumnsBeforeInternal(Boolean onlyUsedCells, Int32 foreach (XLWorksheet ws in Worksheet.Workbook.WorksheetsInternal) { - foreach (XLCell cell in ws.Internals.CellsCollection.GetCells(c => !String.IsNullOrWhiteSpace(c.FormulaA1))) + foreach (XLCell cell in ws.Internals.CellsCollection.GetCells(c => c.Formula is not null)) cell.ShiftFormulaColumns(AsRange(), numberOfColumns); } - var cellsToInsert = new Dictionary(); - var cellsToDelete = new List(); - int firstColumn = RangeAddress.FirstAddress.ColumnNumber; - int firstRow = RangeAddress.FirstAddress.RowNumber; - int lastRow = RangeAddress.FirstAddress.RowNumber + RowCount() - 1; + Worksheet.SparklineGroupsInternal.ShiftColumns(XLSheetRange.FromRangeAddress(RangeAddress), numberOfColumns); + // Inserting and shifting of whole columns is rather inconsistent across the codebase. In some places, the columns collection + // is shifted before this method is called and thus the we can't shift column properties again. In others, the code relies on + // shifting in this method. if (!onlyUsedCells) { int lastColumn = Worksheet.Internals.CellsCollection.MaxColumnUsed; if (lastColumn > 0) { + int firstColumn = RangeAddress.FirstAddress.ColumnNumber; for (int co = lastColumn; co >= firstColumn; co--) { int newColumn = co + numberOfColumns; - for (int ro = lastRow; ro >= firstRow; ro--) - { - var oldCell = Worksheet.Internals.CellsCollection.GetCell(ro, co); - if (oldCell == null) - continue; - - var oldKey = new XLAddress(Worksheet, ro, co, false, false); - var newKey = new XLAddress(Worksheet, ro, newColumn, false, false); - - oldCell.Address = newKey; - if (newKey.IsValid) - cellsToInsert.Add(newKey, oldCell); - cellsToDelete.Add(oldKey); - } - if (this.IsEntireColumn()) { Worksheet.Column(newColumn).Width = Worksheet.Column(co).Width; @@ -1201,26 +1088,12 @@ private IXLRangeColumns InsertColumnsBeforeInternal(Boolean onlyUsedCells, Int32 } } } - else - { - foreach ( - XLCell c in - Worksheet.Internals.CellsCollection.GetCells(firstRow, firstColumn, lastRow, - Worksheet.Internals.CellsCollection.MaxColumnUsed)) - { - int newColumn = c.Address.ColumnNumber + numberOfColumns; - var newKey = new XLAddress(Worksheet, c.Address.RowNumber, newColumn, false, false); - cellsToDelete.Add(c.Address); - c.Address = newKey; - if (newKey.IsValid) - cellsToInsert.Add(newKey, c); - } - } + var insertedRange = new XLSheetRange( + XLSheetPoint.FromAddress(RangeAddress.FirstAddress), + new XLSheetPoint(RangeAddress.LastAddress.RowNumber, RangeAddress.FirstAddress.ColumnNumber + numberOfColumns - 1)); - cellsToDelete.ForEach(c => Worksheet.Internals.CellsCollection.Remove(c.RowNumber, c.ColumnNumber)); - cellsToInsert.ForEach( - c => Worksheet.Internals.CellsCollection.Add(c.Key.RowNumber, c.Key.ColumnNumber, c.Value)); + Worksheet.Internals.CellsCollection.InsertAreaAndShiftRight(insertedRange); Int32 firstRowReturn = RangeAddress.FirstAddress.RowNumber; Int32 lastRowReturn = RangeAddress.LastAddress.RowNumber; @@ -1378,41 +1251,21 @@ private IXLRangeRows InsertRowsAboveInternal(Boolean onlyUsedCells, Int32 number var asRange = AsRange(); foreach (XLWorksheet ws in Worksheet.Workbook.WorksheetsInternal) { - foreach (XLCell cell in ws.Internals.CellsCollection.GetCells(c => !String.IsNullOrWhiteSpace(c.FormulaA1))) + foreach (XLCell cell in ws.Internals.CellsCollection.GetCells(c => c.Formula is not null)) cell.ShiftFormulaRows(asRange, numberOfRows); } - var cellsToInsert = new Dictionary(); - var cellsToDelete = new List(); - int firstRow = RangeAddress.FirstAddress.RowNumber; - int firstColumn = RangeAddress.FirstAddress.ColumnNumber; - int lastColumn = Math.Min( - RangeAddress.FirstAddress.ColumnNumber + ColumnCount() - 1, - Worksheet.Internals.CellsCollection.MaxColumnUsed); + Worksheet.SparklineGroupsInternal.ShiftRows(XLSheetRange.FromRangeAddress(RangeAddress), numberOfRows); if (!onlyUsedCells) { int lastRow = Worksheet.Internals.CellsCollection.MaxRowUsed; if (lastRow > 0) { - for (int ro = lastRow; ro >= firstRow; ro--) + int firstRow = RangeAddress.FirstAddress.RowNumber; + for (var ro = lastRow; ro >= firstRow; ro--) { - int newRow = ro + numberOfRows; - - for (int co = lastColumn; co >= firstColumn; co--) - { - var oldCell = Worksheet.Internals.CellsCollection.GetCell(ro, co); - if (oldCell == null) - continue; - - var oldKey = new XLAddress(Worksheet, ro, co, false, false); - var newKey = new XLAddress(Worksheet, newRow, co, false, false); - - oldCell.Address = newKey; - if (newKey.IsValid) - cellsToInsert.Add(newKey, oldCell); - cellsToDelete.Add(oldKey); - } + var newRow = ro + numberOfRows; if (this.IsEntireRow()) { Worksheet.Row(newRow).Height = Worksheet.Row(ro).Height; @@ -1420,25 +1273,11 @@ private IXLRangeRows InsertRowsAboveInternal(Boolean onlyUsedCells, Int32 number } } } - else - { - foreach ( - XLCell c in - Worksheet.Internals.CellsCollection.GetCells(firstRow, firstColumn, - Worksheet.Internals.CellsCollection.MaxRowUsed, - lastColumn)) - { - int newRow = c.Address.RowNumber + numberOfRows; - var newKey = new XLAddress(Worksheet, newRow, c.Address.ColumnNumber, false, false); - cellsToDelete.Add(c.Address); - c.Address = newKey; - if (newKey.IsValid) - cellsToInsert.Add(newKey, c); - } - } - cellsToDelete.ForEach(c => Worksheet.Internals.CellsCollection.Remove(c.RowNumber, c.ColumnNumber)); - cellsToInsert.ForEach(c => Worksheet.Internals.CellsCollection.Add(c.Key.RowNumber, c.Key.ColumnNumber, c.Value)); + var insertedRange = new XLSheetRange( + XLSheetPoint.FromAddress(RangeAddress.FirstAddress), + new XLSheetPoint(RangeAddress.FirstAddress.RowNumber + numberOfRows - 1, RangeAddress.LastAddress.ColumnNumber)); + Worksheet.Internals.CellsCollection.InsertAreaAndShiftDown(insertedRange); Int32 firstRowReturn = RangeAddress.FirstAddress.RowNumber; Int32 lastRowReturn = RangeAddress.FirstAddress.RowNumber + numberOfRows - 1; @@ -1550,75 +1389,27 @@ public void Delete(XLShiftDeletedCells shiftDeleteCells) } // Range to shift... - var cellsToInsert = new Dictionary(); - var cellsToDelete = new List(); - Int32 columnModifier = 0; Int32 rowModifier = 0; - IEnumerable cellsQuery; + var range = XLSheetRange.FromRangeAddress(RangeAddress); switch (shiftDeleteCells) { case XLShiftDeletedCells.ShiftCellsLeft: - cellsQuery = Worksheet.Internals.CellsCollection.GetCells( - RangeAddress.FirstAddress.RowNumber, - RangeAddress.FirstAddress.ColumnNumber, - RangeAddress.LastAddress.RowNumber, - Worksheet.Internals.CellsCollection.MaxColumnUsed); - + Worksheet.Internals.CellsCollection.DeleteAreaAndShiftLeft(range); + Worksheet.SparklineGroupsInternal.ShiftColumns(range, -numberOfColumns); columnModifier = ColumnCount(); - break; case XLShiftDeletedCells.ShiftCellsUp: - cellsQuery = Worksheet.Internals.CellsCollection.GetCells( - RangeAddress.FirstAddress.RowNumber, - RangeAddress.FirstAddress.ColumnNumber, - Worksheet.Internals.CellsCollection.MaxRowUsed, - RangeAddress.LastAddress.ColumnNumber); - + Worksheet.Internals.CellsCollection.DeleteAreaAndShiftUp(range); + Worksheet.SparklineGroupsInternal.ShiftRows(range, -numberOfRows); rowModifier = RowCount(); - - break; - - default: - cellsQuery = new XLCell[] { }; break; } - foreach (var c in cellsQuery) - { - // Schedule for removal from CellsCollection - cellsToDelete.Add(c.Address); - - // Generate new cell to insert into CellsCollection - var newCellAddress = new XLAddress(Worksheet, c.Address.RowNumber - rowModifier, - c.Address.ColumnNumber - columnModifier, - fixedRow: false, - fixedColumn: false); - - if (newCellAddress.IsValid) - { - bool canInsert = shiftDeleteCells == XLShiftDeletedCells.ShiftCellsLeft - ? c.Address.ColumnNumber > RangeAddress.LastAddress.ColumnNumber - : c.Address.RowNumber > RangeAddress.LastAddress.RowNumber; - - c.Address = newCellAddress; - - if (canInsert) - cellsToInsert.Add(newCellAddress, c); - } - } - - cellsToDelete.ForEach(c => Worksheet.Internals.CellsCollection.Remove(c.RowNumber, c.ColumnNumber)); - cellsToInsert.ForEach( - c => Worksheet.Internals.CellsCollection.Add(c.Key.RowNumber, c.Key.ColumnNumber, c.Value)); - var mergesToRemove = Worksheet.Internals.MergedRanges.Where(Contains).ToList(); mergesToRemove.ForEach(r => Worksheet.Internals.MergedRanges.Remove(r)); - var hyperlinksToRemove = Worksheet.Hyperlinks.Where(hl => Contains(hl.Cell.AsRange())).ToList(); - hyperlinksToRemove.ForEach(hl => Worksheet.Hyperlinks.Delete(hl)); - var shiftedRange = AsRange(); if (shiftDeleteCells == XLShiftDeletedCells.ShiftCellsUp) Worksheet.NotifyRangeShiftedRows(shiftedRange, rowModifier * -1); @@ -1757,14 +1548,6 @@ public IXLRange RangeUsed() return RangeUsed(XLCellsUsedOptions.AllContents); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLRange RangeUsed(bool includeFormats) - { - return RangeUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - public IXLRange RangeUsed(XLCellsUsedOptions options) { var firstCell = (this as IXLRangeBase).FirstCellUsed(options); @@ -1776,12 +1559,12 @@ public IXLRange RangeUsed(XLCellsUsedOptions options) public virtual void CopyTo(IXLRangeBase target) { - CopyTo(target.FirstCell()); + CopyTo((XLCell)target.FirstCell()); } - public virtual void CopyTo(IXLCell target) + internal void CopyTo(XLCell target) { - target.Value = this; + target.CopyFrom(this); } //public IXLChart CreateChart(Int32 firstRow, Int32 firstColumn, Int32 lastRow, Int32 lastColumn) @@ -1866,27 +1649,7 @@ public IXLRangeBase Sort(String columnsToSortBy, XLSortOrder sortOrder = XLSortO columnsToSortBy = DefaultSortString(); } - foreach (string coPairTrimmed in columnsToSortBy.Split(',').Select(coPair => coPair.Trim())) - { - String coString; - String order; - if (coPairTrimmed.Contains(' ')) - { - var pair = coPairTrimmed.Split(' '); - coString = pair[0]; - order = pair[1]; - } - else - { - coString = coPairTrimmed; - order = sortOrder == XLSortOrder.Ascending ? "ASC" : "DESC"; - } - - if (!Int32.TryParse(coString, out Int32 co)) - co = XLHelper.GetColumnNumberFromLetter(coString); - - SortColumns.Add(co, String.Compare(order, "ASC", true) == 0 ? XLSortOrder.Ascending : XLSortOrder.Descending, ignoreBlanks, matchCase); - } + SortColumns.CastTo().AddRange(ParseSortOrder(columnsToSortBy, sortOrder, matchCase, ignoreBlanks)); SortRangeRows(); return this; @@ -1913,140 +1676,77 @@ public IXLRangeBase SortLeftToRight(XLSortOrder sortOrder = XLSortOrder.Ascendin return this; } - #region Sort Rows - private void SortRangeRows() { - Int32 maxRow = RowCount(); - if (maxRow == XLHelper.MaxRowNumber) - maxRow = (this as IXLRangeBase).LastCellUsed(XLCellsUsedOptions.All).Address.RowNumber; - - SortingRangeRows(1, maxRow); - } - - private void SwapRows(Int32 row1, Int32 row2) - { - int row1InWs = RangeAddress.FirstAddress.RowNumber + row1 - 1; - int row2InWs = RangeAddress.FirstAddress.RowNumber + row2 - 1; - - Int32 firstColumn = RangeAddress.FirstAddress.ColumnNumber; - Int32 lastColumn = RangeAddress.LastAddress.ColumnNumber; - - var range1Sp1 = new XLSheetPoint(row1InWs, firstColumn); - var range1Sp2 = new XLSheetPoint(row1InWs, lastColumn); - var range2Sp1 = new XLSheetPoint(row2InWs, firstColumn); - var range2Sp2 = new XLSheetPoint(row2InWs, lastColumn); - - Worksheet.Internals.CellsCollection.SwapRanges(new XLSheetRange(range1Sp1, range1Sp2), - new XLSheetRange(range2Sp1, range2Sp2), Worksheet); - } - - private int SortRangeRows(int begPoint, int endPoint) - { - int pivot = begPoint; - int m = begPoint + 1; - int n = endPoint; - while ((m < endPoint) && RowQuick(pivot).CompareTo(RowQuick(m), SortColumns) >= 0) - m++; - - while (n > begPoint && RowQuick(pivot).CompareTo(RowQuick(n), SortColumns) <= 0) - n--; - - while (m < n) + var sortRange = SheetRange; + var cellsCollection = Worksheet.Internals.CellsCollection; + if (sortRange.IsEntireColumn()) { - SwapRows(m, n); - - while (m < endPoint && RowQuick(pivot).CompareTo(RowQuick(m), SortColumns) >= 0) - m++; - - while (n > begPoint && RowQuick(pivot).CompareTo(RowQuick(n), SortColumns) <= 0) - n--; + // If we're dealing with the entire column, we're not interested in the unused cells + var lastRowUsed = cellsCollection.LastRowUsed(XLSheetRange.Full, XLCellsUsedOptions.Contents); + sortRange = new XLSheetRange(sortRange.FirstPoint, new XLSheetPoint(lastRowUsed, sortRange.RightColumn)); } - if (pivot != n) - SwapRows(n, pivot); + var comparer = new XLRangeRowsSortComparer(Worksheet, sortRange, SortColumns); + var rows = new int[sortRange.Height]; + for (var i = 0; i < sortRange.Height; ++i) + rows[i] = i + sortRange.TopRow; - return n; - } + Array.Sort(rows, comparer); - private void SortingRangeRows(int beg, int end) - { - if (beg == end) - return; - int pivot = SortRangeRows(beg, end); - if (pivot > beg) - SortingRangeRows(beg, pivot - 1); - if (pivot < end) - SortingRangeRows(pivot + 1, end); + cellsCollection.RemapRows(rows, sortRange); } - #endregion Sort Rows - - #region Sort Columns - private void SortRangeColumns() { - Int32 maxColumn = ColumnCount(); - if (maxColumn == XLHelper.MaxColumnNumber) - maxColumn = (this as IXLRangeBase).LastCellUsed(XLCellsUsedOptions.All).Address.ColumnNumber; - SortingRangeColumns(1, maxColumn); - } - - private void SwapColumns(Int32 column1, Int32 column2) - { - int col1InWs = RangeAddress.FirstAddress.ColumnNumber + column1 - 1; - int col2InWs = RangeAddress.FirstAddress.ColumnNumber + column2 - 1; + var sortRange = SheetRange; + var cellsCollection = Worksheet.Internals.CellsCollection; + if (sortRange.IsEntireRow()) + { + // If we're dealing with the entire row, we're not interested in the unused cells + var lastColumnCell = cellsCollection.LastColumnUsed(XLSheetRange.Full, XLCellsUsedOptions.Contents); + sortRange = new XLSheetRange(sortRange.FirstPoint, new XLSheetPoint(sortRange.BottomRow, lastColumnCell)); + } - Int32 firstRow = RangeAddress.FirstAddress.RowNumber; - Int32 lastRow = RangeAddress.LastAddress.RowNumber; + var comparer = new XLRangeColumnsSortComparer(Worksheet, sortRange, SortRows); + var columns = new int[sortRange.Width]; + for (var i = 0; i < sortRange.Width; ++i) + columns[i] = i + sortRange.LeftColumn; - var range1Sp1 = new XLSheetPoint(firstRow, col1InWs); - var range1Sp2 = new XLSheetPoint(lastRow, col1InWs); - var range2Sp1 = new XLSheetPoint(firstRow, col2InWs); - var range2Sp2 = new XLSheetPoint(lastRow, col2InWs); + Array.Sort(columns, comparer); - Worksheet.Internals.CellsCollection.SwapRanges(new XLSheetRange(range1Sp1, range1Sp2), - new XLSheetRange(range2Sp1, range2Sp2), Worksheet); + cellsCollection.RemapColumns(columns, sortRange); } - private int SortRangeColumns(int begPoint, int endPoint) + private IEnumerable ParseSortOrder(string columnsToSortBy, XLSortOrder defaultSortOrder, bool matchCase, bool ignoreBlanks) { - int pivot = begPoint; - int m = begPoint + 1; - int n = endPoint; - while ((m < endPoint) && ColumnQuick(pivot).CompareTo((ColumnQuick(m)), SortRows) >= 0) - m++; - - while ((n > begPoint) && ((ColumnQuick(pivot)).CompareTo((ColumnQuick(n)), SortRows) <= 0)) - n--; - while (m < n) + foreach (var sortColumn in columnsToSortBy.Split(',').Select(coPair => coPair.Trim())) { - SwapColumns(m, n); + var sortOrder = defaultSortOrder; - while ((m < endPoint) && (ColumnQuick(pivot)).CompareTo((ColumnQuick(m)), SortRows) >= 0) - m++; + String columnName; + if (sortColumn.Contains(' ')) + { + var pair = sortColumn.Split(' '); + columnName = pair[0]; + sortOrder = pair[1].Equals("ASC", StringComparison.OrdinalIgnoreCase) ? XLSortOrder.Ascending : XLSortOrder.Descending; + } + else + { + columnName = sortColumn; + } - while ((n > begPoint) && (ColumnQuick(pivot)).CompareTo((ColumnQuick(n)), SortRows) <= 0) - n--; - } - if (pivot != n) - SwapColumns(n, pivot); - return n; - } + if (!Int32.TryParse(columnName, out Int32 columnNumber)) + columnNumber = XLHelper.GetColumnNumberFromLetter(columnName); - private void SortingRangeColumns(int beg, int end) - { - if (end == beg) - return; - int pivot = SortRangeColumns(beg, end); - if (pivot > beg) - SortingRangeColumns(beg, pivot - 1); - if (pivot < end) - SortingRangeColumns(pivot + 1, end); + yield return new XLSortElement( + columnNumber, + sortOrder, + ignoreBlanks, + matchCase); + } } - #endregion Sort Columns - #endregion Sort public XLRangeColumn ColumnQuick(Int32 column) @@ -2064,22 +1764,6 @@ public XLRangeColumn ColumnQuick(Int32 column) return Worksheet.RangeColumn(new XLRangeAddress(firstCellAddress, lastCellAddress)); } - public XLRangeRow RowQuick(Int32 row) - { - var firstCellAddress = new XLAddress(Worksheet, - RangeAddress.FirstAddress.RowNumber + row - 1, - RangeAddress.FirstAddress.ColumnNumber, - false, - false); - var lastCellAddress = new XLAddress(Worksheet, - RangeAddress.FirstAddress.RowNumber + row - 1, - RangeAddress.LastAddress.ColumnNumber, - false, - false); - - return Worksheet.RangeRow(new XLRangeAddress(firstCellAddress, lastCellAddress)); - } - [Obsolete("Use GetDataValidation() to access the existing rule, or CreateDataValidation() to create a new one.")] public IXLDataValidation SetDataValidation() { diff --git a/ClosedXML/Excel/Ranges/XLRangeColumn.cs b/ClosedXML/Excel/Ranges/XLRangeColumn.cs index 9bf913669..81a724ef8 100644 --- a/ClosedXML/Excel/Ranges/XLRangeColumn.cs +++ b/ClosedXML/Excel/Ranges/XLRangeColumn.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Linq; @@ -24,7 +26,9 @@ IXLCell IXLRangeColumn.Cell(int rowNumber) return Cell(rowNumber); } - public override IXLCells Cells(string cellsInColumn) + IXLCells IXLRangeColumn.Cells(string cellsInColumn) => Cells(cellsInColumn); + + public override XLCells Cells(string cellsInColumn) { var retVal = new XLCells(false, XLCellsUsedOptions.AllContents); var rangePairs = cellsInColumn.Split(','); @@ -48,9 +52,11 @@ internal void Delete(Boolean deleteTableField) if (deleteTableField && IsTableColumn()) { var table = Table as XLTable; - var firstCellValue = Cell(1).Value.ToString(); + if (!Cell(1).Value.TryGetText(out var firstCellValue)) + throw new InvalidOperationException("Top cell doesn't contain a text."); + if (!table.FieldNames.ContainsKey(firstCellValue)) - throw new ArgumentException(string.Format("Field {0} not found.", firstCellValue)); + throw new InvalidOperationException($"Field {firstCellValue} not found."); var field = table.Fields.Cast().Single(f => f.Name == firstCellValue); field.Delete(false); @@ -90,9 +96,9 @@ public IXLRangeColumn Sort(XLSortOrder sortOrder = XLSortOrder.Ascending, Boolea return this; } - public new IXLRangeColumn CopyTo(IXLCell target) + public IXLRangeColumn CopyTo(IXLCell target) { - base.CopyTo(target); + base.CopyTo((XLCell)target); int lastRowNumber = target.Address.RowNumber + RowCount() - 1; if (lastRowNumber > XLHelper.MaxRowNumber) @@ -165,12 +171,6 @@ public IXLRangeColumns Columns(string columns) return retVal; } - public IXLRangeColumn SetDataType(XLDataType dataType) - { - DataType = dataType; - return this; - } - public IXLColumn WorksheetColumn() { return Worksheet.Column(RangeAddress.FirstAddress.ColumnNumber); @@ -248,16 +248,20 @@ public int CompareTo(XLRangeColumn otherColumn, IXLSortElements rowsToSort) { if (thisCell.DataType == otherCell.DataType) { - if (thisCell.DataType == XLDataType.Text) + if (thisCell.DataType == XLDataType.Blank) + comparison = 0; + else if (thisCell.DataType == XLDataType.Boolean) + comparison = thisCell.GetBoolean().CompareTo(otherCell.GetBoolean()); + else if (thisCell.DataType == XLDataType.Text) { comparison = e.MatchCase - ? thisCell.InnerText.CompareTo(otherCell.InnerText) - : String.Compare(thisCell.InnerText, otherCell.InnerText, true); + ? thisCell.GetText().CompareTo(otherCell.GetText()) + : String.Compare(thisCell.GetText(), otherCell.GetText(), true); } - else if (thisCell.DataType == XLDataType.TimeSpan) - comparison = thisCell.GetTimeSpan().CompareTo(otherCell.GetTimeSpan()); + else if (thisCell.DataType == XLDataType.Error) + comparison = 0; // Errors are incomparable else - comparison = Double.Parse(thisCell.InnerText, XLHelper.NumberStyle, XLHelper.ParseCulture).CompareTo(Double.Parse(otherCell.InnerText, XLHelper.NumberStyle, XLHelper.ParseCulture)); + comparison = thisCell.CachedValue.GetUnifiedNumber().CompareTo(thisCell.CachedValue.GetUnifiedNumber()); } else if (e.MatchCase) comparison = String.Compare(thisCell.GetString(), otherCell.GetString(), true); @@ -368,13 +372,6 @@ public IXLTable CreateTable(string name) return this; } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLRangeColumn ColumnUsed(Boolean includeFormats) - { - return ColumnUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } public IXLRangeColumn ColumnUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents) { diff --git a/ClosedXML/Excel/Ranges/XLRangeColumns.cs b/ClosedXML/Excel/Ranges/XLRangeColumns.cs index cedf4a188..9fc107890 100644 --- a/ClosedXML/Excel/Ranges/XLRangeColumns.cs +++ b/ClosedXML/Excel/Ranges/XLRangeColumns.cs @@ -1,4 +1,5 @@ -using System; +#nullable disable + using System.Collections.Generic; using System.Linq; @@ -62,14 +63,6 @@ public IXLCells CellsUsed() return cells; } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLCells CellsUsed(Boolean includeFormats) - { - return CellsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents - ); - } public IXLCells CellsUsed(XLCellsUsedOptions options) { @@ -79,34 +72,10 @@ public IXLCells CellsUsed(XLCellsUsedOptions options) return cells; } - public IXLRangeColumns SetDataType(XLDataType dataType) - { - _ranges.ForEach(c => c.DataType = dataType); - return this; - } - #endregion IXLRangeColumns Members #region IXLStylized Members - public override IEnumerable Styles - { - get - { - yield return Style; - foreach (XLRangeColumn rng in _ranges) - { - yield return rng.Style; - foreach (XLCell r in rng.Worksheet.Internals.CellsCollection.GetCells( - rng.RangeAddress.FirstAddress.RowNumber, - rng.RangeAddress.FirstAddress.ColumnNumber, - rng.RangeAddress.LastAddress.RowNumber, - rng.RangeAddress.LastAddress.ColumnNumber)) - yield return r.Style; - } - } - } - protected override IEnumerable Children { get diff --git a/ClosedXML/Excel/Ranges/XLRangeConsolidationEngine.cs b/ClosedXML/Excel/Ranges/XLRangeConsolidationEngine.cs index 8eb04a057..0d5e38797 100644 --- a/ClosedXML/Excel/Ranges/XLRangeConsolidationEngine.cs +++ b/ClosedXML/Excel/Ranges/XLRangeConsolidationEngine.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections; using System.Collections.Generic; @@ -33,7 +35,7 @@ public IXLRanges Consolidate() IXLRanges retVal = new XLRanges(); foreach (var ws in worksheets) { - var matrix = new XLRangeConsolidationMatrix(ws, _allRanges.Where(r => r.Worksheet == ws)); + var matrix = new XLRangeConsolidationMatrix(ws, _allRanges.Where(r => r.Worksheet == ws).ToList()); var consRanges = matrix.GetConsolidatedRanges(); foreach (var consRange in consRanges) { @@ -67,7 +69,7 @@ private class XLRangeConsolidationMatrix /// /// Current worksheet. /// Ranges to be consolidated. They are expected to belong to the current worksheet, no check is performed. - public XLRangeConsolidationMatrix(IXLWorksheet worksheet, IEnumerable ranges) + public XLRangeConsolidationMatrix(IXLWorksheet worksheet, IReadOnlyCollection ranges) { _worksheet = worksheet; PrepareBitMatrix(ranges); diff --git a/ClosedXML/Excel/Ranges/XLRangeFactory.cs b/ClosedXML/Excel/Ranges/XLRangeFactory.cs index b3ded6499..f1be35d05 100644 --- a/ClosedXML/Excel/Ranges/XLRangeFactory.cs +++ b/ClosedXML/Excel/Ranges/XLRangeFactory.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Ranges/XLRangeKey.cs b/ClosedXML/Excel/Ranges/XLRangeKey.cs index 8fb4842a5..13661b9a0 100644 --- a/ClosedXML/Excel/Ranges/XLRangeKey.cs +++ b/ClosedXML/Excel/Ranges/XLRangeKey.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Ranges/XLRangeParameters.cs b/ClosedXML/Excel/Ranges/XLRangeParameters.cs index 2c93bab10..6c58b6d19 100644 --- a/ClosedXML/Excel/Ranges/XLRangeParameters.cs +++ b/ClosedXML/Excel/Ranges/XLRangeParameters.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace ClosedXML.Excel { internal class XLRangeParameters diff --git a/ClosedXML/Excel/Ranges/XLRangeRow.cs b/ClosedXML/Excel/Ranges/XLRangeRow.cs index 8cfe6d874..a898a79cc 100644 --- a/ClosedXML/Excel/Ranges/XLRangeRow.cs +++ b/ClosedXML/Excel/Ranges/XLRangeRow.cs @@ -11,7 +11,7 @@ internal class XLRangeRow : XLRangeBase, IXLRangeRow /// The direct constructor should only be used in . /// public XLRangeRow(XLRangeParameters rangeParameters) - : base(rangeParameters.RangeAddress, (rangeParameters.DefaultStyle as XLStyle).Value) + : base(rangeParameters.RangeAddress, ((XLStyle)rangeParameters.DefaultStyle).Value) { } @@ -19,6 +19,8 @@ public XLRangeRow(XLRangeParameters rangeParameters) #region IXLRangeRow Members + IXLCells IXLRangeRow.Cells(string cellsInRow) => Cells(cellsInRow); + public IXLCell Cell(int column) { return Cell(1, column); @@ -59,7 +61,7 @@ public IXLCells InsertCellsBefore(int numberOfColumns, bool expandRange) return InsertColumnsBefore(numberOfColumns, expandRange).Cells(); } - public override IXLCells Cells(string cellsInRow) + public override XLCells Cells(string cellsInRow) { var retVal = new XLCells(false, XLCellsUsedOptions.AllContents); var rangePairs = cellsInRow.Split(','); @@ -95,9 +97,9 @@ public int CellCount() return this; } - public new IXLRangeRow CopyTo(IXLCell target) + public IXLRangeRow CopyTo(IXLCell target) { - base.CopyTo(target); + base.CopyTo((XLCell)target); int lastRowNumber = target.Address.RowNumber + RowCount() - 1; if (lastRowNumber > XLHelper.MaxRowNumber) @@ -170,12 +172,6 @@ public IXLRangeRows Rows(string rows) return retVal; } - public IXLRangeRow SetDataType(XLDataType dataType) - { - DataType = dataType; - return this; - } - public IXLRow WorksheetRow() { return Worksheet.Row(RangeAddress.FirstAddress.RowNumber); @@ -251,8 +247,8 @@ public int CompareTo(XLRangeRow otherRow, IXLSortElements columnsToSort) { case XLDataType.Text: comparison = e.MatchCase - ? thisCell.InnerText.CompareTo(otherCell.InnerText) - : String.Compare(thisCell.InnerText, otherCell.InnerText, true); + ? thisCell.GetText().CompareTo(otherCell.GetText()) + : String.Compare(thisCell.GetText(), otherCell.GetText(), true); break; case XLDataType.TimeSpan: @@ -275,6 +271,8 @@ public int CompareTo(XLRangeRow otherRow, IXLSortElements columnsToSort) throw new NotImplementedException(); } } + else if (thisCell.Value.IsUnifiedNumber && otherCell.Value.IsUnifiedNumber) + comparison = thisCell.Value.GetUnifiedNumber().CompareTo(otherCell.Value.GetUnifiedNumber()); else if (e.MatchCase) comparison = String.Compare(thisCell.GetString(), otherCell.GetString(), true); else @@ -355,13 +353,6 @@ public XLRangeRow RowBelow(Int32 step) return this; } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLRangeRow RowUsed(Boolean includeFormats) - { - return RowUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } public IXLRangeRow RowUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents) { diff --git a/ClosedXML/Excel/Ranges/XLRangeRows.cs b/ClosedXML/Excel/Ranges/XLRangeRows.cs index ba1e5edbc..7140bc17e 100644 --- a/ClosedXML/Excel/Ranges/XLRangeRows.cs +++ b/ClosedXML/Excel/Ranges/XLRangeRows.cs @@ -1,4 +1,5 @@ -using System; +#nullable disable + using System.Collections.Generic; using System.Linq; @@ -62,13 +63,6 @@ public IXLCells CellsUsed() return cells; } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLCells CellsUsed(Boolean includeFormats) - { - return CellsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } public IXLCells CellsUsed(XLCellsUsedOptions options) { @@ -78,34 +72,10 @@ public IXLCells CellsUsed(XLCellsUsedOptions options) return cells; } - public IXLRangeRows SetDataType(XLDataType dataType) - { - _ranges.ForEach(c => c.DataType = dataType); - return this; - } - #endregion IXLRangeRows Members #region IXLStylized Members - public override IEnumerable Styles - { - get - { - yield return Style; - foreach (XLRangeRow rng in _ranges) - { - yield return rng.Style; - foreach (XLCell r in rng.Worksheet.Internals.CellsCollection.GetCells( - rng.RangeAddress.FirstAddress.RowNumber, - rng.RangeAddress.FirstAddress.ColumnNumber, - rng.RangeAddress.LastAddress.RowNumber, - rng.RangeAddress.LastAddress.ColumnNumber)) - yield return r.Style; - } - } - } - protected override IEnumerable Children { get diff --git a/ClosedXML/Excel/Ranges/XLRangeType.cs b/ClosedXML/Excel/Ranges/XLRangeType.cs index 1042d8653..3d2bb87ae 100644 --- a/ClosedXML/Excel/Ranges/XLRangeType.cs +++ b/ClosedXML/Excel/Ranges/XLRangeType.cs @@ -1,4 +1,6 @@ -namespace ClosedXML.Excel +#nullable disable + +namespace ClosedXML.Excel { internal enum XLRangeType : byte { diff --git a/ClosedXML/Excel/Ranges/XLRanges.cs b/ClosedXML/Excel/Ranges/XLRanges.cs index 0da17d892..27c24be47 100644 --- a/ClosedXML/Excel/Ranges/XLRanges.cs +++ b/ClosedXML/Excel/Ranges/XLRanges.cs @@ -34,6 +34,8 @@ public XLRanges() : base(XLWorkbook.DefaultStyleValue) #region IXLRanges Members + IXLCells IXLRanges.Cells() => Cells(); + public IXLRanges Clear(XLClearOptions clearOptions = XLClearOptions.All) { Ranges.ForEach(c => c.Clear(clearOptions)); @@ -58,7 +60,7 @@ public void Add(XLRange range) public void Add(IXLRangeBase range) { - Add(range.AsRange() as XLRange); + Add((XLRange)range.AsRange()); } public void Add(IXLCell cell) @@ -84,7 +86,7 @@ public bool Remove(IXLRange range) /// Null means the entire collection should be cleared. /// Specify whether or not should removed ranges be unsubscribed from /// row/column shifting events. Until ranges are unsubscribed they cannot be collected by GC. - public void RemoveAll(Predicate match = null, bool releaseEventHandlers = true) + public void RemoveAll(Predicate? match = null, bool releaseEventHandlers = true) { foreach (var index in _indexes.Values) { @@ -132,7 +134,7 @@ public IEnumerable GetIntersectedRanges(IXLRangeAddress rangeAddress) internal IEnumerable GetIntersectedRanges(in XLRangeAddress rangeAddress) { - return GetRangeIndex(rangeAddress.Worksheet) + return GetRangeIndex(rangeAddress.Worksheet!) .GetIntersectedRanges(rangeAddress); } @@ -172,24 +174,24 @@ public IXLRanges AddToNamed(String rangeName, XLScope scope) return AddToNamed(rangeName, XLScope.Workbook, null); } - public IXLRanges AddToNamed(String rangeName, XLScope scope, String comment) + public IXLRanges AddToNamed(String rangeName, XLScope scope, String? comment) { Ranges.ForEach(r => r.AddToNamed(rangeName, scope, comment)); return this; } - public Object Value + public XLCellValue Value { set { Ranges.ForEach(r => r.Value = value); } } - public IXLRanges SetValue(T value) + public IXLRanges SetValue(XLCellValue value) { Ranges.ForEach(r => r.SetValue(value)); return this; } - public IXLCells Cells() + public XLCells Cells() { var cells = new XLCells(false, XLCellsUsedOptions.AllContents); foreach (XLRange container in Ranges) @@ -205,14 +207,6 @@ public IXLCells CellsUsed() return cells; } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLCells CellsUsed(Boolean includeFormats) - { - return CellsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - public IXLCells CellsUsed(XLCellsUsedOptions options) { var cells = new XLCells(true, options); @@ -221,34 +215,10 @@ public IXLCells CellsUsed(XLCellsUsedOptions options) return cells; } - public IXLRanges SetDataType(XLDataType dataType) - { - Ranges.ForEach(c => c.DataType = dataType); - return this; - } - #endregion IXLRanges Members #region IXLStylized Members - public override IEnumerable Styles - { - get - { - yield return Style; - foreach (XLRange rng in Ranges) - { - yield return rng.Style; - foreach (XLCell r in rng.Worksheet.Internals.CellsCollection.GetCells( - rng.RangeAddress.FirstAddress.RowNumber, - rng.RangeAddress.FirstAddress.ColumnNumber, - rng.RangeAddress.LastAddress.RowNumber, - rng.RangeAddress.LastAddress.ColumnNumber)) - yield return r.Style; - } - } - } - protected override IEnumerable Children { get @@ -277,7 +247,7 @@ public override bool Equals(object obj) return Equals(obj as XLRanges); } - public bool Equals(XLRanges other) + public bool Equals(XLRanges? other) { if (other == null) return false; diff --git a/ClosedXML/Excel/RichText/IXLFormattedText.cs b/ClosedXML/Excel/RichText/IXLFormattedText.cs index 3180bee97..f64edfc6a 100644 --- a/ClosedXML/Excel/RichText/IXLFormattedText.cs +++ b/ClosedXML/Excel/RichText/IXLFormattedText.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; @@ -32,11 +34,38 @@ public interface IXLFormattedText : IEnumerable, IEquatable ClearFont(); IXLFormattedText Substring(Int32 index); IXLFormattedText Substring(Int32 index, Int32 length); + + /// + /// Replace the text and formatting of this text by texts and formatting from the text. + /// + /// Original to copy from. + /// This text. + IXLFormattedText CopyFrom(IXLFormattedText original); + + /// + /// How many rich strings is the formatted text composed of. + /// Int32 Count { get; } + + /// + /// Length of the whole formatted text. + /// Int32 Length { get; } + /// + /// Get text of the whole formatted text. + /// String Text { get; } - IXLPhonetics Phonetics { get; } + + /// + /// Does this text has phonetics? Unlike accessing the property, this method + /// doesn't create a new instance on access. + /// Boolean HasPhonetics { get; } + + /// + /// Get or create phonetics for the text. Use to check for existence to avoid unnecessary creation. + /// + IXLPhonetics Phonetics { get; } } } diff --git a/ClosedXML/Excel/RichText/IXLPhonetic.cs b/ClosedXML/Excel/RichText/IXLPhonetic.cs index 56e20f9d0..a7559a534 100644 --- a/ClosedXML/Excel/RichText/IXLPhonetic.cs +++ b/ClosedXML/Excel/RichText/IXLPhonetic.cs @@ -4,8 +4,8 @@ namespace ClosedXML.Excel { public interface IXLPhonetic: IEquatable { - String Text { get; set; } - Int32 Start { get; set; } - Int32 End { get; set; } + String Text { get; } + Int32 Start { get; } + Int32 End { get; } } } diff --git a/ClosedXML/Excel/RichText/IXLPhonetics.cs b/ClosedXML/Excel/RichText/IXLPhonetics.cs index 67cd96de3..5f2ee0816 100644 --- a/ClosedXML/Excel/RichText/IXLPhonetics.cs +++ b/ClosedXML/Excel/RichText/IXLPhonetics.cs @@ -3,8 +3,8 @@ namespace ClosedXML.Excel { - public enum XLPhoneticAlignment { Center, Distributed, Left, NoControl } - public enum XLPhoneticType { FullWidthKatakana, HalfWidthKatakana, Hiragana, NoConversion } + public enum XLPhoneticAlignment { Center = 0, Distributed = 1, Left = 2, NoControl = 3 } + public enum XLPhoneticType { FullWidthKatakana = 0, HalfWidthKatakana = 1, Hiragana = 2, NoConversion = 3 } public interface IXLPhonetics : IXLFontBase, IEnumerable, IEquatable { IXLPhonetics SetBold(); IXLPhonetics SetBold(Boolean value); @@ -18,10 +18,29 @@ public interface IXLPhonetics : IXLFontBase, IEnumerable, IEquatabl IXLPhonetics SetFontName(String value); IXLPhonetics SetFontFamilyNumbering(XLFontFamilyNumberingValues value); IXLPhonetics SetFontCharSet(XLFontCharSet value); + IXLPhonetics SetFontScheme(XLFontScheme value); + /// + /// Add a phonetic run above a base text. Phonetic runs can't overlap. + /// + /// Text to display above a section of a base text. Can't be empty. + /// Index of a first character of a base text above which should be displayed. Valid values are 0..length-1. + /// The excluded ending index in a base text (the hint is not displayed above the end). Must be > . Valid values are 1..length. IXLPhonetics Add(String text, Int32 start, Int32 end); + + /// + /// Remove all phonetic runs. Keeps font properties. + /// IXLPhonetics ClearText(); + + /// + /// Reset font properties to the default font of a container (likely IXLCell). Keeps phonetic runs, and . + /// IXLPhonetics ClearFont(); + + /// + /// Number of phonetic runs above the base text. + /// Int32 Count { get; } XLPhoneticAlignment Alignment { get; set; } diff --git a/ClosedXML/Excel/RichText/IXLRichString.cs b/ClosedXML/Excel/RichText/IXLRichString.cs index a0904603e..b17481a24 100644 --- a/ClosedXML/Excel/RichText/IXLRichString.cs +++ b/ClosedXML/Excel/RichText/IXLRichString.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -7,11 +9,10 @@ public interface IXLWithRichString IXLRichString AddText(String text); IXLRichString AddNewLine(); } - public interface IXLRichString: IXLFontBase, IEquatable, IXLWithRichString + public interface IXLRichString : IXLFontBase, IEquatable, IXLWithRichString { String Text { get; set; } - IXLRichString SetBold(); IXLRichString SetBold(Boolean value); IXLRichString SetItalic(); IXLRichString SetItalic(Boolean value); IXLRichString SetUnderline(); IXLRichString SetUnderline(XLFontUnderlineValues value); @@ -23,5 +24,8 @@ public interface IXLRichString: IXLFontBase, IEquatable, IXLWithR IXLRichString SetFontName(String value); IXLRichString SetFontFamilyNumbering(XLFontFamilyNumberingValues value); IXLRichString SetFontCharSet(XLFontCharSet value); + + /// + IXLRichString SetFontScheme(XLFontScheme value); } } diff --git a/ClosedXML/Excel/RichText/IXLRichText.cs b/ClosedXML/Excel/RichText/IXLRichText.cs index 46efd4197..f1fbaede6 100644 --- a/ClosedXML/Excel/RichText/IXLRichText.cs +++ b/ClosedXML/Excel/RichText/IXLRichText.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace ClosedXML.Excel { public interface IXLRichText : IXLFormattedText diff --git a/ClosedXML/Excel/RichText/XLFormattedText.cs b/ClosedXML/Excel/RichText/XLFormattedText.cs index 37484780d..9cd8a5c09 100644 --- a/ClosedXML/Excel/RichText/XLFormattedText.cs +++ b/ClosedXML/Excel/RichText/XLFormattedText.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Text; @@ -5,52 +7,68 @@ namespace ClosedXML.Excel { - internal class XLFormattedText: IXLFormattedText + internal class XLFormattedText : IXLFormattedText { - List _richTexts = new List(); - + /// + /// Font used for a new rich text run, never modified. It is generally provided by a container of the formatted text. + /// + private readonly IXLFontBase _defaultFont; + private List _richTexts = new(); + private XLPhonetics _phonetics; protected T Container; - readonly IXLFontBase _defaultFont; - public XLFormattedText(IXLFontBase defaultFont) - { - Length = 0; - _defaultFont = defaultFont; - } - public XLFormattedText(IXLFormattedText defaultRichText, IXLFontBase defaultFont) + protected XLFormattedText(IXLFormattedText defaultRichText, IXLFontBase defaultFont) : this(defaultFont) { foreach (var rt in defaultRichText) AddText(rt.Text, rt); if (defaultRichText.HasPhonetics) { - _phonetics = new XLPhonetics(defaultRichText.Phonetics, defaultFont); + _phonetics = new XLPhonetics(defaultRichText.Phonetics, defaultFont, OnContentChanged); } } - public XLFormattedText(String text, IXLFontBase defaultFont) - :this(defaultFont) + protected XLFormattedText(String text, IXLFontBase defaultFont) + : this(defaultFont) { AddText(text); } - public Int32 Count { get { return _richTexts.Count; } } + protected XLFormattedText(IXLFontBase defaultFont) + { + Length = 0; + _defaultFont = defaultFont; + } + + IXLPhonetics IXLFormattedText.Phonetics => Phonetics; + + public Int32 Count => _richTexts.Count; + public int Length { get; private set; } + public String Text => ToString(); + + public Boolean HasPhonetics => _phonetics is not null; + + /// + internal XLPhonetics Phonetics => _phonetics ??= new XLPhonetics(_defaultFont, OnContentChanged); + public IXLRichString AddText(String text) { return AddText(text, _defaultFont); } + public IXLRichString AddText(String text, IXLFontBase font) { - var richText = new XLRichString(text, font, this); + var richText = new XLRichString(text, font, this, OnContentChanged); return AddText(richText); } - public IXLRichString AddText(IXLRichString richText) + public IXLRichString AddText(XLRichString richText) { _richTexts.Add(richText); Length += richText.Text.Length; + OnContentChanged(); return richText; } @@ -63,8 +81,10 @@ public IXLFormattedText ClearText() { _richTexts.Clear(); Length = 0; + OnContentChanged(); return this; } + public IXLFormattedText ClearFont() { String text = Text; @@ -84,12 +104,13 @@ public IXLFormattedText Substring(Int32 index) { return Substring(index, Length - index); } + public IXLFormattedText Substring(Int32 index, Int32 length) { if (index + 1 > Length || (Length - index + 1) < length || length <= 0) throw new IndexOutOfRangeException("Index and length must refer to a location within the string."); - List newRichTexts = new List(); + var newRichTexts = new List(); var retVal = new XLFormattedText(_defaultFont); Int32 lastPosition = 0; @@ -104,7 +125,7 @@ public IXLFormattedText Substring(Int32 index, Int32 length) Int32 startIndex = index - lastPosition; if (startIndex > 0) - newRichTexts.Add(new XLRichString(rt.Text.Substring(0, startIndex), rt, this)); + newRichTexts.Add(new XLRichString(rt.Text.Substring(0, startIndex), rt, this, OnContentChanged)); else if (startIndex < 0) startIndex = 0; @@ -112,12 +133,12 @@ public IXLFormattedText Substring(Int32 index, Int32 length) if (leftToTake > rt.Text.Length - startIndex) leftToTake = rt.Text.Length - startIndex; - XLRichString newRt = new XLRichString(rt.Text.Substring(startIndex, leftToTake), rt, this); + var newRt = new XLRichString(rt.Text.Substring(startIndex, leftToTake), rt, this, OnContentChanged); newRichTexts.Add(newRt); retVal.AddText(newRt); if (startIndex + leftToTake < rt.Text.Length) - newRichTexts.Add(new XLRichString(rt.Text.Substring(startIndex + leftToTake), rt, this)); + newRichTexts.Add(new XLRichString(rt.Text.Substring(startIndex + leftToTake), rt, this, OnContentChanged)); } else // We haven't reached the desired position yet { @@ -126,19 +147,25 @@ public IXLFormattedText Substring(Int32 index, Int32 length) lastPosition += rt.Text.Length; } _richTexts = newRichTexts; + OnContentChanged(); return retVal; } - public IEnumerator GetEnumerator() + public IXLFormattedText CopyFrom(IXLFormattedText original) { - return _richTexts.GetEnumerator(); - } + ClearText(); + foreach (var richText in original) + AddText(new XLRichString(richText.Text, richText, this, OnContentChanged)); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() - { - return GetEnumerator(); + return this; } + public List.Enumerator GetEnumerator() => _richTexts.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + public Boolean Bold { set { _richTexts.ForEach(rt => rt.Bold = value); } } public Boolean Italic { set { _richTexts.ForEach(rt => rt.Italic = value); } } public XLFontUnderlineValues Underline { set { _richTexts.ForEach(rt => rt.Underline = value); } } @@ -150,12 +177,17 @@ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() public String FontName { set { _richTexts.ForEach(rt => rt.FontName = value); } } public XLFontFamilyNumberingValues FontFamilyNumbering { set { _richTexts.ForEach(rt => rt.FontFamilyNumbering = value); } } - public IXLFormattedText SetBold() { Bold = true; return this; } public IXLFormattedText SetBold(Boolean value) { Bold = value; return this; } - public IXLFormattedText SetItalic() { Italic = true; return this; } public IXLFormattedText SetItalic(Boolean value) { Italic = value; return this; } - public IXLFormattedText SetUnderline() { Underline = XLFontUnderlineValues.Single; return this; } public IXLFormattedText SetUnderline(XLFontUnderlineValues value) { Underline = value; return this; } - public IXLFormattedText SetStrikethrough() { Strikethrough = true; return this; } public IXLFormattedText SetStrikethrough(Boolean value) { Strikethrough = value; return this; } + public IXLFormattedText SetBold() { Bold = true; return this; } + public IXLFormattedText SetBold(Boolean value) { Bold = value; return this; } + public IXLFormattedText SetItalic() { Italic = true; return this; } + public IXLFormattedText SetItalic(Boolean value) { Italic = value; return this; } + public IXLFormattedText SetUnderline() { Underline = XLFontUnderlineValues.Single; return this; } + public IXLFormattedText SetUnderline(XLFontUnderlineValues value) { Underline = value; return this; } + public IXLFormattedText SetStrikethrough() { Strikethrough = true; return this; } + public IXLFormattedText SetStrikethrough(Boolean value) { Strikethrough = value; return this; } public IXLFormattedText SetVerticalAlignment(XLFontVerticalTextAlignmentValues value) { VerticalAlignment = value; return this; } - public IXLFormattedText SetShadow() { Shadow = true; return this; } public IXLFormattedText SetShadow(Boolean value) { Shadow = value; return this; } + public IXLFormattedText SetShadow() { Shadow = true; return this; } + public IXLFormattedText SetShadow(Boolean value) { Shadow = value; return this; } public IXLFormattedText SetFontSize(Double value) { FontSize = value; return this; } public IXLFormattedText SetFontColor(XLColor value) { FontColor = value; return this; } public IXLFormattedText SetFontName(String value) { FontName = value; return this; } @@ -163,27 +195,27 @@ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() public bool Equals(IXLFormattedText other) { - Int32 count = Count; - if (count != other.Count) + if (other is null) return false; - for (Int32 i = 0; i < count; i++) - { - if (_richTexts.ElementAt(i) != other.ElementAt(i)) - return false; - } + if (ReferenceEquals(this, other)) + return true; - return _phonetics == null || Phonetics.Equals(other.Phonetics); - } + if (Count != other.Count) + return false; - public String Text { get { return ToString(); } } + if (!_richTexts.SequenceEqual(other)) + return false; - private IXLPhonetics _phonetics; - public IXLPhonetics Phonetics - { - get { return _phonetics ?? (_phonetics = new XLPhonetics(_defaultFont)); } + return (_phonetics is null && !other.HasPhonetics) || Phonetics.Equals(other.Phonetics); } - public Boolean HasPhonetics { get { return _phonetics != null; } } + /// + /// This method is called every time the formatted text is changed (new runs, font props, phonetics...). + /// + protected virtual void OnContentChanged() + { + // Do nothing, intended to be overriden. + } } } diff --git a/ClosedXML/Excel/RichText/XLImmutableRichText.cs b/ClosedXML/Excel/RichText/XLImmutableRichText.cs new file mode 100644 index 000000000..dd56f2d1a --- /dev/null +++ b/ClosedXML/Excel/RichText/XLImmutableRichText.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace ClosedXML.Excel +{ + /// + /// A class for holding in a . + /// It's immutable (keys in reverse dictionary can't change) and more memory efficient + /// than mutable rich text. + /// + [DebuggerDisplay("{Text}")] + internal sealed class XLImmutableRichText : IEquatable + { + private readonly RichTextRun[] _runs; + private readonly PhoneticRun[] _phoneticRuns; + + private XLImmutableRichText(string text, RichTextRun[] runs, PhoneticRun[] phoneticRuns, PhoneticProperties? phoneticsProps) + { + Text = text; + _runs = runs; + _phoneticRuns = phoneticRuns; + PhoneticsProperties = phoneticsProps; + } + + /// + /// A text of a whole rich text, without styling. + /// + public string Text { get; } + + /// + /// Individual rich text runs that make up the , in ascending order, non-overlapping. + /// + public IReadOnlyList Runs => _runs; + + /// + /// All phonetics runs of rich text. Empty array, if no phonetic run. In ascending order, non-overlapping. + /// + public IReadOnlyList PhoneticRuns => _phoneticRuns; + + /// + /// Properties used to display phonetic runs. + /// + public PhoneticProperties? PhoneticsProperties { get; } + + public bool Equals(XLImmutableRichText? other) + { + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + return Text == other.Text && + _runs.SequenceEqual(other._runs) && + _phoneticRuns.SequenceEqual(other._phoneticRuns) && + Nullable.Equals(PhoneticsProperties, other.PhoneticsProperties); + } + + public override bool Equals(object? obj) + { + return Equals(obj as XLImmutableRichText); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = Text.GetHashCode(); + hashCode = (hashCode * 397) ^ PhoneticsProperties.GetHashCode(); + foreach (var phoneticRun in _phoneticRuns) + hashCode = (hashCode * 397) ^ phoneticRun.GetHashCode(); + + foreach (var run in _runs) + hashCode = (hashCode * 397) ^ run.GetHashCode(); + + return hashCode; + } + } + + internal string GetRunText(RichTextRun run) => Text.Substring(run.StartIndex, run.Length); + + /// + /// Create an immutable rich text with same content as the original . + /// + internal static XLImmutableRichText Create(XLFormattedText formattedText) + { + var text = formattedText.Text; + var runs = new RichTextRun[formattedText.Count]; + var runIdx = 0; + var charStartIdx = 0; + foreach (var richString in formattedText) + { + runs[runIdx++] = new RichTextRun(richString, charStartIdx, richString.Text.Length); + charStartIdx += richString.Text.Length; + } + + PhoneticRun[] phoneticRuns; + PhoneticProperties? phoneticProps; + if (formattedText.HasPhonetics) + { + var rtPhonetics = formattedText.Phonetics; + phoneticRuns = new PhoneticRun[rtPhonetics.Count]; + var phoneticRunIdx = 0; + var prevPhoneticEndIdx = 0; + foreach (var phonetic in formattedText.Phonetics) + { + if (phonetic.Start >= text.Length) + throw new ArgumentException("Phonetic run start index must be within the text boundaries."); + + if (phonetic.End > text.Length) + throw new ArgumentException("Phonetic run end index must be at most length of a text."); + + if (phonetic.Start < prevPhoneticEndIdx) + throw new ArgumentException("Phonetic runs must be in ascending order and can't overlap."); + + phoneticRuns[phoneticRunIdx++] = new PhoneticRun(phonetic.Text, phonetic.Start, phonetic.End); + prevPhoneticEndIdx = phonetic.End; + } + + phoneticProps = new PhoneticProperties(formattedText.Phonetics); + } + else + { + phoneticRuns = Array.Empty(); + phoneticProps = null; + } + + return new XLImmutableRichText(text, runs, phoneticRuns, phoneticProps); + } + + internal readonly struct RichTextRun : IEquatable + { + internal readonly int StartIndex; + internal readonly int Length; + internal readonly XLFontValue Font; + + internal RichTextRun(XLRichString richString, int startIndex, int length) + { + var key = XLFont.GenerateKey(richString); + Font = XLFontValue.FromKey(ref key); + StartIndex = startIndex; + Length = length; + } + + public bool Equals(RichTextRun other) + { + return StartIndex == other.StartIndex && Length == other.Length && Font.Equals(other.Font); + } + + public override bool Equals(object? obj) + { + return obj is RichTextRun other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = StartIndex; + hashCode = (hashCode * 397) ^ Length; + hashCode = (hashCode * 397) ^ Font.GetHashCode(); + return hashCode; + } + } + } + + /// + /// Phonetic runs can't overlap and must be in order (i.e. start index must be ascending). + /// + internal readonly struct PhoneticRun + { + /// + /// Text that is displayed above a segment indicating how should segment be read. + /// + internal readonly string Text; + + /// + /// Starting index of displayed phonetic (first character is 0). + /// + internal readonly int StartIndex; + + /// + /// End index, excluding (the last index is a length of the rich text). + /// + internal readonly int EndIndex; + + public PhoneticRun(string text, int startIndex, int endIndex) + { + if (text.Length == 0) + throw new ArgumentException("Phonetic run text can't be empty.", nameof(text)); + + if (startIndex < 0) + throw new ArgumentException("Start index index must be greater than 0.", nameof(startIndex)); + + if (startIndex >= endIndex) + throw new ArgumentException("Start index must be less than end index.", nameof(endIndex)); + + Text = text; + StartIndex = startIndex; + EndIndex = endIndex; + } + + public bool Equals(PhoneticRun other) + { + return Text == other.Text && StartIndex == other.StartIndex && EndIndex == other.EndIndex; + } + + public override bool Equals(object? obj) + { + return obj is PhoneticRun other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = Text.GetHashCode(); + hashCode = (hashCode * 397) ^ StartIndex; + hashCode = (hashCode * 397) ^ EndIndex; + return hashCode; + } + } + } + + /// + /// Properties of phonetic runs. All phonetic runs of a rich text have same font and other properties. + /// + internal readonly struct PhoneticProperties + { + /// + /// Font used for text of phonetic runs. All phonetic runs use same font. There can be no phonetic runs, + /// but with specified font (e.g. the mutable API has only specified font, but no text yet). + /// + public readonly XLFontValue Font; + + /// + /// Type of phonetics. Default is + /// + public readonly XLPhoneticType Type; + + /// + /// Alignment of phonetics. Default is + /// + public readonly XLPhoneticAlignment Alignment; + + public PhoneticProperties(XLPhonetics rtPhonetics) + { + var fontKey = XLFont.GenerateKey(rtPhonetics); + Font = XLFontValue.FromKey(ref fontKey); + Type = rtPhonetics.Type; + Alignment = rtPhonetics.Alignment; + } + + public bool Equals(PhoneticProperties other) + { + return Font.Equals(other.Font) && Type == other.Type && Alignment == other.Alignment; + } + + public override bool Equals(object? obj) + { + return obj is PhoneticProperties other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = Font.GetHashCode(); + hashCode = (hashCode * 397) ^ (int)Type; + hashCode = (hashCode * 397) ^ (int)Alignment; + return hashCode; + } + } + } + } +} diff --git a/ClosedXML/Excel/RichText/XLPhonetic.cs b/ClosedXML/Excel/RichText/XLPhonetic.cs index 253a24fd1..37c2f1723 100644 --- a/ClosedXML/Excel/RichText/XLPhonetic.cs +++ b/ClosedXML/Excel/RichText/XLPhonetic.cs @@ -10,15 +10,18 @@ public XLPhonetic(String text, Int32 start, Int32 end) Start = start; End = end; } - public String Text { get; set; } - public Int32 Start { get; set; } - public Int32 End { get; set; } + public String Text { get; } + public Int32 Start { get; } + public Int32 End { get; } - public bool Equals(IXLPhonetic other) + public bool Equals(IXLPhonetic? other) { - if (other == null) + if (other is null) return false; + if (ReferenceEquals(this, other)) + return true; + return Text == other.Text && Start == other.Start && End == other.End; } } diff --git a/ClosedXML/Excel/RichText/XLPhonetics.cs b/ClosedXML/Excel/RichText/XLPhonetics.cs index b0482673c..821998ce8 100644 --- a/ClosedXML/Excel/RichText/XLPhonetics.cs +++ b/ClosedXML/Excel/RichText/XLPhonetics.cs @@ -6,38 +6,172 @@ namespace ClosedXML.Excel { internal class XLPhonetics : IXLPhonetics { - private readonly List _phonetics = new List(); - + private readonly List _phonetics = new(); + private readonly XLFont _font; private readonly IXLFontBase _defaultFont; + private readonly Action _onChange; + private XLPhoneticAlignment _alignment; + private XLPhoneticType _type; - public XLPhonetics(IXLFontBase defaultFont) + public XLPhonetics(IXLFontBase defaultFont, Action onChange) { _defaultFont = defaultFont; - Type = XLPhoneticType.FullWidthKatakana; - Alignment = XLPhoneticAlignment.Left; - this.CopyFont(_defaultFont); + _font = new XLFont(defaultFont); + _type = XLPhoneticType.FullWidthKatakana; + _alignment = XLPhoneticAlignment.Left; + _onChange = onChange; } - public XLPhonetics(IXLPhonetics defaultPhonetics, IXLFontBase defaultFont) + public XLPhonetics(IXLPhonetics defaultPhonetics, IXLFontBase defaultFont, Action onChange) { _defaultFont = defaultFont; - Type = defaultPhonetics.Type; - Alignment = defaultPhonetics.Alignment; + _font = new XLFont(defaultPhonetics); + _type = defaultPhonetics.Type; + _alignment = defaultPhonetics.Alignment; + _onChange = onChange; + } + + public Int32 Count => _phonetics.Count; + + public Boolean Bold + { + get => _font.Bold; + set + { + _font.Bold = value; + _onChange(); + } + } + + public Boolean Italic + { + get => _font.Italic; + set + { + _font.Italic = value; + _onChange(); + } + } + + public XLFontUnderlineValues Underline + { + get => _font.Underline; + set + { + _font.Underline = value; + _onChange(); + } + } + + public Boolean Strikethrough + { + get => _font.Strikethrough; + set + { + _font.Strikethrough = value; + _onChange(); + } + } + + public XLFontVerticalTextAlignmentValues VerticalAlignment + { + get => _font.VerticalAlignment; + set + { + _font.VerticalAlignment = value; + _onChange(); + } + } + + public Boolean Shadow + { + get => _font.Shadow; + set + { + _font.Shadow = value; + _onChange(); + } + } - this.CopyFont(defaultPhonetics); + public Double FontSize + { + get => _font.FontSize; + set + { + _font.FontSize = value; + _onChange(); + } } - public Boolean Bold { get; set; } - public Boolean Italic { get; set; } - public XLFontUnderlineValues Underline { get; set; } - public Boolean Strikethrough { get; set; } - public XLFontVerticalTextAlignmentValues VerticalAlignment { get; set; } - public Boolean Shadow { get; set; } - public Double FontSize { get; set; } - public XLColor FontColor { get; set; } - public String FontName { get; set; } - public XLFontFamilyNumberingValues FontFamilyNumbering { get; set; } - public XLFontCharSet FontCharSet { get; set; } + public XLColor FontColor + { + get => _font.FontColor; + set + { + _font.FontColor = value; + _onChange(); + } + } + + public String FontName + { + get => _font.FontName; + set + { + _font.FontName = value; + _onChange(); + } + } + + public XLFontFamilyNumberingValues FontFamilyNumbering + { + get => _font.FontFamilyNumbering; + set + { + _font.FontFamilyNumbering = value; + _onChange(); + } + } + + public XLFontCharSet FontCharSet + { + get => _font.FontCharSet; + set + { + _font.FontCharSet = value; + _onChange(); + } + } + + public XLFontScheme FontScheme + { + get => _font.FontScheme; + set + { + _font.FontScheme = value; + _onChange(); + } + } + + public XLPhoneticAlignment Alignment + { + get => _alignment; + set + { + _alignment = value; + _onChange(); + } + } + + public XLPhoneticType Type + { + get => _type; + set + { + _type = value; + _onChange(); + } + } public IXLPhonetics SetBold() { Bold = true; return this; } @@ -71,33 +205,33 @@ public XLPhonetics(IXLPhonetics defaultPhonetics, IXLFontBase defaultFont) public IXLPhonetics SetFontCharSet(XLFontCharSet value) { FontCharSet = value; return this; } + public IXLPhonetics SetFontScheme(XLFontScheme value) { FontScheme = value; return this; } + + public IXLPhonetics SetAlignment(XLPhoneticAlignment phoneticAlignment) { Alignment = phoneticAlignment; return this; } + + public IXLPhonetics SetType(XLPhoneticType phoneticType) { Type = phoneticType; return this; } + public IXLPhonetics Add(String text, Int32 start, Int32 end) { _phonetics.Add(new XLPhonetic(text, start, end)); + _onChange(); return this; } public IXLPhonetics ClearText() { _phonetics.Clear(); + _onChange(); return this; } public IXLPhonetics ClearFont() { this.CopyFont(_defaultFont); + _onChange(); return this; } - public Int32 Count { get { return _phonetics.Count; } } - - public XLPhoneticAlignment Alignment { get; set; } - public XLPhoneticType Type { get; set; } - - public IXLPhonetics SetAlignment(XLPhoneticAlignment phoneticAlignment) { Alignment = phoneticAlignment; return this; } - - public IXLPhonetics SetType(XLPhoneticType phoneticType) { Type = phoneticType; return this; } - public IEnumerator GetEnumerator() { return _phonetics.GetEnumerator(); @@ -108,29 +242,23 @@ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() return GetEnumerator(); } - public bool Equals(IXLPhonetics other) + public bool Equals(IXLPhonetics? other) => Equals(other as XLPhonetics); + + public bool Equals(XLPhonetics? other) { - if (other == null) + if (other is null) return false; - Int32 phoneticsCount = _phonetics.Count; - for (Int32 i = 0; i < phoneticsCount; i++) - { - if (!_phonetics[i].Equals(other.ElementAt(i))) - return false; - } + if (ReferenceEquals(this, other)) + return true; + + if (!_phonetics.SequenceEqual(other._phonetics)) + return false; return - Bold == other.Bold - && Italic == other.Italic - && Underline == other.Underline - && Strikethrough == other.Strikethrough - && VerticalAlignment == other.VerticalAlignment - && Shadow == other.Shadow - && FontSize == other.FontSize - && FontColor.Equals(other.FontColor) - && FontName == other.FontName - && FontFamilyNumbering == other.FontFamilyNumbering; + _font.Key.Equals(other._font.Key) && + Type == other.Type && + Alignment == other.Alignment; } } } diff --git a/ClosedXML/Excel/RichText/XLRichString.cs b/ClosedXML/Excel/RichText/XLRichString.cs index 5a82aabb7..2ff288b73 100644 --- a/ClosedXML/Excel/RichText/XLRichString.cs +++ b/ClosedXML/Excel/RichText/XLRichString.cs @@ -6,16 +6,28 @@ namespace ClosedXML.Excel [DebuggerDisplay("{Text}")] internal class XLRichString : IXLRichString { - private IXLWithRichString _withRichString; + private readonly IXLWithRichString _withRichString; + private readonly XLFont _font; + private readonly Action _onChange; + private string _text; - public XLRichString(String text, IXLFontBase font, IXLWithRichString withRichString) + public XLRichString(String text, IXLFontBase font, IXLWithRichString withRichString, Action? onChange) { - Text = text; - this.CopyFont(font); + _text = text; + _font = new XLFont(font); _withRichString = withRichString; + _onChange = onChange ?? (() => { }); } - public String Text { get; set; } + public String Text + { + get => _text; + set + { + _text = value; + _onChange(); + } + } public IXLRichString AddText(String text) { @@ -27,17 +39,125 @@ public IXLRichString AddNewLine() return AddText(Environment.NewLine); } - public Boolean Bold { get; set; } - public Boolean Italic { get; set; } - public XLFontUnderlineValues Underline { get; set; } - public Boolean Strikethrough { get; set; } - public XLFontVerticalTextAlignmentValues VerticalAlignment { get; set; } - public Boolean Shadow { get; set; } - public Double FontSize { get; set; } - public XLColor FontColor { get; set; } - public String FontName { get; set; } - public XLFontFamilyNumberingValues FontFamilyNumbering { get; set; } - public XLFontCharSet FontCharSet { get; set; } + public Boolean Bold + { + get => _font.Bold; + set + { + _font.Bold = value; + _onChange(); + } + } + + public Boolean Italic + { + get => _font.Italic; + set + { + _font.Italic = value; + _onChange(); + } + } + + public XLFontUnderlineValues Underline + { + get => _font.Underline; + set + { + _font.Underline = value; + _onChange(); + } + } + + public Boolean Strikethrough + { + get => _font.Strikethrough; + set + { + _font.Strikethrough = value; + _onChange(); + } + } + + public XLFontVerticalTextAlignmentValues VerticalAlignment + { + get => _font.VerticalAlignment; + set + { + _font.VerticalAlignment = value; + _onChange(); + } + } + + public Boolean Shadow + { + get => _font.Shadow; + set + { + _font.Shadow = value; + _onChange(); + } + } + + public Double FontSize + { + get => _font.FontSize; + set + { + _font.FontSize = value; + _onChange(); + } + } + + public XLColor FontColor + { + get => _font.FontColor; + set + { + _font.FontColor = value; + _onChange(); + } + } + + public String FontName + { + get => _font.FontName; + set + { + _font.FontName = value; + _onChange(); + } + } + + public XLFontFamilyNumberingValues FontFamilyNumbering + { + get => _font.FontFamilyNumbering; + set + { + _font.FontFamilyNumbering = value; + _onChange(); + } + } + + public XLFontCharSet FontCharSet + { + get => _font.FontCharSet; + set + { + _font.FontCharSet = value; + _onChange(); + } + } + + public XLFontScheme FontScheme + { + get => _font.FontScheme; + set + { + _font.FontScheme = value; + _onChange(); + } + } public IXLRichString SetBold() { @@ -119,41 +239,31 @@ public IXLRichString SetFontCharSet(XLFontCharSet value) FontCharSet = value; return this; } - public Boolean Equals(IXLRichString other) + public IXLRichString SetFontScheme(XLFontScheme value) { - return - Text == other.Text - && Bold.Equals(other.Bold) - && Italic.Equals(other.Italic) - && Underline.Equals(other.Underline) - && Strikethrough.Equals(other.Strikethrough) - && VerticalAlignment.Equals(other.VerticalAlignment) - && Shadow.Equals(other.Shadow) - && FontSize.Equals(other.FontSize) - && FontColor.Equals(other.FontColor) - && FontName.Equals(other.FontName) - && FontFamilyNumbering.Equals(other.FontFamilyNumbering) - ; + FontScheme = value; return this; } - public override bool Equals(object obj) + public override bool Equals(object? obj) => Equals(obj as XLRichString); + + public Boolean Equals(IXLRichString? other) => Equals(other as XLRichString); + + public Boolean Equals(XLRichString? other) { - return Equals((XLRichString)obj); + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + return Text == other.Text && _font.Key.Equals(other._font.Key); } public override int GetHashCode() { - return Text.GetHashCode() - ^ Bold.GetHashCode() - ^ Italic.GetHashCode() - ^ (Int32)Underline - ^ Strikethrough.GetHashCode() - ^ (Int32)VerticalAlignment - ^ Shadow.GetHashCode() - ^ FontSize.GetHashCode() - ^ FontColor.GetHashCode() - ^ FontName.GetHashCode() - ^ (Int32)FontFamilyNumbering; + // Since all properties of type are mutable, can't have different hashcode for any instance. + // Don't ever use this class in a dictionary, e.g. SST. + return 4; // Chosen by fair dice roll. Guaranteed to be random. } } } diff --git a/ClosedXML/Excel/RichText/XLRichText.cs b/ClosedXML/Excel/RichText/XLRichText.cs index aaad29a4b..7de5d965d 100644 --- a/ClosedXML/Excel/RichText/XLRichText.cs +++ b/ClosedXML/Excel/RichText/XLRichText.cs @@ -1,30 +1,74 @@ using System; -using System.Collections.Generic; -using System.Text; using System.Linq; namespace ClosedXML.Excel { - internal class XLRichText: XLFormattedText, IXLRichText + internal class XLRichText : XLFormattedText, IXLRichText { - - public XLRichText(IXLFontBase defaultFont) - :base(defaultFont) + // Should be set as the last thing in ctor to prevent firing changes to immutable rich text during ctor + private readonly XLCell? _cell; + + /// + /// Copy ctor to return user modifiable rich text from immutable rich text stored + /// in the shared string table. + /// + public XLRichText(XLCell cell, XLImmutableRichText original) + : base(cell.Style.Font) { + foreach (var originalRun in original.Runs) + { + var runText = original.GetRunText(originalRun); + AddText(new XLRichString(runText, new XLFont(originalRun.Font.Key), this, OnContentChanged)); + } + + var hasPhonetics = original.PhoneticRuns.Any() || original.PhoneticsProperties.HasValue; + if (hasPhonetics) + { + // Access to phonetics instantiate a new instance. + var phonetics = Phonetics; + if (original.PhoneticsProperties.HasValue) + { + var phoneticProps = original.PhoneticsProperties.Value; + phonetics.CopyFont(new XLFont(phoneticProps.Font.Key)); + phonetics.Type = phoneticProps.Type; + phonetics.Alignment = phoneticProps.Alignment; + } + + foreach (var phoneticRun in original.PhoneticRuns) + phonetics.Add(phoneticRun.Text, phoneticRun.StartIndex, phoneticRun.EndIndex); + } + Container = this; + _cell = cell; } - public XLRichText(XLFormattedText defaultRichText, IXLFontBase defaultFont) - :base(defaultRichText, defaultFont) + public XLRichText(XLCell cell, IXLFontBase defaultFont) + : base(defaultFont) { Container = this; + _cell = cell; } - public XLRichText(String text, IXLFontBase defaultFont) - :base(text, defaultFont) + public XLRichText(XLCell cell, String text, IXLFontBase defaultFont) + : base(text, defaultFont) { Container = this; + _cell = cell; } + protected override void OnContentChanged() + { + // The rich text is still being created + if (_cell is null) + return; + + if (_cell.DataType != XLDataType.Text || !_cell.HasRichText) + throw new InvalidOperationException("The rich text isn't a content of a cell."); + + _cell.SetOnlyValue(Text); + var point = _cell.SheetPoint; + var richText = XLImmutableRichText.Create(this); + _cell.Worksheet.Internals.CellsCollection.ValueSlice.SetRichText(point, richText); + } } } diff --git a/ClosedXML/Excel/Rows/IXLRow.cs b/ClosedXML/Excel/Rows/IXLRow.cs index 0ef9f02c4..48503b7d5 100644 --- a/ClosedXML/Excel/Rows/IXLRow.cs +++ b/ClosedXML/Excel/Rows/IXLRow.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -61,7 +63,14 @@ public interface IXLRow : IXLRangeBase IXLRow AdjustToContents(Int32 startColumn, Double minHeight, Double maxHeight); - IXLRow AdjustToContents(Int32 startColumn, Int32 endColumn, Double minHeight, Double maxHeight); + /// + /// Adjust height of the column according to the content of the cells. + /// + /// Number of a first column whose content is considered. + /// Number of a last column whose content is considered. + /// Minimum height of adjusted column, in points. + /// Maximum height of adjusted column, in points. + IXLRow AdjustToContents(Int32 startColumn, Int32 endColumn, Double minHeightPt, Double maxHeightPt); /// Hides this row. IXLRow Hide(); @@ -184,8 +193,6 @@ public interface IXLRow : IXLRangeBase /// IXLRow AddHorizontalPageBreak(); - IXLRow SetDataType(XLDataType dataType); - IXLRow RowAbove(); IXLRow RowAbove(Int32 step); @@ -200,9 +207,6 @@ public interface IXLRow : IXLRangeBase /// Specify what you want to clear. new IXLRow Clear(XLClearOptions clearOptions = XLClearOptions.All); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRangeRow RowUsed(Boolean includeFormats); - IXLRangeRow RowUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents); } } diff --git a/ClosedXML/Excel/Rows/IXLRows.cs b/ClosedXML/Excel/Rows/IXLRows.cs index bf8adf752..170c74f3e 100644 --- a/ClosedXML/Excel/Rows/IXLRows.cs +++ b/ClosedXML/Excel/Rows/IXLRows.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; @@ -106,10 +108,7 @@ public interface IXLRows : IEnumerable /// /// Returns the collection of cells that have a value. /// - /// if set to true will return all cells with a value or a style different than the default. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCells CellsUsed(Boolean includeFormats); - + /// The options to determine whether a cell is used. IXLCells CellsUsed(XLCellsUsedOptions options); IXLStyle Style { get; set; } @@ -119,8 +118,6 @@ public interface IXLRows : IEnumerable /// IXLRows AddHorizontalPageBreaks(); - IXLRows SetDataType(XLDataType dataType); - /// /// Clears the contents of these rows. /// diff --git a/ClosedXML/Excel/Rows/XLRow.cs b/ClosedXML/Excel/Rows/XLRow.cs index 873739ad6..6d087a67c 100644 --- a/ClosedXML/Excel/Rows/XLRow.cs +++ b/ClosedXML/Excel/Rows/XLRow.cs @@ -1,13 +1,18 @@ +using ClosedXML.Graphics; using System; using System.Collections.Generic; using System.Linq; namespace ClosedXML.Excel { - internal class XLRow : XLRangeBase, IXLRow + internal sealed class XLRow : XLRangeBase, IXLRow { #region Private fields + /// + /// Don't use directly, use properties. + /// + private XlRowFlags _flags; private Double _height; private Int32 _outlineLevel; @@ -33,37 +38,88 @@ public override XLRangeType RangeType get { return XLRangeType.Row; } } - public override IEnumerable Styles + protected override IEnumerable Children { get { - yield return Style; - int row = RowNumber(); foreach (XLCell cell in Worksheet.Internals.CellsCollection.GetCellsInRow(row)) - yield return cell.Style; + yield return cell; } } - protected override IEnumerable Children + public Boolean Collapsed { - get + get => _flags.HasFlag(XlRowFlags.Collapsed); + set { - int row = RowNumber(); - - foreach (XLCell cell in Worksheet.Internals.CellsCollection.GetCellsInRow(row)) - yield return cell; + if (value) + _flags |= XlRowFlags.Collapsed; + else + _flags &= ~XlRowFlags.Collapsed; } } - public Boolean Collapsed { get; set; } + /// + /// Distance in pixels from the bottom of the cells in the current row to the typographical + /// baseline of the cell content if, hypothetically, the zoom level for the sheet containing + /// this row is 100 percent and the cell has bottom-alignment formatting. + /// + /// + /// If the attribute is set, it sets customHeight to true even if the customHeight is explicitly + /// set to false. Custom height means no auto-sizing by Excel on load, so if row has this + /// attribute, it stops Excel from auto-sizing the height of a row to fit the content on load. + /// + public Double? DyDescent { get; set; } - #region IXLRow Members + /// + /// Should cells in the row display phonetic? This doesn't actually affect whether the phonetic are + /// shown in the row, that depends entirely on the property + /// of a cell. This property determines whether a new cell in the row will have it's phonetic turned on + /// (and also the state of the "Show or hide phonetic" in Excel when whole row is selected). + /// Default is false. + /// + public Boolean ShowPhonetic + { + get => _flags.HasFlag(XlRowFlags.ShowPhonetic); + set + { + if (value) + _flags |= XlRowFlags.ShowPhonetic; + else + _flags &= ~XlRowFlags.ShowPhonetic; + } + } - public Boolean Loading { get; set; } + public Boolean Loading + { + get => _flags.HasFlag(XlRowFlags.Loading); + set + { + if (value) + _flags |= XlRowFlags.Loading; + else + _flags &= ~XlRowFlags.Loading; + } + } - public Boolean HeightChanged { get; private set; } + /// + /// Does row have an individual height or is it derived from the worksheet ? + /// + public Boolean HeightChanged + { + get => _flags.HasFlag(XlRowFlags.HeightChanged); + private set + { + if (value) + _flags |= XlRowFlags.HeightChanged; + else + _flags &= ~XlRowFlags.HeightChanged; + } + } + + #region IXLRow Members public Double Height { @@ -77,6 +133,10 @@ public Double Height } } + IXLCells IXLRow.Cells(String cellsInRow) => Cells(cellsInRow); + + IXLCells IXLRow.Cells(Int32 firstColumn, Int32 lastColumn) => Cells(firstColumn, lastColumn); + public void ClearHeight() { Height = Worksheet.RowHeight; @@ -158,7 +218,7 @@ public override IXLCells Cells() return Cells(true, XLCellsUsedOptions.All); } - public override IXLCells Cells(Boolean usedCellsOnly) + public override XLCells Cells(Boolean usedCellsOnly) { if (usedCellsOnly) return Cells(true, XLCellsUsedOptions.AllContents); @@ -166,7 +226,7 @@ public override IXLCells Cells(Boolean usedCellsOnly) return Cells(FirstCellUsed().Address.ColumnNumber, LastCellUsed().Address.ColumnNumber); } - public override IXLCells Cells(String cellsInRow) + public override XLCells Cells(String cellsInRow) { var retVal = new XLCells(false, XLCellsUsedOptions.AllContents); var rangePairs = cellsInRow.Split(','); @@ -175,7 +235,7 @@ public override IXLCells Cells(String cellsInRow) return retVal; } - public IXLCells Cells(Int32 firstColumn, Int32 lastColumn) + public XLCells Cells(Int32 firstColumn, Int32 lastColumn) { return Cells(firstColumn + ":" + lastColumn); } @@ -206,87 +266,101 @@ public IXLRow AdjustToContents(Int32 startColumn, Double minHeight, Double maxHe return AdjustToContents(startColumn, XLHelper.MaxColumnNumber, minHeight, maxHeight); } - public IXLRow AdjustToContents(Int32 startColumn, Int32 endColumn, Double minHeight, Double maxHeight) + public IXLRow AdjustToContents(Int32 startColumn, Int32 endColumn, Double minHeightPt, Double maxHeightPt) { var engine = Worksheet.Workbook.GraphicEngine; - var dpiY = Worksheet.Workbook.DpiY; - Double rowMaxHeight = minHeight; - foreach (XLCell c in from XLCell c in Row(startColumn, endColumn).CellsUsed() where !c.IsMerged() select c) + var dpi = new Dpi(Worksheet.Workbook.DpiX, Worksheet.Workbook.DpiY); + + var rowHeightPx = CalculateMinRowHeight(startColumn, endColumn, engine, dpi); + + var rowHeightPt = XLHelper.PixelsToPoints(rowHeightPx, dpi.Y); + if (rowHeightPt <= 0) + rowHeightPt = Worksheet.RowHeight; + + if (minHeightPt > rowHeightPt) + rowHeightPt = minHeightPt; + + if (maxHeightPt < rowHeightPt) + rowHeightPt = maxHeightPt; + + Height = rowHeightPt; + + return this; + } + + private int CalculateMinRowHeight(int startColumn, int endColumn, IXLGraphicEngine engine, Dpi dpi) + { + var glyphs = new List(); + XLStyle? cellStyle = null; + var rowHeightPx = 0; + foreach (var cell in Row(startColumn, endColumn).CellsUsed().Cast()) + { + // Clear maintains capacity -> reduce need for GC + glyphs.Clear(); + + if (cell.IsMerged()) + continue; + + // Reuse styles if possible to reduce memory consumption + if (cellStyle is null || cellStyle.Value != cell.StyleValue) + cellStyle = (XLStyle)cell.Style; + + cell.GetGlyphBoxes(engine, dpi, glyphs); + var cellHeightPx = (int)Math.Ceiling(GetContentHeight(cellStyle.Alignment.TextRotation, glyphs)); + + rowHeightPx = Math.Max(cellHeightPx, rowHeightPx); + } + + return rowHeightPx; + } + + private static double GetContentHeight(int textRotationDeg, List glyphs) + { + if (textRotationDeg == 0) { - Double thisHeight; - Int32 textRotation = c.StyleValue.Alignment.TextRotation; - if (c.HasRichText || textRotation != 0 || c.InnerText.Contains(Environment.NewLine)) + var textHeight = 0d; + var lineMaxHeight = 0d; + foreach (var glyph in glyphs) { - var kpList = new List>(); - if (c.HasRichText) - { - foreach (IXLRichString rt in c.GetRichText()) - { - String formattedString = rt.Text; - var arr = formattedString.Split(new[] { Environment.NewLine }, StringSplitOptions.None); - Int32 arrCount = arr.Count(); - for (Int32 i = 0; i < arrCount; i++) - { - String s = arr[i]; - if (i < arrCount - 1) - s += Environment.NewLine; - kpList.Add(new KeyValuePair(rt, s)); - } - } - } - else + if (!glyph.IsLineBreak) { - String formattedString = c.GetFormattedString(); - var arr = formattedString.Split(new[] { Environment.NewLine }, StringSplitOptions.None); - Int32 arrCount = arr.Count(); - for (Int32 i = 0; i < arrCount; i++) - { - String s = arr[i]; - if (i < arrCount - 1) - s += Environment.NewLine; - kpList.Add(new KeyValuePair(c.Style.Font, s)); - } + var cellHeightPx = glyph.LineHeight; + lineMaxHeight = Math.Max(cellHeightPx, lineMaxHeight); } - - Double maxLongCol = kpList.Max(kp => kp.Value.Length); - Double maxHeightCol = XLHelper.PixelsToPoints(kpList.Max(kp => engine.GetTextHeight(kp.Key, dpiY)), dpiY); - Int32 lineCount = kpList.Count(kp => kp.Value.Contains(Environment.NewLine)) + 1; - if (textRotation == 0) - thisHeight = maxHeightCol * lineCount; else { - if (textRotation == 255) - thisHeight = maxLongCol * maxHeightCol; - else - { - Double rotation; - if (textRotation == 90 || textRotation == 180) - rotation = 90; - else - rotation = textRotation % 90; - - thisHeight = (rotation / 90.0) * maxHeightCol * maxLongCol * 0.5; - } + // At the end of each line, add height of the line to total height. + textHeight += lineMaxHeight; + lineMaxHeight = 0d; } } - else - thisHeight = XLHelper.PixelsToPoints(engine.GetTextHeight(c.Style.Font, dpiY), dpiY); - if (thisHeight >= maxHeight) + // If the last line ends without EOL, it must be also counted + textHeight += lineMaxHeight; + + return textHeight; + } + else if (textRotationDeg == 255) + { + // Glyphs are vertically aligned. + var textHeight = glyphs.Sum(static g => g.LineHeight); + return textHeight; + } + else + { + // Rotated text + var width = 0d; + var height = 0d; + foreach (var glyph in glyphs) { - rowMaxHeight = maxHeight; - break; + width += glyph.AdvanceWidth; + height = Math.Max(glyph.LineHeight, height); } - if (thisHeight > rowMaxHeight) - rowMaxHeight = thisHeight; - } - - if (rowMaxHeight <= 0) - rowMaxHeight = Worksheet.RowHeight; - Height = rowMaxHeight; - - return this; + var projectedWidth = Math.Sin(XLHelper.DegToRad(textRotationDeg)) * width; + var projectedHeight = Math.Cos(XLHelper.DegToRad(textRotationDeg)) * height; + return projectedWidth + projectedHeight; + } } public IXLRow Hide() @@ -301,7 +375,17 @@ public IXLRow Unhide() return this; } - public Boolean IsHidden { get; set; } + public Boolean IsHidden + { + get => _flags.HasFlag(XlRowFlags.IsHidden); + set + { + if (value) + _flags |= XlRowFlags.IsHidden; + else + _flags &= ~XlRowFlags.IsHidden; + } + } public Int32 OutlineLevel { @@ -441,20 +525,6 @@ public IXLRow AddHorizontalPageBreak() return this; } - public IXLRow SetDataType(XLDataType dataType) - { - DataType = dataType; - return this; - } - - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLRangeRow RowUsed(Boolean includeFormats) - { - return RowUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - public IXLRangeRow RowUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents) { return Row((this as IXLRangeBase).FirstCellUsed(options), @@ -475,7 +545,7 @@ internal override void WorksheetRangeShiftedColumns(XLRange range, int columnsSh internal override void WorksheetRangeShiftedRows(XLRange range, int rowsShifted) { - return; // rows are shifted by XLRowCollection + // rows are shifted by XLRowCollection } internal void SetRowNumber(Int32 row) @@ -600,5 +670,18 @@ public override Boolean IsEntireColumn() { return false; } + + /// + /// Flag enum to save space, instead of wasting byte for each flag. + /// + [Flags] + private enum XlRowFlags : byte + { + Collapsed = 1, + IsHidden = 2, + ShowPhonetic = 4, + HeightChanged = 8, + Loading = 16 + } } } diff --git a/ClosedXML/Excel/Rows/XLRowCollection.cs b/ClosedXML/Excel/Rows/XLRowCollection.cs index 1059b3ad1..a1a7dcb25 100644 --- a/ClosedXML/Excel/Rows/XLRowCollection.cs +++ b/ClosedXML/Excel/Rows/XLRowCollection.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -20,9 +22,7 @@ public void Add(int key, XLRow value) { if (key > MaxRowUsed) MaxRowUsed = key; - if (Deleted.ContainsKey(key)) - Deleted.Remove(key); - + Deleted.Remove(key); _dictionary.Add(key, value); } @@ -64,9 +64,7 @@ public void Add(KeyValuePair item) { if (item.Key > MaxRowUsed) MaxRowUsed = item.Key; - if (Deleted.ContainsKey(item.Key)) - Deleted.Remove(item.Key); - + Deleted.Remove(item.Key); _dictionary.Add(item.Key, item.Value); } diff --git a/ClosedXML/Excel/Rows/XLRows.cs b/ClosedXML/Excel/Rows/XLRows.cs index f4d744b90..2db061e54 100644 --- a/ClosedXML/Excel/Rows/XLRows.cs +++ b/ClosedXML/Excel/Rows/XLRows.cs @@ -9,14 +9,13 @@ namespace ClosedXML.Excel internal class XLRows : XLStylizedBase, IXLRows, IXLStylized { private readonly List _rowsCollection = new List(); - private readonly XLWorksheet _worksheet; + private readonly XLWorksheet? _worksheet; private bool IsMaterialized => _lazyEnumerable == null; - private IEnumerable _lazyEnumerable; + private IEnumerable? _lazyEnumerable; private IEnumerable Rows => _lazyEnumerable ?? _rowsCollection.AsEnumerable(); - /// /// Create a new instance of . /// @@ -24,7 +23,7 @@ internal class XLRows : XLStylizedBase, IXLRows, IXLStylized /// all rows on a worksheet so changing its height will affect all rows. /// Default style to use when initializing child entries. /// A predefined enumerator of to support lazy initialization. - public XLRows(XLWorksheet worksheet, XLStyleValue defaultStyle = null, IEnumerable lazyEnumerable = null) + public XLRows(XLWorksheet? worksheet, XLStyleValue? defaultStyle = null, IEnumerable? lazyEnumerable = null) : base(defaultStyle) { _worksheet = worksheet; @@ -185,14 +184,6 @@ public IXLCells CellsUsed() return cells; } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLCells CellsUsed(Boolean includeFormats) - { - return CellsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - public IXLCells CellsUsed(XLCellsUsedOptions options) { var cells = new XLCells(true, options); @@ -208,15 +199,10 @@ public IXLRows AddHorizontalPageBreaks() return this; } - public IXLRows SetDataType(XLDataType dataType) - { - Rows.ForEach(c => c.DataType = dataType); - return this; - } - #endregion IXLRows Members #region IXLStylized Members + protected override IEnumerable Children { get @@ -231,23 +217,6 @@ protected override IEnumerable Children } } - public override IEnumerable Styles - { - get - { - yield return Style; - if (_worksheet != null) - yield return _worksheet.Style; - else - { - foreach (IXLStyle s in Rows.SelectMany(row => row.Styles)) - { - yield return s; - } - } - } - } - public override IXLRanges RangesUsed { get diff --git a/ClosedXML/Excel/SaveOptions.cs b/ClosedXML/Excel/SaveOptions.cs index 83cf9821a..c499e11cd 100644 --- a/ClosedXML/Excel/SaveOptions.cs +++ b/ClosedXML/Excel/SaveOptions.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; namespace ClosedXML.Excel @@ -16,6 +18,19 @@ public SaveOptions() public Boolean ConsolidateConditionalFormatRanges { get; set; } = true; public Boolean ConsolidateDataValidationRanges { get; set; } = true; + + /// + /// Evaluate a cells with a formula and save the calculated value along with the formula. + /// + /// + /// True - formulas are evaluated and the calculated values are saved to the file. + /// If evaluation of a formula throws an exception, value is not saved but file is still saved. + /// + /// + /// False (default) - formulas are not evaluated and the formula cells don't have their values saved to the file. + /// + /// + /// public Boolean EvaluateFormulasBeforeSaving { get; set; } = false; /// diff --git a/ClosedXML/Excel/Sparkline/IXLSparkLineGroup.cs b/ClosedXML/Excel/Sparkline/IXLSparkLineGroup.cs index e3ad63158..8a36a23e7 100644 --- a/ClosedXML/Excel/Sparkline/IXLSparkLineGroup.cs +++ b/ClosedXML/Excel/Sparkline/IXLSparkLineGroup.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; @@ -74,6 +76,10 @@ public interface IXLSparklineGroup : IEnumerable void CopyFrom(IXLSparklineGroup sparklineGroup); + /// + /// Copy this sparkline group to the specified worksheet + /// + /// The worksheet to copy this sparkline group to IXLSparklineGroup CopyTo(IXLWorksheet targetSheet); IXLSparkline GetSparkline(IXLCell cell); diff --git a/ClosedXML/Excel/Sparkline/IXLSparkLineGroups.cs b/ClosedXML/Excel/Sparkline/IXLSparkLineGroups.cs index ce2c4f27b..8800f07dc 100644 --- a/ClosedXML/Excel/Sparkline/IXLSparkLineGroups.cs +++ b/ClosedXML/Excel/Sparkline/IXLSparkLineGroups.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System.Collections.Generic; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Sparkline/IXLSparkline.cs b/ClosedXML/Excel/Sparkline/IXLSparkline.cs index 679d051ee..c4180b58f 100644 --- a/ClosedXML/Excel/Sparkline/IXLSparkline.cs +++ b/ClosedXML/Excel/Sparkline/IXLSparkline.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned namespace ClosedXML.Excel { public interface IXLSparkline diff --git a/ClosedXML/Excel/Sparkline/IXLSparklineHorizontalAxis.cs b/ClosedXML/Excel/Sparkline/IXLSparklineHorizontalAxis.cs index 5d2873dfc..aa6538ceb 100644 --- a/ClosedXML/Excel/Sparkline/IXLSparklineHorizontalAxis.cs +++ b/ClosedXML/Excel/Sparkline/IXLSparklineHorizontalAxis.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Sparkline/IXLSparklineStyle.cs b/ClosedXML/Excel/Sparkline/IXLSparklineStyle.cs index 30bc8ff0b..7c95bc574 100644 --- a/ClosedXML/Excel/Sparkline/IXLSparklineStyle.cs +++ b/ClosedXML/Excel/Sparkline/IXLSparklineStyle.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Sparkline/IXLSparklineVerticalAxis.cs b/ClosedXML/Excel/Sparkline/IXLSparklineVerticalAxis.cs index 17e5d2633..967d7c55a 100644 --- a/ClosedXML/Excel/Sparkline/IXLSparklineVerticalAxis.cs +++ b/ClosedXML/Excel/Sparkline/IXLSparklineVerticalAxis.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Sparkline/XLSparkLine.cs b/ClosedXML/Excel/Sparkline/XLSparkLine.cs index e2dbe3a76..a85b891d2 100644 --- a/ClosedXML/Excel/Sparkline/XLSparkLine.cs +++ b/ClosedXML/Excel/Sparkline/XLSparkLine.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; @@ -73,7 +75,7 @@ public IXLSparkline SetLocation(IXLCell cell) SparklineGroup.Remove(_location); _location = cell; - (SparklineGroup as XLSparklineGroup).Add(this); + ((XLSparklineGroup) SparklineGroup).Add(this); return this; } diff --git a/ClosedXML/Excel/Sparkline/XLSparkLineGroup.cs b/ClosedXML/Excel/Sparkline/XLSparkLineGroup.cs index ba5847eca..efd21f6cc 100644 --- a/ClosedXML/Excel/Sparkline/XLSparkLineGroup.cs +++ b/ClosedXML/Excel/Sparkline/XLSparkLineGroup.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections; @@ -8,6 +10,10 @@ namespace ClosedXML.Excel { internal class XLSparklineGroup : IXLSparklineGroup { + private XLWorksheet _worksheet; + private IXLRange _dateRange; + private IXLSparklineStyle _style; + #region Public Properties public IXLRange DateRange @@ -41,10 +47,7 @@ public IXLSparklineStyle Style /// /// The worksheet this sparkline group is associated with /// - public IXLWorksheet Worksheet { get; } - - private IXLRange _dateRange; - private IXLSparklineStyle _style; + public IXLWorksheet Worksheet => _worksheet; #endregion Public Properties @@ -148,9 +151,9 @@ public IXLSparkline Add(IXLCell location, IXLRange sourceData) public IEnumerable Add(string locationRangeAddress, string sourceDataAddress) { - var sourceDataRange = Worksheet.Workbook.Range(sourceDataAddress) ?? - Worksheet.Range(sourceDataAddress); - return Add(Worksheet.Range(locationRangeAddress), sourceDataRange); + var sourceDataRange = _worksheet.Workbook.Range(sourceDataAddress) ?? + _worksheet.Range(sourceDataAddress); + return Add(_worksheet.Range(locationRangeAddress), sourceDataRange); } /// @@ -162,7 +165,7 @@ public void CopyFrom(IXLSparklineGroup sparklineGroup) if (sparklineGroup.DateRange != null) { DateRange = sparklineGroup.DateRange.Worksheet == sparklineGroup.Worksheet - ? Worksheet.Range(sparklineGroup.DateRange.RangeAddress.ToString()) + ? _worksheet.Range(sparklineGroup.DateRange.RangeAddress.ToString()) : sparklineGroup.DateRange; } @@ -177,11 +180,14 @@ public void CopyFrom(IXLSparklineGroup sparklineGroup) XLSparklineVerticalAxis.Copy(sparklineGroup.VerticalAxis, VerticalAxis); } - /// - /// Copy this sparkline group to the specified worksheet - /// - /// The worksheet to copy this sparkline group to - public IXLSparklineGroup CopyTo(IXLWorksheet targetSheet) + /// + IXLSparklineGroup IXLSparklineGroup.CopyTo(IXLWorksheet targetSheet) + { + return CopyTo((XLWorksheet)targetSheet); + } + + /// + internal IXLSparklineGroup CopyTo(XLWorksheet targetSheet) { if (targetSheet == Worksheet) throw new InvalidOperationException( @@ -325,7 +331,7 @@ internal IXLSparkline Add(IXLSparkline sparkline) /// The new sparkline group added internal XLSparklineGroup(IXLWorksheet targetWorksheet) { - Worksheet = targetWorksheet ?? throw new ArgumentNullException(nameof(targetWorksheet)); + _worksheet = targetWorksheet as XLWorksheet ?? throw new ArgumentNullException(nameof(targetWorksheet)); HorizontalAxis = new XLSparklineHorizontalAxis(this); VerticalAxis = new XLSparklineVerticalAxis(this); HorizontalAxis.Color = XLColor.Black; diff --git a/ClosedXML/Excel/Sparkline/XLSparkLineGroups.cs b/ClosedXML/Excel/Sparkline/XLSparkLineGroups.cs index daee483a7..6fb03f156 100644 --- a/ClosedXML/Excel/Sparkline/XLSparkLineGroups.cs +++ b/ClosedXML/Excel/Sparkline/XLSparkLineGroups.cs @@ -7,20 +7,14 @@ namespace ClosedXML.Excel { internal class XLSparklineGroups : IXLSparklineGroups { - #region Public Properties + private readonly XLWorksheet _worksheet; - public IXLWorksheet Worksheet { get; } - - #endregion Public Properties - - #region Public Constructors - - public XLSparklineGroups(IXLWorksheet worksheet) + public XLSparklineGroups(XLWorksheet worksheet) { - Worksheet = worksheet ?? throw new ArgumentNullException(nameof(worksheet)); + _worksheet = worksheet ?? throw new ArgumentNullException(nameof(worksheet)); } - #endregion Public Constructors + public IXLWorksheet Worksheet => _worksheet; #region Public Methods @@ -173,5 +167,53 @@ public void RemoveAll() private readonly List _sparklineGroups = new List(); #endregion Private Fields + + /// + /// Shift address of all sparklines to reflect inserted columns before a range. + /// + /// Range before which will the columns be inserted. Has same worksheet. + /// How many columns, can be positive or negative number. + internal void ShiftColumns(XLSheetRange shiftedRange, int numberOfColumns) + { + foreach (var group in _sparklineGroups) + { + foreach (var sparkline in group.ToList()) + { + var originalAddress = XLSheetPoint.FromAddress(sparkline.Location.Address); + if (!originalAddress.InRangeOrToLeft(shiftedRange)) + continue; + + var newAddressColumn = originalAddress.Column + numberOfColumns; + if (newAddressColumn is >= 1 and <= XLHelper.MaxColumnNumber) + sparkline.Location = new XLCell(_worksheet, originalAddress.Row, newAddressColumn); + else + group.Remove(sparkline); + } + } + } + + /// + /// Shift address of all sparklines to reflect inserted rows before a range. + /// + /// Range before which will the rows be inserted. Has same worksheet. + /// How many rows, can be positive or negative number. + internal void ShiftRows(XLSheetRange shiftedRange, int numberOfRows) + { + foreach (var group in _sparklineGroups) + { + foreach (var sparkline in group.ToList()) + { + var originalAddress = XLSheetPoint.FromAddress(sparkline.Location.Address); + if (!originalAddress.InRangeOrBelow(shiftedRange)) + continue; + + var newAddressRow = originalAddress.Row + numberOfRows; + if (newAddressRow is >= 1 and <= XLHelper.MaxRowNumber) + sparkline.Location = new XLCell(_worksheet, newAddressRow, originalAddress.Column); + else + group.Remove(sparkline); + } + } + } } } diff --git a/ClosedXML/Excel/Sparkline/XLSparklineHorizontalAxis.cs b/ClosedXML/Excel/Sparkline/XLSparklineHorizontalAxis.cs index 65708651d..05edba024 100644 --- a/ClosedXML/Excel/Sparkline/XLSparklineHorizontalAxis.cs +++ b/ClosedXML/Excel/Sparkline/XLSparklineHorizontalAxis.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Sparkline/XLSparklineStyle.cs b/ClosedXML/Excel/Sparkline/XLSparklineStyle.cs index 1cdc30bfa..9a7a8fa0b 100644 --- a/ClosedXML/Excel/Sparkline/XLSparklineStyle.cs +++ b/ClosedXML/Excel/Sparkline/XLSparklineStyle.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Sparkline/XLSparklineTheme.cs b/ClosedXML/Excel/Sparkline/XLSparklineTheme.cs index 8e1dbe76c..86a42d0b2 100644 --- a/ClosedXML/Excel/Sparkline/XLSparklineTheme.cs +++ b/ClosedXML/Excel/Sparkline/XLSparklineTheme.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned namespace ClosedXML.Excel { public static class XLSparklineTheme diff --git a/ClosedXML/Excel/Sparkline/XLSparklineVerticalAxis.cs b/ClosedXML/Excel/Sparkline/XLSparklineVerticalAxis.cs index 9831e47db..6365c18d3 100644 --- a/ClosedXML/Excel/Sparkline/XLSparklineVerticalAxis.cs +++ b/ClosedXML/Excel/Sparkline/XLSparklineVerticalAxis.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Style/Colors/XLColor_Internal.cs b/ClosedXML/Excel/Style/Colors/XLColor_Internal.cs index 3919bf8f2..67a8f3866 100644 --- a/ClosedXML/Excel/Style/Colors/XLColor_Internal.cs +++ b/ClosedXML/Excel/Style/Colors/XLColor_Internal.cs @@ -1,5 +1,4 @@ -using System; -using System.Drawing; +#nullable disable namespace ClosedXML.Excel { @@ -7,48 +6,11 @@ public partial class XLColor { internal XLColorKey Key { get; private set; } - private XLColor(XLColor defaultColor) : this(defaultColor.Key) - { - } - private XLColor() : this(new XLColorKey()) { HasValue = false; } - private XLColor(Color color) : this(new XLColorKey - { - Color = color, - ColorType = XLColorType.Color - }) - { - } - - private XLColor(Int32 index) : this(new XLColorKey - { - Indexed = index, - ColorType = XLColorType.Indexed - }) - { - } - - private XLColor(XLThemeColor themeColor) : this(new XLColorKey - { - ThemeColor = themeColor, - ColorType = XLColorType.Theme - }) - { - } - - private XLColor(XLThemeColor themeColor, Double themeTint) : this(new XLColorKey - { - ThemeColor = themeColor, - ThemeTint = themeTint, - ColorType = XLColorType.Theme - }) - { - } - internal XLColor(XLColorKey key) { Key = key; diff --git a/ClosedXML/Excel/Style/Colors/XLColor_Public.cs b/ClosedXML/Excel/Style/Colors/XLColor_Public.cs index a75edcf40..9d4db0219 100644 --- a/ClosedXML/Excel/Style/Colors/XLColor_Public.cs +++ b/ClosedXML/Excel/Style/Colors/XLColor_Public.cs @@ -28,14 +28,6 @@ public enum XLThemeColor public partial class XLColor : IEquatable { - /// - /// Usually indexed colors are limited to max 63 - /// Index 81 is some special case. - /// Some people claim it's the index for tooltip color. - /// We'll return normal black when index 81 is found. - /// - private const Int32 TOOLTIPCOLORINDEX = 81; - public Boolean HasValue { get; private set; } public XLColorType ColorType @@ -51,10 +43,7 @@ public Color Color throw new InvalidOperationException("Cannot convert theme color to Color."); if (ColorType == XLColorType.Indexed) - if (Indexed == TOOLTIPCOLORINDEX) - return Color.FromArgb(255, Color.Black); - else - return IndexedColors[Indexed].Color; + return IndexedColors[Indexed].Color; return Key.Color; } @@ -135,7 +124,7 @@ public override string ToString() return "Color Index: " + Indexed; } - public static Boolean operator ==(XLColor left, XLColor right) + public static Boolean operator ==(XLColor? left, XLColor? right) { // If both are null, or both are same instance, return true. if (ReferenceEquals(left, right)) return true; @@ -146,7 +135,7 @@ public override string ToString() return left.Equals(right); } - public static Boolean operator !=(XLColor left, XLColor right) + public static Boolean operator !=(XLColor? left, XLColor? right) { return !(left == right); } diff --git a/ClosedXML/Excel/Style/Colors/XLColor_Static.cs b/ClosedXML/Excel/Style/Colors/XLColor_Static.cs index 0e996ddb6..a8c2d8fd8 100644 --- a/ClosedXML/Excel/Style/Colors/XLColor_Static.cs +++ b/ClosedXML/Excel/Style/Colors/XLColor_Static.cs @@ -10,8 +10,65 @@ public partial class XLColor { private static readonly XLColorRepository Repository = new XLColorRepository(key => new XLColor(key)); - private static readonly Dictionary ByColor = new Dictionary(); - private static readonly Object ByColorLock = new Object(); + /// + /// VML palette entries from MS-OI29500. Excel uses Windows system color scheme to determine the actual colors of a palette + /// entry, but we have no way to get them. Win10 doesn't even have a tool, use Classic Color Panel. We will use the default + /// values that are default on Windows. + /// + private static readonly Lazy> VmlPaletteColors = new(() => new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "ButtonFace", FromRgb(0xF0F0F0) }, + { "WindowText", FromRgb(0x000000) }, + { "Menu", FromRgb(0xF0F0F0) }, + { "Highlight", FromRgb(0x0078D7) }, + { "HighlightText", FromRgb(0xFFFFFF) }, + { "CaptionText", FromRgb(0x000000) }, + { "ActiveCaption", FromRgb(0x99B4D1) }, + { "ButtonHighlight", FromRgb(0xFFFFFF) }, + { "ButtonShadow", FromRgb(0xA0A0A0) }, + { "ButtonText", FromRgb(0x000000) }, + { "GrayText", FromRgb(0x6D6D6D) }, + { "InactiveCaption", FromRgb(0xBFCDDB) }, + { "InactiveCaptionText", FromRgb(0x000000) }, + { "InfoBackground", FromRgb(0xFFFFE1) }, + { "InfoText", FromRgb(0x000000) }, + { "MenuText", FromRgb(0x000000) }, + { "Scrollbar", FromRgb(0xC8C8C8) }, + { "Window", FromRgb(0xFFFFFF) }, + { "WindowFrame", FromRgb(0x646464) }, + { "ThreeDLightShadow", FromRgb(0x000000) }, + { "ThreeDDarkShadow", FromRgb(0x696969) }, + { "ActiveBorder", FromRgb(0xB4B4B4) }, + { "InactiveBorder", FromRgb(0xF4F7FC) }, + { "Background", FromRgb(0x000000) }, + { "AppWorkspace", FromRgb(0xABABAB) }, + { "ThreeDFace", FromRgb(0xF0F0F0) }, + { "ThreeDShadow", FromRgb(0xA0A0A0) }, + { "ThreeDHighlight", FromRgb(0xFFFFFF) } + }); + + /// + /// Named colors for VML ST_ColorType from ISO-29500:4. + /// + private static readonly Lazy> VmlNamedColors = new(() => new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Black", Black }, + { "Silver", Silver }, + { "Gray", Gray }, + { "White", White }, + { "Maroon", Maroon }, + { "Red", Red }, + { "Purple", Purple }, + { "Fuchsia", Fuchsia }, + { "Green", Green }, + { "Lime", Lime }, + { "Olive", Olive }, + { "Yellow", Yellow }, + { "Navy", Navy }, + { "Blue", Blue }, + { "Teal", Teal }, + { "Aqua", Aqua }, + }); internal static XLColor FromKey(ref XLColorKey key) { @@ -33,6 +90,11 @@ public static XLColor FromArgb(Int32 argb) return FromColor(Color.FromArgb(argb)); } + internal static XLColor FromArgb(UInt32 argb) + { + return FromColor(Color.FromArgb(unchecked((int)argb))); + } + public static XLColor FromArgb(Int32 r, Int32 g, Int32 b) { return FromColor(Color.FromArgb(r, g, b)); @@ -43,12 +105,27 @@ public static XLColor FromArgb(Int32 a, Int32 r, Int32 g, Int32 b) return FromColor(Color.FromArgb(a, r, g, b)); } -#if NETFRAMEWORK - public static XLColor FromKnownColor(KnownColor color) + /// + /// Create a color from RBG hexa number. Alpha will be always set to full opacity. + /// + internal static XLColor FromRgb(Int32 rgb) { - return FromColor(Color.FromKnownColor(color)); + unchecked + { + return FromColor(Color.FromArgb(rgb | (int)0xFF000000)); + } + } + + /// + /// Parse a color in a RRGGBB hexadecimal format. It is used + /// to parse colors of ST_HexColorRGB type from the XML. + /// + /// A 6 character long hexadecimal number. + /// Parsed color with full opacity. + internal static XLColor FromHexRgb(String hexColorRgb) + { + return FromColor(ColorStringParser.ParseFromRgb6(hexColorRgb)); } -#endif public static XLColor FromName(String name) { @@ -57,7 +134,7 @@ public static XLColor FromName(String name) public static XLColor FromHtml(String htmlColor) { - return FromColor(ColorStringParser.ParseFromArgb(htmlColor)); + return FromColor(ColorStringParser.ParseFromHtml(htmlColor)); } public static XLColor FromIndex(Int32 index) @@ -91,7 +168,41 @@ public static XLColor FromTheme(XLThemeColor themeColor, Double themeTint) return FromKey(ref key); } - private static Dictionary _indexedColors; + /// + /// Parse VML ST_ColorType type from ECMA-376, Part 4 §20.1.2.3. + /// + internal static XLColor FromVmlColor(String vmlColor) + { + // Check if VML color is a hexadecimal RGB color. + var rgbColorText = vmlColor.AsSpan().Trim(); + if (rgbColorText.StartsWith("#".AsSpan()) && ColorStringParser.TryParseRgb6(rgbColorText[1..], out var rgbColor)) + { + return FromColor(rgbColor); + } + + // Check if VML color is a VML named color. + var namedColorText = vmlColor.Trim(); + if (VmlNamedColors.Value.TryGetValue(namedColorText, out var namedColor)) + { + return namedColor; + } + + // Check if VML color is a palette color. ST_ColorType palette entry can have an *optional* index to + // a palette (that is a *system* palette, not indexed colors). The palette index only brings trouble + // because VML is used pretty much only for unusual features like notes, form controls ect. Strip the + // palette index if found, it is a legacy feature from times of 256 colors. E.g., "Menu [80]" => "Menu" + var paletteColorName = vmlColor.Split('[')[0].Trim(); + if (VmlPaletteColors.Value.TryGetValue(paletteColorName, out var paletteColor)) + { + return paletteColor; + } + + // Don't crash on unparsable colors, e.g. None or malformed #RED should still be represented, even + // though we don't actually know how to map them. + return FromName(vmlColor); + } + + private static Dictionary? _indexedColors; public static Dictionary IndexedColors { diff --git a/ClosedXML/Excel/Style/Formatting/XLCellFormat.cs b/ClosedXML/Excel/Style/Formatting/XLCellFormat.cs new file mode 100644 index 000000000..94284814d --- /dev/null +++ b/ClosedXML/Excel/Style/Formatting/XLCellFormat.cs @@ -0,0 +1,23 @@ +namespace ClosedXML.Excel.Formatting; + +/// +/// +/// A master formatting record that determines a direct formatting of a cell/column/row. The final +/// formatting used to render a cell is determined by composition of multiple master formatting +/// records at multiple levels. The least specific is the default one, unrelated to a workbook. +/// Next level is a workbook formatting record, represented by normal style. Next is column or row +/// one and the most specific one is in the cell. +/// +/// +/// Master formatting record has optional properties. The unspecified properties are set through +/// formatting records composition. The (or its reference form +/// ) has everything specified, because it is the final formatting of a +/// cell. +/// +/// +internal readonly record struct XLCellFormat +{ + public XLFontFormat? Font { get; init; } + + // TODO: Add remaining properties. For now only font +} diff --git a/ClosedXML/Excel/Style/Formatting/XLFontFormat.cs b/ClosedXML/Excel/Style/Formatting/XLFontFormat.cs new file mode 100644 index 000000000..af8263990 --- /dev/null +++ b/ClosedXML/Excel/Style/Formatting/XLFontFormat.cs @@ -0,0 +1,79 @@ +namespace ClosedXML.Excel.Formatting; + +/// +/// A formatting record for . Unlike , attributes are optional. +/// +internal readonly record struct XLFontFormat +{ + public required XLFontName? Name { get; init; } + + public required XLFontCharSet? Charset { get; init; } + + public required XLFontFamilyNumberingValues? Family { get; init; } + + public required bool? Bold { get; init; } + + public required bool? Italic { get; init; } + + public required bool? Strikethrough { get; init; } + + public required bool? Outline { get; init; } + + public required bool? Shadow { get; init; } + + public required bool? Condense { get; init; } + + public required bool? Extend { get; init; } + + public required XLColor? Color { get; init; } + + public required XLFontSize? Size { get; init; } + + public required XLFontUnderlineValues? Underline { get; init; } + + public required XLFontVerticalTextAlignmentValues? VerticalAlignment { get; init; } + + public required XLFontScheme? Scheme { get; init; } + + public XLFontKey ApplyTo(XLFontKey nf) + { + // No Outline, Condense or Extend + if (Name is not null) + nf = nf with { FontName = Name.Value.Text }; + + if (Charset is not null) + nf = nf with { FontCharSet = Charset.Value }; + + if (Family is not null) + nf = nf with { FontFamilyNumbering = Family.Value }; + + if (Bold is not null) + nf = nf with { Bold = Bold.Value }; + + if (Italic is not null) + nf = nf with { Italic = Italic.Value }; + + if (Strikethrough is not null) + nf = nf with { Strikethrough = Strikethrough.Value }; + + if (Shadow is not null) + nf = nf with { Shadow = Shadow.Value }; + + if (Color is not null) + nf = nf with { FontColor = Color.Key }; + + if (Size is not null) + nf = nf with { FontSize = Size.Value.Points }; + + if (Underline is not null) + nf = nf with { Underline = Underline.Value }; + + if (VerticalAlignment is not null) + nf = nf with { VerticalAlignment = VerticalAlignment.Value }; + + if (Scheme is not null) + nf = nf with { FontScheme = Scheme.Value }; + + return nf; + } +} diff --git a/ClosedXML/Excel/Style/IXLAlignment.cs b/ClosedXML/Excel/Style/IXLAlignment.cs index cff51742b..3ab62b7ce 100644 --- a/ClosedXML/Excel/Style/IXLAlignment.cs +++ b/ClosedXML/Excel/Style/IXLAlignment.cs @@ -1,15 +1,17 @@ +#nullable disable + using System; namespace ClosedXML.Excel { - public enum XLAlignmentReadingOrderValues + public enum XLAlignmentReadingOrderValues : byte { ContextDependent, LeftToRight, RightToLeft } - public enum XLAlignmentHorizontalValues + public enum XLAlignmentHorizontalValues : byte { Center, CenterContinuous, @@ -21,7 +23,7 @@ public enum XLAlignmentHorizontalValues Right } - public enum XLAlignmentVerticalValues + public enum XLAlignmentVerticalValues : byte { Bottom, Center, @@ -68,7 +70,9 @@ public interface IXLAlignment : IEquatable Boolean ShrinkToFit { get; set; } /// - /// Gets or sets the cell's text rotation. + /// Gets or sets the cell's text rotation in degrees. Allowed values are -90 + /// (text is rotated clockwise) to 90 (text is rotated counterclockwise) and + /// 255 for vertical layout of a text. /// Int32 TextRotation { get; set; } diff --git a/ClosedXML/Excel/Style/IXLBorder.cs b/ClosedXML/Excel/Style/IXLBorder.cs index 5c49df4c8..9025bdc9a 100644 --- a/ClosedXML/Excel/Style/IXLBorder.cs +++ b/ClosedXML/Excel/Style/IXLBorder.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Style/IXLFill.cs b/ClosedXML/Excel/Style/IXLFill.cs index 69ac75f7b..132867396 100644 --- a/ClosedXML/Excel/Style/IXLFill.cs +++ b/ClosedXML/Excel/Style/IXLFill.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Style/IXLFont.cs b/ClosedXML/Excel/Style/IXLFont.cs index a996b6215..10a6b3ba9 100644 --- a/ClosedXML/Excel/Style/IXLFont.cs +++ b/ClosedXML/Excel/Style/IXLFont.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -131,6 +133,28 @@ public enum XLFontCharSet Oem = 255 } + /// + /// A font theme scheme. Theme has categories of fonts and when the theme changes, texts that are associated with + /// the particular theme scheme are switched to a font of a new theme. + /// + public enum XLFontScheme + { + /// + /// Not a part of theme scheme. + /// + None = 0, + + /// + /// A major font of a theme, generally used for headings. + /// + Major = 1, + + /// + /// A minor font of a theme, generally used to body and paragraphs. + /// + Minor = 2 + } + public interface IXLFont : IXLFontBase, IEquatable { IXLStyle SetBold(); IXLStyle SetBold(Boolean value); @@ -153,6 +177,10 @@ public interface IXLFont : IXLFontBase, IEquatable IXLStyle SetFontFamilyNumbering(XLFontFamilyNumberingValues value); + /// IXLStyle SetFontCharSet(XLFontCharSet value); + + /// + IXLStyle SetFontScheme(XLFontScheme value); } } diff --git a/ClosedXML/Excel/Style/IXLFontBase.cs b/ClosedXML/Excel/Style/IXLFontBase.cs index 21b8288a2..c8b535417 100644 --- a/ClosedXML/Excel/Style/IXLFontBase.cs +++ b/ClosedXML/Excel/Style/IXLFontBase.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -24,6 +26,30 @@ public interface IXLFontBase XLFontFamilyNumberingValues FontFamilyNumbering { get; set; } + /// + /// + /// Defines an expected character set used by the text of this font. It helps Excel to choose + /// a font face, either because requested one isn't present or is unsuitable. Each font file contains + /// a list of charsets it is capable of rendering and this property is used to detect whether the charset + /// of a text matches the rendering capabilities of a font face and is thus suitable. + /// + /// + /// Example: + /// The FontCharSet is XLFontCharSet.Default, but the selected font name is B Mitra + /// that contains only arabic alphabet and declares so in its file. Excel will detect this discrepancy and + /// choose a different font to display the text. The outcome is that text is not displayed with the B Mitra + /// font, but with a different one and user doesn't see persian numbers. To use the B Mitra font, + /// this property must be set to XLFontCharSet.Arabic that would match the font declared capabilities. + /// + /// + /// Due to prevalence of unicode fonts, this property is rarely used. XLFontCharSet FontCharSet { get; set; } + + /// + /// Determines a theme font scheme a text belongs to. If the text belongs to a scheme and user changes theme + /// in Excel, the font of the text will switch to the new theme font. Scheme font has precedence and will be + /// used instead of a set font. + /// + XLFontScheme FontScheme { get; set; } } } diff --git a/ClosedXML/Excel/Style/IXLNumberFormat.cs b/ClosedXML/Excel/Style/IXLNumberFormat.cs index cb55e5131..531a7ea0c 100644 --- a/ClosedXML/Excel/Style/IXLNumberFormat.cs +++ b/ClosedXML/Excel/Style/IXLNumberFormat.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Style/IXLNumberFormatBase.cs b/ClosedXML/Excel/Style/IXLNumberFormatBase.cs index d334a932d..a488a42c3 100644 --- a/ClosedXML/Excel/Style/IXLNumberFormatBase.cs +++ b/ClosedXML/Excel/Style/IXLNumberFormatBase.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Style/IXLProtection.cs b/ClosedXML/Excel/Style/IXLProtection.cs index 94d9153f6..54f6a882a 100644 --- a/ClosedXML/Excel/Style/IXLProtection.cs +++ b/ClosedXML/Excel/Style/IXLProtection.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Style/IXLStyle.cs b/ClosedXML/Excel/Style/IXLStyle.cs index 7634e61df..25a0fbaeb 100644 --- a/ClosedXML/Excel/Style/IXLStyle.cs +++ b/ClosedXML/Excel/Style/IXLStyle.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; @@ -15,6 +17,11 @@ public interface IXLStyle : IEquatable IXLFont Font { get; set; } + /// + /// Should the text values of a cell saved to the file be prefixed by a quote (') character? + /// Has no effect if cell values is not a . Doesn't affect values during runtime, + /// text values are returned without quote. + /// Boolean IncludeQuotePrefix { get; set; } IXLNumberFormat NumberFormat { get; set; } diff --git a/ClosedXML/Excel/Style/IXLStylized.cs b/ClosedXML/Excel/Style/IXLStylized.cs index c056e04f9..9108981e6 100644 --- a/ClosedXML/Excel/Style/IXLStylized.cs +++ b/ClosedXML/Excel/Style/IXLStylized.cs @@ -1,23 +1,52 @@ using System; -using System.Collections.Generic; namespace ClosedXML.Excel { + /// + /// An interface implemented by workbook elements that have a defined . + /// internal interface IXLStylized { + /// + /// Editable style of the workbook element. Modification of this property DOES affect styles of child objects as well - they will + /// be changed accordingly. Accessing this property causes a new instance generated so use this property + /// with caution. If you need only _read_ the style consider using property instead. + /// IXLStyle Style { get; set; } - IEnumerable Styles { get; } - + /// + /// Editable style of the workbook element. Modification of this property DOES NOT affect styles of child objects. + /// Accessing this property causes a new instance generated so use this property with caution. If you need + /// only _read_ the style consider using property instead. + /// IXLStyle InnerStyle { get; set; } + /// + /// + /// Return a collection of ranges that determine outside borders (used by + /// ). + /// + /// + /// Return ranges represented by elements. For one element (e.g. workbook, cell, + /// column), it should return only the element itself. For element that represent a + /// collection of other elements, e.g. , , + /// , it should return range for each element in the collection. + /// + /// IXLRanges RangesUsed { get; } /// - /// Immutable style + /// Style value representing the current style of the stylized element. + /// The value is updated when style is modified ( + /// is immutable). /// XLStyleValue StyleValue { get; } + /// + /// A callback method called when is changed. It should update + /// style of the stylized descendants of the stylized element. + /// + /// A method that changes the style from original to modified. void ModifyStyle(Func modification); } } diff --git a/ClosedXML/Excel/Style/XLAlignment.cs b/ClosedXML/Excel/Style/XLAlignment.cs index 36057767b..53a5a39f2 100644 --- a/ClosedXML/Excel/Style/XLAlignment.cs +++ b/ClosedXML/Excel/Style/XLAlignment.cs @@ -1,26 +1,22 @@ -#region - using System; using System.Text; -#endregion - namespace ClosedXML.Excel { internal class XLAlignment : IXLAlignment { #region Static members - internal static XLAlignmentKey GenerateKey(IXLAlignment d) + internal static XLAlignmentKey GenerateKey(IXLAlignment? d) { XLAlignmentKey key; if (d == null) { key = XLAlignmentValue.Default.Key; } - else if (d is XLAlignment) + else if (d is XLAlignment alignment) { - key = (d as XLAlignment).Key; + key = alignment.Key; } else { @@ -62,17 +58,17 @@ internal XLAlignmentKey Key /// /// Style to attach the new instance to. /// Style value to use. - public XLAlignment(XLStyle style, XLAlignmentValue value) + public XLAlignment(XLStyle? style, XLAlignmentValue value) { _style = style ?? XLStyle.CreateEmptyStyle(); _value = value; } - public XLAlignment(XLStyle style, XLAlignmentKey key) : this(style, XLAlignmentValue.FromKey(ref key)) + public XLAlignment(XLStyle? style, XLAlignmentKey key) : this(style, XLAlignmentValue.FromKey(ref key)) { } - public XLAlignment(XLStyle style = null, IXLAlignment d = null) : this(style, GenerateKey(d)) + public XLAlignment(XLStyle? style = null, IXLAlignment? d = null) : this(style, GenerateKey(d)) { } @@ -91,7 +87,7 @@ public XLAlignmentHorizontalValues Horizontal || value == XLAlignmentHorizontalValues.Distributed ); - Modify(k => { k.Horizontal = value; return k; }); + Modify(k => k with { Horizontal = value }); if (updateIndent) Indent = 0; } @@ -100,7 +96,7 @@ public XLAlignmentHorizontalValues Horizontal public XLAlignmentVerticalValues Vertical { get { return Key.Vertical; } - set { Modify(k => { k.Vertical = value; return k; }); } + set { Modify(k => k with { Vertical = value }); } } public Int32 Indent @@ -123,32 +119,32 @@ public Int32 Indent "For indents, only left, right, and distributed horizontal alignments are supported."); } } - Modify(k => { k.Indent = value; return k; }); + Modify(k => k with { Indent = value }); } } public Boolean JustifyLastLine { get { return Key.JustifyLastLine; } - set { Modify(k => { k.JustifyLastLine = value; return k; }); } + set { Modify(k => k with { JustifyLastLine = value }); } } public XLAlignmentReadingOrderValues ReadingOrder { get { return Key.ReadingOrder; } - set { Modify(k => { k.ReadingOrder = value; return k; }); } + set { Modify(k => k with { ReadingOrder = value }); } } public Int32 RelativeIndent { get { return Key.RelativeIndent; } - set { Modify(k => { k.RelativeIndent = value; return k; }); } + set { Modify(k => k with { RelativeIndent = value }); } } public Boolean ShrinkToFit { get { return Key.ShrinkToFit; } - set { Modify(k => { k.ShrinkToFit = value; return k; }); } + set { Modify(k => k with { ShrinkToFit = value }); } } public Int32 TextRotation @@ -158,17 +154,17 @@ public Int32 TextRotation { Int32 rotation = value; - if (rotation != 255 && (rotation < -90 || rotation > 180)) - throw new ArgumentException("TextRotation must be between -90 and 180 degrees, or 255."); + if (rotation != 255 && (rotation < -90 || rotation > 90)) + throw new ArgumentException("TextRotation must be between -90 and 90 degrees, or 255."); - Modify(k => { k.TextRotation = rotation; return k; }); + Modify(k => k with { TextRotation = rotation }); } } public Boolean WrapText { get { return Key.WrapText; } - set { Modify(k => { k.WrapText = value; return k; }); } + set { Modify(k => k with { WrapText = value }); } } public Boolean TopToBottom @@ -269,9 +265,8 @@ private void Modify(Func modification) _style.Modify(styleKey => { - var align = styleKey.Alignment; - styleKey.Alignment = modification(align); - return styleKey; + var alignment = modification(styleKey.Alignment); + return styleKey with { Alignment = alignment }; }); } @@ -306,7 +301,7 @@ public override bool Equals(object obj) return Equals(obj as XLAlignment); } - public bool Equals(IXLAlignment other) + public bool Equals(IXLAlignment? other) { var otherA = other as XLAlignment; if (otherA == null) diff --git a/ClosedXML/Excel/Style/XLAlignmentKey.cs b/ClosedXML/Excel/Style/XLAlignmentKey.cs index c16722420..4160a4333 100644 --- a/ClosedXML/Excel/Style/XLAlignmentKey.cs +++ b/ClosedXML/Excel/Style/XLAlignmentKey.cs @@ -1,78 +1,30 @@ -using System; +namespace ClosedXML.Excel; -namespace ClosedXML.Excel +public readonly record struct XLAlignmentKey { - public struct XLAlignmentKey : IEquatable - { - public XLAlignmentHorizontalValues Horizontal { get; set; } - - public XLAlignmentVerticalValues Vertical { get; set; } - - public int Indent { get; set; } - - public bool JustifyLastLine { get; set; } + public required XLAlignmentHorizontalValues Horizontal { get; init; } - public XLAlignmentReadingOrderValues ReadingOrder { get; set; } + public required XLAlignmentVerticalValues Vertical { get; init; } - public int RelativeIndent { get; set; } + public required int Indent { get; init; } - public bool ShrinkToFit { get; set; } + public required bool JustifyLastLine { get; init; } - public int TextRotation { get; set; } + public required XLAlignmentReadingOrderValues ReadingOrder { get; init; } - public bool WrapText { get; set; } + public required int RelativeIndent { get; init; } - public bool TopToBottom { get; set; } + public required bool ShrinkToFit { get; init; } - public bool Equals(XLAlignmentKey other) - { - return - Horizontal == other.Horizontal - && Vertical == other.Vertical - && Indent == other.Indent - && JustifyLastLine == other.JustifyLastLine - && ReadingOrder == other.ReadingOrder - && RelativeIndent == other.RelativeIndent - && ShrinkToFit == other.ShrinkToFit - && TextRotation == other.TextRotation - && WrapText == other.WrapText - && TopToBottom == other.TopToBottom; - } + public required int TextRotation { get; init; } - public override bool Equals(object obj) - { - if (obj is XLAlignment) - return Equals((XLAlignment)obj); - return base.Equals(obj); - } + public required bool WrapText { get; init; } - public override int GetHashCode() - { - var hashCode = -596887160; - hashCode = hashCode * -1521134295 + (int)Horizontal; - hashCode = hashCode * -1521134295 + (int)Vertical; - hashCode = hashCode * -1521134295 + Indent; - hashCode = hashCode * -1521134295 + JustifyLastLine.GetHashCode(); - hashCode = hashCode * -1521134295 + (int)ReadingOrder; - hashCode = hashCode * -1521134295 + RelativeIndent; - hashCode = hashCode * -1521134295 + ShrinkToFit.GetHashCode(); - hashCode = hashCode * -1521134295 + TextRotation; - hashCode = hashCode * -1521134295 + WrapText.GetHashCode(); - hashCode = hashCode * -1521134295 + TopToBottom.GetHashCode(); - return hashCode; - } - - public override string ToString() - { - return - $"{Horizontal} {Vertical} {ReadingOrder} Indent: {Indent} RelativeIndent: {RelativeIndent} TextRotation: {TextRotation} " + - (WrapText ? "WrapText" : "") + - (JustifyLastLine ? "JustifyLastLine" : "") + - (TopToBottom ? "TopToBottom" : ""); - } - - public static bool operator ==(XLAlignmentKey left, XLAlignmentKey right) => left.Equals(right); - - public static bool operator !=(XLAlignmentKey left, XLAlignmentKey right) => !(left.Equals(right)); + public override string ToString() + { + return + $"{Horizontal} {Vertical} {ReadingOrder} Indent: {Indent} RelativeIndent: {RelativeIndent} TextRotation: {TextRotation} " + + (WrapText ? "WrapText" : "") + + (JustifyLastLine ? "JustifyLastLine" : ""); } } diff --git a/ClosedXML/Excel/Style/XLAlignmentValue.cs b/ClosedXML/Excel/Style/XLAlignmentValue.cs index 758f0acbf..caf1531d9 100644 --- a/ClosedXML/Excel/Style/XLAlignmentValue.cs +++ b/ClosedXML/Excel/Style/XLAlignmentValue.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.Caching; +#nullable disable + +using ClosedXML.Excel.Caching; namespace ClosedXML.Excel { @@ -46,8 +48,6 @@ public static XLAlignmentValue FromKey(ref XLAlignmentKey key) public bool WrapText { get { return Key.WrapText; } } - public bool TopToBottom { get { return Key.TopToBottom; } } - private XLAlignmentValue(XLAlignmentKey key) { Key = key; @@ -55,14 +55,18 @@ private XLAlignmentValue(XLAlignmentKey key) public override bool Equals(object obj) { - var cached = obj as XLAlignmentValue; - return cached != null && - Key.Equals(cached.Key); + return obj is XLAlignmentValue cached && Key.Equals(cached.Key); } public override int GetHashCode() { return 990326508 + Key.GetHashCode(); } + + internal XLAlignmentValue WithWrapText(bool wrapText) + { + var keyCopy = Key with { WrapText = wrapText }; + return FromKey(ref keyCopy); + } } } diff --git a/ClosedXML/Excel/Style/XLBorder.cs b/ClosedXML/Excel/Style/XLBorder.cs index 8fae5fc2f..eb69951cb 100644 --- a/ClosedXML/Excel/Style/XLBorder.cs +++ b/ClosedXML/Excel/Style/XLBorder.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -90,13 +92,12 @@ public XLBorderStyleValues OutsideBorder if (_container is XLWorksheet || _container is XLConditionalFormat) { - Modify(k => + Modify(k => k with { - k.TopBorder = - k.BottomBorder = - k.LeftBorder = - k.RightBorder = value; - return k; + TopBorder = value, + BottomBorder = value, + LeftBorder = value, + RightBorder = value, }); } else @@ -120,13 +121,12 @@ public XLColor OutsideBorderColor if (_container is XLWorksheet || _container is XLConditionalFormat) { - Modify(k => + Modify(k => k with { - k.TopBorderColor = - k.BottomBorderColor = - k.LeftBorderColor = - k.RightBorderColor = value.Key; - return k; + TopBorderColor = value.Key, + BottomBorderColor = value.Key, + LeftBorderColor = value.Key, + RightBorderColor = value.Key, }); } else @@ -151,13 +151,12 @@ public XLBorderStyleValues InsideBorder var wsContainer = _container as XLWorksheet; if (wsContainer != null) { - Modify(k => + Modify(k => k with { - k.TopBorder = - k.BottomBorder = - k.LeftBorder = - k.RightBorder = value; - return k; + TopBorder = value, + BottomBorder = value, + LeftBorder = value, + RightBorder = value, }); } else @@ -169,13 +168,12 @@ public XLBorderStyleValues InsideBorder foreach (var cell in r.Cells()) { (cell.Style.Border as XLBorder) - .Modify(k => + .Modify(k => k with { - k.TopBorder = - k.BottomBorder = - k.LeftBorder = - k.RightBorder = value; - return k; + TopBorder = value, + BottomBorder = value, + LeftBorder = value, + RightBorder = value, }); } } @@ -193,13 +191,12 @@ public XLColor InsideBorderColor var wsContainer = _container as XLWorksheet; if (wsContainer != null) { - Modify(k => + Modify(k => k with { - k.TopBorderColor = - k.BottomBorderColor = - k.LeftBorderColor = - k.RightBorderColor = value.Key; - return k; + TopBorderColor = value.Key, + BottomBorderColor = value.Key, + LeftBorderColor = value.Key, + RightBorderColor = value.Key, }); } else @@ -211,13 +208,12 @@ public XLColor InsideBorderColor foreach (var cell in r.Cells()) { (cell.Style.Border as XLBorder) - .Modify(k => + .Modify(k => k with { - k.TopBorderColor = - k.BottomBorderColor = - k.LeftBorderColor = - k.RightBorderColor = value.Key; - return k; + TopBorderColor = value.Key, + BottomBorderColor = value.Key, + LeftBorderColor = value.Key, + RightBorderColor = value.Key, }); } } @@ -229,7 +225,7 @@ public XLColor InsideBorderColor public XLBorderStyleValues LeftBorder { get { return Key.LeftBorder; } - set { Modify(k => { k.LeftBorder = value; return k; }); } + set { Modify(k => k with { LeftBorder = value }); } } public XLColor LeftBorderColor @@ -244,14 +240,14 @@ public XLColor LeftBorderColor if (value == null) throw new ArgumentNullException(nameof(value), "Color cannot be null"); - Modify(k => { k.LeftBorderColor = value.Key; return k; }); + Modify(k => k with { LeftBorderColor = value.Key }); } } public XLBorderStyleValues RightBorder { get { return Key.RightBorder; } - set { Modify(k => { k.RightBorder = value; return k; }); } + set { Modify(k => k with { RightBorder = value }); } } public XLColor RightBorderColor @@ -266,14 +262,14 @@ public XLColor RightBorderColor if (value == null) throw new ArgumentNullException(nameof(value), "Color cannot be null"); - Modify(k => { k.RightBorderColor = value.Key; return k; }); + Modify(k => k with { RightBorderColor = value.Key }); } } public XLBorderStyleValues TopBorder { get { return Key.TopBorder; } - set { Modify(k => { k.TopBorder = value; return k; }); } + set { Modify(k => k with { TopBorder = value }); } } public XLColor TopBorderColor @@ -288,14 +284,14 @@ public XLColor TopBorderColor if (value == null) throw new ArgumentNullException(nameof(value), "Color cannot be null"); - Modify(k => { k.TopBorderColor = value.Key; return k; }); + Modify(k => k with { TopBorderColor = value.Key }); } } public XLBorderStyleValues BottomBorder { get { return Key.BottomBorder; } - set { Modify(k => { k.BottomBorder = value; return k; }); } + set { Modify(k => k with { BottomBorder = value }); } } public XLColor BottomBorderColor @@ -310,14 +306,14 @@ public XLColor BottomBorderColor if (value == null) throw new ArgumentNullException(nameof(value), "Color cannot be null"); - Modify(k => { k.BottomBorderColor = value.Key; return k; }); + Modify(k => k with { BottomBorderColor = value.Key }); } } public XLBorderStyleValues DiagonalBorder { get { return Key.DiagonalBorder; } - set { Modify(k => { k.DiagonalBorder = value; return k; }); } + set { Modify(k => k with { DiagonalBorder = value }); } } public XLColor DiagonalBorderColor @@ -332,128 +328,128 @@ public XLColor DiagonalBorderColor if (value == null) throw new ArgumentNullException(nameof(value), "Color cannot be null"); - Modify(k => { k.DiagonalBorderColor = value.Key; return k; }); + Modify(k => k with { DiagonalBorderColor = value.Key }); } } public Boolean DiagonalUp { get { return Key.DiagonalUp; } - set { Modify(k => { k.DiagonalUp = value; return k; }); } + set { Modify(k => k with { DiagonalUp = value }); } } public Boolean DiagonalDown { get { return Key.DiagonalDown; } - set { Modify(k => { k.DiagonalDown = value; return k; }); } + set { Modify(k => k with { DiagonalDown = value }); } } public IXLStyle SetOutsideBorder(XLBorderStyleValues value) { OutsideBorder = value; - return _container.Style; + return _style; } public IXLStyle SetOutsideBorderColor(XLColor value) { OutsideBorderColor = value; - return _container.Style; + return _style; } public IXLStyle SetInsideBorder(XLBorderStyleValues value) { InsideBorder = value; - return _container.Style; + return _style; } public IXLStyle SetInsideBorderColor(XLColor value) { InsideBorderColor = value; - return _container.Style; + return _style; } public IXLStyle SetLeftBorder(XLBorderStyleValues value) { LeftBorder = value; - return _container.Style; + return _style; } public IXLStyle SetLeftBorderColor(XLColor value) { LeftBorderColor = value; - return _container.Style; + return _style; } public IXLStyle SetRightBorder(XLBorderStyleValues value) { RightBorder = value; - return _container.Style; + return _style; } public IXLStyle SetRightBorderColor(XLColor value) { RightBorderColor = value; - return _container.Style; + return _style; } public IXLStyle SetTopBorder(XLBorderStyleValues value) { TopBorder = value; - return _container.Style; + return _style; } public IXLStyle SetTopBorderColor(XLColor value) { TopBorderColor = value; - return _container.Style; + return _style; } public IXLStyle SetBottomBorder(XLBorderStyleValues value) { BottomBorder = value; - return _container.Style; + return _style; } public IXLStyle SetBottomBorderColor(XLColor value) { BottomBorderColor = value; - return _container.Style; + return _style; } public IXLStyle SetDiagonalUp() { DiagonalUp = true; - return _container.Style; + return _style; } public IXLStyle SetDiagonalUp(Boolean value) { DiagonalUp = value; - return _container.Style; + return _style; } public IXLStyle SetDiagonalDown() { DiagonalDown = true; - return _container.Style; + return _style; } public IXLStyle SetDiagonalDown(Boolean value) { DiagonalDown = value; - return _container.Style; + return _style; } public IXLStyle SetDiagonalBorder(XLBorderStyleValues value) { DiagonalBorder = value; - return _container.Style; + return _style; } public IXLStyle SetDiagonalBorderColor(XLColor value) { DiagonalBorderColor = value; - return _container.Style; + return _style; } #endregion IXLBorder Members @@ -464,9 +460,8 @@ private void Modify(Func modification) _style.Modify(styleKey => { - var border = styleKey.Border; - styleKey.Border = modification(border); - return styleKey; + var border = modification(styleKey.Border); + return styleKey with { Border = border }; }); } @@ -561,32 +556,28 @@ public RestoreOutsideBorder(IXLRange range) private void DisposeManaged() { _topBorders.ForEach(kp => (_range.FirstRow().Cell(kp.Key).Style - .Border as XLBorder).Modify(k => + .Border as XLBorder).Modify(k => k with { - k.TopBorder = kp.Value.TopBorder; - k.TopBorderColor = kp.Value.TopBorderColor; - return k; + TopBorder = kp.Value.TopBorder, + TopBorderColor = kp.Value.TopBorderColor, })); _bottomBorders.ForEach(kp => (_range.LastRow().Cell(kp.Key).Style - .Border as XLBorder).Modify(k => + .Border as XLBorder).Modify(k => k with { - k.BottomBorder = kp.Value.BottomBorder; - k.BottomBorderColor = kp.Value.BottomBorderColor; - return k; + BottomBorder = kp.Value.BottomBorder, + BottomBorderColor = kp.Value.BottomBorderColor, })); _leftBorders.ForEach(kp => (_range.FirstColumn().Cell(kp.Key).Style - .Border as XLBorder).Modify(k => + .Border as XLBorder).Modify(k => k with { - k.LeftBorder = kp.Value.LeftBorder; - k.LeftBorderColor = kp.Value.LeftBorderColor; - return k; + LeftBorder = kp.Value.LeftBorder, + LeftBorderColor = kp.Value.LeftBorderColor, })); _rightBorders.ForEach(kp => (_range.LastColumn().Cell(kp.Key).Style - .Border as XLBorder).Modify(k => + .Border as XLBorder).Modify(k => k with { - k.RightBorder = kp.Value.RightBorder; - k.RightBorderColor = kp.Value.RightBorderColor; - return k; + RightBorder = kp.Value.RightBorder, + RightBorderColor = kp.Value.RightBorderColor, })); } diff --git a/ClosedXML/Excel/Style/XLBorderKey.cs b/ClosedXML/Excel/Style/XLBorderKey.cs index facb097ec..48fd382f8 100644 --- a/ClosedXML/Excel/Style/XLBorderKey.cs +++ b/ClosedXML/Excel/Style/XLBorderKey.cs @@ -1,97 +1,85 @@ -using System; +using System; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +internal readonly record struct XLBorderKey { - internal struct XLBorderKey : IEquatable + public required XLBorderStyleValues LeftBorder { get; init; } + + public required XLColorKey LeftBorderColor { get; init; } + + public required XLBorderStyleValues RightBorder { get; init; } + + public required XLColorKey RightBorderColor { get; init; } + + public required XLBorderStyleValues TopBorder { get; init; } + + public required XLColorKey TopBorderColor { get; init; } + + public required XLBorderStyleValues BottomBorder { get; init; } + + public required XLColorKey BottomBorderColor { get; init; } + + public required XLBorderStyleValues DiagonalBorder { get; init; } + + public required XLColorKey DiagonalBorderColor { get; init; } + + public required bool DiagonalUp { get; init; } + + public required bool DiagonalDown { get; init; } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(LeftBorder); + hash.Add(RightBorder); + hash.Add(TopBorder); + hash.Add(BottomBorder); + hash.Add(DiagonalBorder); + hash.Add(DiagonalUp); + hash.Add(DiagonalDown); + + if (LeftBorder != XLBorderStyleValues.None) + hash.Add(LeftBorderColor); + if (RightBorder != XLBorderStyleValues.None) + hash.Add(RightBorderColor); + if (TopBorder != XLBorderStyleValues.None) + hash.Add(TopBorderColor); + if (BottomBorder != XLBorderStyleValues.None) + hash.Add(BottomBorderColor); + if (DiagonalBorder != XLBorderStyleValues.None) + hash.Add(DiagonalBorderColor); + + return hash.ToHashCode(); + } + + public bool Equals(XLBorderKey other) + { + return + AreEquivalent(LeftBorder, LeftBorderColor, other.LeftBorder, other.LeftBorderColor) + && AreEquivalent(RightBorder, RightBorderColor, other.RightBorder, other.RightBorderColor) + && AreEquivalent(TopBorder, TopBorderColor, other.TopBorder, other.TopBorderColor) + && AreEquivalent(BottomBorder, BottomBorderColor, other.BottomBorder, other.BottomBorderColor) + && AreEquivalent(DiagonalBorder, DiagonalBorderColor, other.DiagonalBorder, other.DiagonalBorderColor) + && DiagonalUp == other.DiagonalUp + && DiagonalDown == other.DiagonalDown; + } + + private bool AreEquivalent( + XLBorderStyleValues borderStyle1, XLColorKey color1, + XLBorderStyleValues borderStyle2, XLColorKey color2) + { + return (borderStyle1 == XLBorderStyleValues.None && + borderStyle2 == XLBorderStyleValues.None) || + borderStyle1 == borderStyle2 && + color1 == color2; + } + + public override string ToString() { - public XLBorderStyleValues LeftBorder { get; set; } - - public XLColorKey LeftBorderColor { get; set; } - - public XLBorderStyleValues RightBorder { get; set; } - - public XLColorKey RightBorderColor { get; set; } - - public XLBorderStyleValues TopBorder { get; set; } - - public XLColorKey TopBorderColor { get; set; } - - public XLBorderStyleValues BottomBorder { get; set; } - - public XLColorKey BottomBorderColor { get; set; } - - public XLBorderStyleValues DiagonalBorder { get; set; } - - public XLColorKey DiagonalBorderColor { get; set; } - - public bool DiagonalUp { get; set; } - - public bool DiagonalDown { get; set; } - - public override int GetHashCode() - { - var hashCode = -198124310; - hashCode = hashCode * -1521134295 + (int)LeftBorder; - hashCode = hashCode * -1521134295 + (int)RightBorder; - hashCode = hashCode * -1521134295 + (int)TopBorder; - hashCode = hashCode * -1521134295 + (int)BottomBorder; - hashCode = hashCode * -1521134295 + (int)DiagonalBorder; - hashCode = hashCode * -1521134295 + DiagonalUp.GetHashCode(); - hashCode = hashCode * -1521134295 + DiagonalDown.GetHashCode(); - - if (LeftBorder != XLBorderStyleValues.None) - hashCode = hashCode * -1521134295 + LeftBorderColor.GetHashCode(); - if (RightBorder != XLBorderStyleValues.None) - hashCode = hashCode * -1521134295 + RightBorderColor.GetHashCode(); - if (TopBorder != XLBorderStyleValues.None) - hashCode = hashCode * -1521134295 + TopBorderColor.GetHashCode(); - if (BottomBorder != XLBorderStyleValues.None) - hashCode = hashCode * -1521134295 + BottomBorderColor.GetHashCode(); - if (DiagonalBorder != XLBorderStyleValues.None) - hashCode = hashCode * -1521134295 + DiagonalBorderColor.GetHashCode(); - - return hashCode; - } - - public bool Equals(XLBorderKey other) - { - return - AreEquivalent(LeftBorder, LeftBorderColor, other.LeftBorder, other.LeftBorderColor) - && AreEquivalent(RightBorder, RightBorderColor, other.RightBorder, other.RightBorderColor) - && AreEquivalent(TopBorder, TopBorderColor, other.TopBorder, other.TopBorderColor) - && AreEquivalent(BottomBorder, BottomBorderColor, other.BottomBorder, other.BottomBorderColor) - && AreEquivalent(DiagonalBorder, DiagonalBorderColor, other.DiagonalBorder, other.DiagonalBorderColor) - && DiagonalUp == other.DiagonalUp - && DiagonalDown == other.DiagonalDown; - } - - private bool AreEquivalent( - XLBorderStyleValues borderStyle1, XLColorKey color1, - XLBorderStyleValues borderStyle2, XLColorKey color2) - { - return (borderStyle1 == XLBorderStyleValues.None && - borderStyle2 == XLBorderStyleValues.None) || - borderStyle1 == borderStyle2 && - color1 == color2; - } - - public override bool Equals(object obj) - { - if (obj is XLBorderKey) - return Equals((XLBorderKey)obj); - return base.Equals(obj); - } - - public override string ToString() - { - return $"{LeftBorder} {LeftBorderColor} {RightBorder} {RightBorderColor} {TopBorder} {TopBorderColor} " + - $"{BottomBorder} {BottomBorderColor} {DiagonalBorder} {DiagonalBorderColor} " + - (DiagonalUp ? "DiagonalUp" : "") + - (DiagonalDown ? "DiagonalDown" : ""); - } - - public static bool operator ==(XLBorderKey left, XLBorderKey right) => left.Equals(right); - - public static bool operator !=(XLBorderKey left, XLBorderKey right) => !(left.Equals(right)); + return $"{LeftBorder} {LeftBorderColor} {RightBorder} {RightBorderColor} {TopBorder} {TopBorderColor} " + + $"{BottomBorder} {BottomBorderColor} {DiagonalBorder} {DiagonalBorderColor} " + + (DiagonalUp ? "DiagonalUp" : "") + + (DiagonalDown ? "DiagonalDown" : ""); } } diff --git a/ClosedXML/Excel/Style/XLBorderValue.cs b/ClosedXML/Excel/Style/XLBorderValue.cs index f5cfd6c32..a4cd7628f 100644 --- a/ClosedXML/Excel/Style/XLBorderValue.cs +++ b/ClosedXML/Excel/Style/XLBorderValue.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.Caching; +#nullable disable + +using ClosedXML.Excel.Caching; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Style/XLColorKey.cs b/ClosedXML/Excel/Style/XLColorKey.cs index a30822bb4..3699fa27c 100644 --- a/ClosedXML/Excel/Style/XLColorKey.cs +++ b/ClosedXML/Excel/Style/XLColorKey.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Style/XLFill.cs b/ClosedXML/Excel/Style/XLFill.cs index 0835fe543..151f1bdc8 100644 --- a/ClosedXML/Excel/Style/XLFill.cs +++ b/ClosedXML/Excel/Style/XLFill.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; namespace ClosedXML.Excel { @@ -7,16 +6,16 @@ internal class XLFill : IXLFill { #region static members - internal static XLFillKey GenerateKey(IXLFill defaultFill) + internal static XLFillKey GenerateKey(IXLFill? defaultFill) { XLFillKey key; if (defaultFill == null) { key = XLFillValue.Default.Key; } - else if (defaultFill is XLFill) + else if (defaultFill is XLFill fill) { - key = (defaultFill as XLFill).Key; + key = fill.Key; } else { @@ -53,17 +52,17 @@ internal XLFillKey Key /// /// Style to attach the new instance to. /// Style value to use. - public XLFill(XLStyle style, XLFillValue value) + public XLFill(XLStyle? style, XLFillValue value) { _style = style ?? XLStyle.CreateEmptyStyle(); _value = value; } - public XLFill(XLStyle style, XLFillKey key) : this(style, XLFillValue.FromKey(ref key)) + public XLFill(XLStyle? style, XLFillKey key) : this(style, XLFillValue.FromKey(ref key)) { } - public XLFill(XLStyle style = null, IXLFill d = null) : this(style, GenerateKey(d)) + public XLFill(XLStyle? style = null, IXLFill? d = null) : this(style, GenerateKey(d)) { } @@ -75,9 +74,8 @@ private void Modify(Func modification) _style.Modify(styleKey => { - var fill = styleKey.Fill; - styleKey.Fill = modification(fill); - return styleKey; + var fill = modification(styleKey.Fill); + return styleKey with { Fill = fill }; }); } @@ -100,15 +98,14 @@ public XLColor BackgroundColor && XLColor.IsNullOrTransparent(BackgroundColor)) { var patternType = value.HasValue ? XLFillPatternValues.Solid : XLFillPatternValues.None; - Modify(k => + Modify(k => k with { - k.BackgroundColor = value.Key; - k.PatternType = patternType; - return k; + BackgroundColor = value.Key, + PatternType = patternType, }); } else - Modify(k => { k.BackgroundColor = value.Key; return k; }); + Modify(k => k with { BackgroundColor = value.Key }); } } @@ -124,7 +121,7 @@ public XLColor PatternColor if (value == null) throw new ArgumentNullException(nameof(value), "Color cannot be null"); - Modify(k => { k.PatternColor = value.Key; return k; }); + Modify(k => k with { PatternColor = value.Key }); } } @@ -138,15 +135,14 @@ public XLFillPatternValues PatternType { // If fill was empty and the pattern changes to non-empty we have to specify a background color too. // Otherwise the fill will be considered empty and pattern won't update (the cached empty fill will be used). - Modify(k => + Modify(k => k with { - k.BackgroundColor = XLColor.FromTheme(XLThemeColor.Text1).Key; - k.PatternType = value; - return k; + BackgroundColor = XLColor.FromTheme(XLThemeColor.Text1).Key, + PatternType = value, }); } else - Modify(k => { k.PatternType = value; return k; }); + Modify(k => k with { PatternType = value }); } } @@ -177,7 +173,7 @@ public override bool Equals(object obj) return Equals(obj as XLFill); } - public bool Equals(IXLFill other) + public bool Equals(IXLFill? other) { var otherF = other as XLFill; if (otherF == null) diff --git a/ClosedXML/Excel/Style/XLFillKey.cs b/ClosedXML/Excel/Style/XLFillKey.cs index de1a2cfcd..15822f6a4 100644 --- a/ClosedXML/Excel/Style/XLFillKey.cs +++ b/ClosedXML/Excel/Style/XLFillKey.cs @@ -1,68 +1,56 @@ -using System; +using System; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +internal readonly record struct XLFillKey { - internal struct XLFillKey : IEquatable - { - public XLColorKey BackgroundColor { get; set; } + public required XLColorKey BackgroundColor { get; init; } - public XLColorKey PatternColor { get; set; } + public required XLColorKey PatternColor { get; init; } - public XLFillPatternValues PatternType { get; set; } + public required XLFillPatternValues PatternType { get; init; } - public override int GetHashCode() - { - var hashCode = 2043579837; + public override int GetHashCode() + { + var hash = new HashCode(); - if (HasNoFill()) return hashCode; + if (HasNoFill()) return hash.ToHashCode(); - hashCode = hashCode * -1521134295 + (int)PatternType; - hashCode = hashCode * -1521134295 + BackgroundColor.GetHashCode(); + hash.Add(PatternType); + hash.Add(BackgroundColor); - if (HasNoForeground()) return hashCode; + if (HasNoForeground()) return hash.ToHashCode(); - hashCode = hashCode * -1521134295 + PatternColor.GetHashCode(); + hash.Add(PatternColor); - return hashCode; - } - - public bool Equals(XLFillKey other) - { - if (HasNoFill() && other.HasNoFill()) - return true; - - return BackgroundColor == other.BackgroundColor - && PatternType == other.PatternType - && (HasNoForeground() && other.HasNoForeground() || - PatternColor == other.PatternColor); - } - - private bool HasNoFill() - { - return PatternType == XLFillPatternValues.None - || (PatternType == XLFillPatternValues.Solid && XLColor.IsTransparent(BackgroundColor)); - } + return hash.ToHashCode(); + } - private bool HasNoForeground() - { - return PatternType == XLFillPatternValues.Solid || - PatternType == XLFillPatternValues.None; - } + public bool Equals(XLFillKey other) + { + if (HasNoFill() && other.HasNoFill()) + return true; - public override bool Equals(object obj) - { - if (obj is XLFillKey) - return Equals((XLFillKey)obj); - return base.Equals(obj); - } + return BackgroundColor == other.BackgroundColor + && PatternType == other.PatternType + && (HasNoForeground() && other.HasNoForeground() || + PatternColor == other.PatternColor); + } - public override string ToString() - { - return $"{PatternType} {BackgroundColor}/{PatternColor}"; - } + private bool HasNoFill() + { + return PatternType == XLFillPatternValues.None + || (PatternType == XLFillPatternValues.Solid && XLColor.IsTransparent(BackgroundColor)); + } - public static bool operator ==(XLFillKey left, XLFillKey right) => left.Equals(right); + private bool HasNoForeground() + { + return PatternType == XLFillPatternValues.Solid || + PatternType == XLFillPatternValues.None; + } - public static bool operator !=(XLFillKey left, XLFillKey right) => !(left.Equals(right)); + public override string ToString() + { + return $"{PatternType} {BackgroundColor}/{PatternColor}"; } } diff --git a/ClosedXML/Excel/Style/XLFillValue.cs b/ClosedXML/Excel/Style/XLFillValue.cs index f7c2eb098..f4906744a 100644 --- a/ClosedXML/Excel/Style/XLFillValue.cs +++ b/ClosedXML/Excel/Style/XLFillValue.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.Caching; +#nullable disable + +using ClosedXML.Excel.Caching; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Style/XLFont.cs b/ClosedXML/Excel/Style/XLFont.cs index 22a45d8d6..1993e1a29 100644 --- a/ClosedXML/Excel/Style/XLFont.cs +++ b/ClosedXML/Excel/Style/XLFont.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Text; namespace ClosedXML.Excel @@ -23,15 +24,15 @@ public static IXLFontBase DefaultCommentFont } } - internal static XLFontKey GenerateKey(IXLFontBase defaultFont) + internal static XLFontKey GenerateKey(IXLFontBase? defaultFont) { if (defaultFont == null) { return XLFontValue.Default.Key; } - else if (defaultFont is XLFont) + else if (defaultFont is XLFont font) { - return (defaultFont as XLFont).Key; + return font.Key; } else { @@ -47,7 +48,8 @@ internal static XLFontKey GenerateKey(IXLFontBase defaultFont) FontColor = defaultFont.FontColor.Key, FontName = defaultFont.FontName, FontFamilyNumbering = defaultFont.FontFamilyNumbering, - FontCharSet = defaultFont.FontCharSet + FontCharSet = defaultFont.FontCharSet, + FontScheme = defaultFont.FontScheme }; } } @@ -71,17 +73,36 @@ internal XLFontKey Key /// /// Style to attach the new instance to. /// Style value to use. - public XLFont(XLStyle style, XLFontValue value) + public XLFont(XLStyle? style, XLFontValue value) { _style = style ?? XLStyle.CreateEmptyStyle(); _value = value; } - public XLFont(XLStyle style, XLFontKey key) : this(style, XLFontValue.FromKey(ref key)) + public XLFont(XLStyle? style, XLFontKey key) : this(style, XLFontValue.FromKey(ref key)) { } - public XLFont(XLStyle style = null, IXLFont d = null) : this(style, GenerateKey(d)) + /// + /// Create a new font that is attached to a style and the changes to the font object are propagated to the style. + /// + /// The container style that will be modified by changes of created XLFont. + public XLFont(XLStyle style) : this(style, GenerateKey(style.Font)) + { + } + + /// + /// Create a new font. The changes to the object are not propagated to a style. + /// + public XLFont(IXLFontBase font) : this(null, GenerateKey(font)) + { + } + + public XLFont(XLFontKey key) : this(null, XLFontValue.FromKey(ref key)) + { + } + + private XLFont() : this(null, GenerateKey(null)) { } @@ -93,9 +114,8 @@ private void Modify(Func modification) _style.Modify(styleKey => { - var font = styleKey.Font; - styleKey.Font = modification(font); - return styleKey; + var font = modification(styleKey.Font); + return styleKey with { Font = font }; }); } @@ -106,7 +126,7 @@ public Boolean Bold get { return Key.Bold; } set { - Modify(k => { k.Bold = value; return k; }); + Modify(k => k with { Bold = value }); } } @@ -115,7 +135,7 @@ public Boolean Italic get { return Key.Italic; } set { - Modify(k => { k.Italic = value; return k; }); + Modify(k => k with { Italic = value }); } } @@ -124,7 +144,7 @@ public XLFontUnderlineValues Underline get { return Key.Underline; } set { - Modify(k => { k.Underline = value; return k; }); + Modify(k => k with { Underline = value }); } } @@ -133,7 +153,7 @@ public Boolean Strikethrough get { return Key.Strikethrough; } set { - Modify(k => { k.Strikethrough = value; return k; }); + Modify(k => k with { Strikethrough = value }); } } @@ -142,7 +162,7 @@ public XLFontVerticalTextAlignmentValues VerticalAlignment get { return Key.VerticalAlignment; } set { - Modify(k => { k.VerticalAlignment = value; return k; }); + Modify(k => k with { VerticalAlignment = value }); } } @@ -151,7 +171,7 @@ public Boolean Shadow get { return Key.Shadow; } set { - Modify(k => { k.Shadow = value; return k; }); + Modify(k => k with { Shadow = value }); } } @@ -160,7 +180,7 @@ public Double FontSize get { return Key.FontSize; } set { - Modify(k => { k.FontSize = value; return k; }); + Modify(k => k with { FontSize = value }); } } @@ -175,7 +195,7 @@ public XLColor FontColor { if (value == null) throw new ArgumentNullException(nameof(value), "Color cannot be null"); - Modify(k => { k.FontColor = value.Key; return k; }); + Modify(k => k with { FontColor = value.Key }); } } @@ -184,7 +204,7 @@ public String FontName get { return Key.FontName; } set { - Modify(k => { k.FontName = value; return k; }); + Modify(k => k with { FontName = value }); } } @@ -193,7 +213,7 @@ public XLFontFamilyNumberingValues FontFamilyNumbering get { return Key.FontFamilyNumbering; } set { - Modify(k => { k.FontFamilyNumbering = value; return k; }); + Modify(k => k with { FontFamilyNumbering = value }); } } @@ -202,7 +222,16 @@ public XLFontCharSet FontCharSet get { return Key.FontCharSet; } set { - Modify(k => { k.FontCharSet = value; return k; }); + Modify(k => k with { FontCharSet = value }); + } + } + + public XLFontScheme FontScheme + { + get { return Key.FontScheme; } + set + { + Modify(k => k with { FontScheme = value }); } } @@ -302,6 +331,12 @@ public IXLStyle SetFontCharSet(XLFontCharSet value) return _style; } + public IXLStyle SetFontScheme(XLFontScheme value) + { + FontScheme = value; + return _style; + } + #endregion IXLFont Members #region Overridden @@ -321,13 +356,17 @@ public override string ToString() sb.Append("-"); sb.Append(Shadow.ToString()); sb.Append("-"); - sb.Append(FontSize.ToString()); + sb.Append(FontSize.ToString(CultureInfo.InvariantCulture)); sb.Append("-"); sb.Append(FontColor); sb.Append("-"); sb.Append(FontName); sb.Append("-"); sb.Append(FontFamilyNumbering.ToString()); + sb.Append("-"); + sb.Append(FontCharSet.ToString()); + sb.Append("-"); + sb.Append(FontScheme.ToString()); return sb.ToString(); } @@ -336,7 +375,7 @@ public override bool Equals(object obj) return Equals(obj as XLFont); } - public Boolean Equals(IXLFont other) + public Boolean Equals(IXLFont? other) { var otherF = other as XLFont; if (otherF == null) diff --git a/ClosedXML/Excel/Style/XLFontKey.cs b/ClosedXML/Excel/Style/XLFontKey.cs index 1afbbf148..020f7e224 100644 --- a/ClosedXML/Excel/Style/XLFontKey.cs +++ b/ClosedXML/Excel/Style/XLFontKey.cs @@ -1,81 +1,73 @@ -using System; +using System; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +internal readonly record struct XLFontKey { - internal struct XLFontKey : IEquatable + public required bool Bold { get; init; } + + public required bool Italic { get; init; } + + public required XLFontUnderlineValues Underline { get; init; } + + public required bool Strikethrough { get; init; } + + public required XLFontVerticalTextAlignmentValues VerticalAlignment { get; init; } + + public required bool Shadow { get; init; } + + public required double FontSize { get; init; } + + public required XLColorKey FontColor { get; init; } + + public required string FontName { get; init; } + + public required XLFontFamilyNumberingValues FontFamilyNumbering { get; init; } + + public required XLFontCharSet FontCharSet { get; init; } + + public required XLFontScheme FontScheme { get; init; } + + public bool Equals(XLFontKey other) + { + return + Bold == other.Bold + && Italic == other.Italic + && Underline == other.Underline + && Strikethrough == other.Strikethrough + && VerticalAlignment == other.VerticalAlignment + && Shadow == other.Shadow + && FontSize.Equals(other.FontSize) + && FontColor == other.FontColor + && FontFamilyNumbering == other.FontFamilyNumbering + && FontCharSet == other.FontCharSet + && FontScheme == other.FontScheme + && string.Equals(FontName, other.FontName, StringComparison.OrdinalIgnoreCase); + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Bold); + hash.Add(Italic); + hash.Add(Underline); + hash.Add(Strikethrough); + hash.Add(VerticalAlignment); + hash.Add(Shadow); + hash.Add(FontSize); + hash.Add(FontColor); + hash.Add(FontName, StringComparer.OrdinalIgnoreCase); + hash.Add(FontFamilyNumbering); + hash.Add(FontCharSet); + hash.Add(FontScheme); + return hash.ToHashCode(); + } + + public override string ToString() { - public bool Bold { get; set; } - - public bool Italic { get; set; } - - public XLFontUnderlineValues Underline { get; set; } - - public bool Strikethrough { get; set; } - - public XLFontVerticalTextAlignmentValues VerticalAlignment { get; set; } - - public bool Shadow { get; set; } - - public double FontSize { get; set; } - - public XLColorKey FontColor { get; set; } - - public string FontName { get; set; } - - public XLFontFamilyNumberingValues FontFamilyNumbering { get; set; } - - public XLFontCharSet FontCharSet { get; set; } - - public bool Equals(XLFontKey other) - { - return - Bold == other.Bold - && Italic == other.Italic - && Underline == other.Underline - && Strikethrough == other.Strikethrough - && VerticalAlignment == other.VerticalAlignment - && Shadow == other.Shadow - && FontSize == other.FontSize - && FontColor == other.FontColor - && FontFamilyNumbering == other.FontFamilyNumbering - && FontCharSet == other.FontCharSet - && string.Equals(FontName, other.FontName, StringComparison.InvariantCultureIgnoreCase); - } - - public override bool Equals(object obj) - { - if (obj is XLFontKey) - return Equals((XLFontKey)obj); - return base.Equals(obj); - } - - public override int GetHashCode() - { - var hashCode = 1158783753; - hashCode = hashCode * -1521134295 + Bold.GetHashCode(); - hashCode = hashCode * -1521134295 + Italic.GetHashCode(); - hashCode = hashCode * -1521134295 + (int)Underline; - hashCode = hashCode * -1521134295 + Strikethrough.GetHashCode(); - hashCode = hashCode * -1521134295 + (int)VerticalAlignment; - hashCode = hashCode * -1521134295 + Shadow.GetHashCode(); - hashCode = hashCode * -1521134295 + FontSize.GetHashCode(); - hashCode = hashCode * -1521134295 + FontColor.GetHashCode(); - hashCode = hashCode * -1521134295 + StringComparer.InvariantCultureIgnoreCase.GetHashCode(FontName); - hashCode = hashCode * -1521134295 + (int)FontFamilyNumbering; - hashCode = hashCode * -1521134295 + (int)FontCharSet; - return hashCode; - } - - public override string ToString() - { - return $"{FontName} {FontSize}pt {FontColor} " + - (Bold ? "Bold" : "") + (Italic ? "Italic" : "") + (Strikethrough ? "Strikethrough" : "") + - (Underline == XLFontUnderlineValues.None ? "" : Underline.ToString()) + - $"{FontFamilyNumbering} {FontCharSet}"; - } - - public static bool operator ==(XLFontKey left, XLFontKey right) => left.Equals(right); - - public static bool operator !=(XLFontKey left, XLFontKey right) => !(left.Equals(right)); + return $"{FontName} {FontSize}pt {FontColor} " + + (Bold ? "Bold" : "") + (Italic ? "Italic" : "") + (Strikethrough ? "Strikethrough" : "") + + (Underline == XLFontUnderlineValues.None ? "" : Underline.ToString()) + + $"{FontFamilyNumbering} {FontCharSet} {FontScheme}"; } } diff --git a/ClosedXML/Excel/Style/XLFontName.cs b/ClosedXML/Excel/Style/XLFontName.cs new file mode 100644 index 000000000..0209880dd --- /dev/null +++ b/ClosedXML/Excel/Style/XLFontName.cs @@ -0,0 +1,38 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using ClosedXML.Excel.Formatting; + +namespace ClosedXML.Excel; + +/// +/// A font name, two font names are equal when they are case insensitive equal. It is a custom +/// class because that way and other structures don't have to implement +/// custom hash code and equality methods. +/// +internal readonly record struct XLFontName +{ + private const StringComparison Comparison = StringComparison.OrdinalIgnoreCase; + + private XLFontName(string text) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException(nameof(text)); + + Text = text; + } + + public string Text { get; } + + public override int GetHashCode() + { + return Text.GetHashCode(Comparison); + } + + public bool Equal(XLFontName other) + { + return string.Equals(Text, other.Text, Comparison); + } + + [return: NotNullIfNotNull(nameof(text))] + public static implicit operator XLFontName?(string? text) => !string.IsNullOrWhiteSpace(text) ? new XLFontName(text) : null; +} diff --git a/ClosedXML/Excel/Style/XLFontSize.cs b/ClosedXML/Excel/Style/XLFontSize.cs new file mode 100644 index 000000000..7a95dc268 --- /dev/null +++ b/ClosedXML/Excel/Style/XLFontSize.cs @@ -0,0 +1,27 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace ClosedXML.Excel; + +/// +/// Size of a font stored in twips. Storing font size as double causes various problems with +/// equality of font size. Per MS-OI29500: Office converts the points provided to twips, and +/// rounding may occur when writing sz@val back to SpreadsheetML files. +/// +internal readonly record struct XLFontSize(short Twips) +{ + [return: NotNullIfNotNull(nameof(sizeInPoints))] + public static XLFontSize? FromPoints(double? sizeInPoints) + { + if (sizeInPoints is null) + return null; + + var twips = Math.Round(sizeInPoints.Value * 20, MidpointRounding.AwayFromZero); + return new XLFontSize(checked((short)twips)); + } + + /// + /// Font size converted to points. Can have rounding issues, so use only when necessary. + /// + public double Points => Twips / 20.0; +} diff --git a/ClosedXML/Excel/Style/XLFontValue.cs b/ClosedXML/Excel/Style/XLFontValue.cs index 37b4d7430..6420736b6 100644 --- a/ClosedXML/Excel/Style/XLFontValue.cs +++ b/ClosedXML/Excel/Style/XLFontValue.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.Caching; +#nullable disable + +using ClosedXML.Excel.Caching; using System.Collections.Generic; namespace ClosedXML.Excel @@ -19,11 +21,13 @@ public static XLFontValue FromKey(ref XLFontKey key) Underline = XLFontUnderlineValues.None, Strikethrough = false, VerticalAlignment = XLFontVerticalTextAlignmentValues.Baseline, + Shadow = false, FontSize = 11, FontColor = XLColor.FromArgb(0, 0, 0).Key, FontName = "Calibri", FontFamilyNumbering = XLFontFamilyNumberingValues.Swiss, - FontCharSet = XLFontCharSet.Default + FontCharSet = XLFontCharSet.Default, + FontScheme = XLFontScheme.None }; internal static readonly XLFontValue Default = FromKey(ref DefaultKey); @@ -51,6 +55,8 @@ public static XLFontValue FromKey(ref XLFontKey key) public XLFontCharSet FontCharSet { get { return Key.FontCharSet; } } + public XLFontScheme FontScheme { get { return Key.FontScheme; } } + private XLFontValue(XLFontKey key) { Key = key; diff --git a/ClosedXML/Excel/Style/XLGradientType.cs b/ClosedXML/Excel/Style/XLGradientType.cs new file mode 100644 index 000000000..b2c634e72 --- /dev/null +++ b/ClosedXML/Excel/Style/XLGradientType.cs @@ -0,0 +1,17 @@ +namespace ClosedXML.Excel; + +/// +/// A type gradient fill. +/// +internal enum XLGradientType +{ + /// + /// Gradient fill is linear. The color transition is along a line (diagonal, horizontal...). + /// + Linear, + + /// + /// Gradient fill is path. The color transition is along a rectangle, where color happens from center of rectangle outwards. + /// + Path +} diff --git a/ClosedXML/Excel/Style/XLNumberFormat.cs b/ClosedXML/Excel/Style/XLNumberFormat.cs index 54ffb778c..ff0c41c1a 100644 --- a/ClosedXML/Excel/Style/XLNumberFormat.cs +++ b/ClosedXML/Excel/Style/XLNumberFormat.cs @@ -1,152 +1,147 @@ using System; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +internal class XLNumberFormat : IXLNumberFormat { - internal class XLNumberFormat : IXLNumberFormat - { - #region Static members + #region Static members - internal static XLNumberFormatKey GenerateKey(IXLNumberFormat defaultNumberFormat) - { - if (defaultNumberFormat == null) - return XLNumberFormatValue.Default.Key; + internal static XLNumberFormatKey GenerateKey(IXLNumberFormat? defaultNumberFormat) + { + if (defaultNumberFormat == null) + return XLNumberFormatValue.Default.Key; - if (defaultNumberFormat is XLNumberFormat) - return (defaultNumberFormat as XLNumberFormat).Key; + if (defaultNumberFormat is XLNumberFormat format) + return format.Key; - return new XLNumberFormatKey - { - NumberFormatId = defaultNumberFormat.NumberFormatId, - Format = defaultNumberFormat.Format - }; - } + return new XLNumberFormatKey + { + NumberFormatId = defaultNumberFormat.NumberFormatId, + Format = defaultNumberFormat.Format + }; + } - #endregion Static members + #endregion Static members - #region Properties + #region Properties - private readonly XLStyle _style; + private readonly XLStyle _style; - private XLNumberFormatValue _value; + private XLNumberFormatValue _value; - internal XLNumberFormatKey Key - { - get { return _value.Key; } - private set { _value = XLNumberFormatValue.FromKey(ref value); } - } + internal XLNumberFormatKey Key + { + get => _value.Key; + private set => _value = XLNumberFormatValue.FromKey(ref value); + } - #endregion Properties + #endregion Properties - #region Constructors + #region Constructors - /// - /// Create an instance of XLNumberFormat initializing it with the specified value. - /// - /// Style to attach the new instance to. - /// Style value to use. - public XLNumberFormat(XLStyle style, XLNumberFormatValue value) - { - _style = style ?? XLStyle.CreateEmptyStyle(); - _value = value; - } + /// + /// Create an instance of XLNumberFormat initializing it with the specified value. + /// + /// Style to attach the new instance to. + /// Style value to use. + public XLNumberFormat(XLStyle? style, XLNumberFormatValue value) + { + _style = style ?? XLStyle.CreateEmptyStyle(); + _value = value; + } - public XLNumberFormat(XLStyle style, XLNumberFormatKey key) : this(style, XLNumberFormatValue.FromKey(ref key)) - { - } + public XLNumberFormat(XLStyle? style, XLNumberFormatKey key) : this(style, XLNumberFormatValue.FromKey(ref key)) + { + } - public XLNumberFormat(XLStyle style = null, IXLNumberFormat d = null) : this(style, GenerateKey(d)) - { - } + public XLNumberFormat(XLStyle? style = null, IXLNumberFormat? d = null) : this(style, GenerateKey(d)) + { + } - #endregion Constructors + #endregion Constructors - #region IXLNumberFormat Members + #region IXLNumberFormat Members - public Int32 NumberFormatId + public Int32 NumberFormatId + { + get => Key.NumberFormatId; + set { - get { return Key.NumberFormatId; } - set + Modify(_ => new XLNumberFormatKey { - Modify(k => - { - k.Format = XLNumberFormatValue.Default.Format; - k.NumberFormatId = value; - return k; - }); - } + Format = XLNumberFormatValue.Default.Format, + NumberFormatId = value, + }); } + } - public String Format + public String Format + { + get => Key.Format; + set { - get { return Key.Format; } - set + Modify(_ => new XLNumberFormatKey { - Modify(k => - { - k.Format = value; - if (string.IsNullOrWhiteSpace(k.Format)) - k.NumberFormatId = XLNumberFormatValue.Default.NumberFormatId; - else - k.NumberFormatId = -1; - return k; - }); - } - } - - public IXLStyle SetNumberFormatId(Int32 value) - { - NumberFormatId = value; - return _style; - } - - public IXLStyle SetFormat(String value) - { - Format = value; - return _style; + Format = value, + NumberFormatId = string.IsNullOrWhiteSpace(value) + ? XLNumberFormatValue.Default.NumberFormatId + : XLNumberFormatKey.CustomFormatNumberId + }); } + } - #endregion IXLNumberFormat Members + public IXLStyle SetNumberFormatId(Int32 value) + { + NumberFormatId = value; + return _style; + } - private void Modify(Func modification) - { - Key = modification(Key); + public IXLStyle SetFormat(String value) + { + Format = value; + return _style; + } - _style.Modify(styleKey => - { - var numberFormat = styleKey.NumberFormat; - styleKey.NumberFormat = modification(numberFormat); - return styleKey; - }); - } + #endregion IXLNumberFormat Members - #region Overridden + private void Modify(Func modification) + { + Key = modification(Key); - public override string ToString() + _style.Modify(styleKey => { - return NumberFormatId + "-" + Format; - } + var numberFormat = modification(styleKey.NumberFormat); + return styleKey with { NumberFormat = numberFormat }; + }); + } - public override bool Equals(object obj) - { - return Equals(obj as IXLNumberFormatBase); - } + #region Overridden - public bool Equals(IXLNumberFormatBase other) - { - var otherN = other as XLNumberFormat; - if (otherN == null) - return false; + public override string ToString() + { + return NumberFormatId + "-" + Format; + } - return Key == otherN.Key; - } + public override bool Equals(object obj) + { + return Equals(obj as IXLNumberFormatBase); + } - public override int GetHashCode() - { - var hashCode = 416600561; - hashCode = hashCode * -1521134295 + Key.GetHashCode(); - return hashCode; - } + public bool Equals(IXLNumberFormatBase? other) + { + var otherN = other as XLNumberFormat; + if (otherN == null) + return false; - #endregion Overridden + return Key == otherN.Key; } + + public override int GetHashCode() + { + var hashCode = 416600561; + hashCode = hashCode * -1521134295 + Key.GetHashCode(); + return hashCode; + } + + #endregion Overridden } diff --git a/ClosedXML/Excel/Style/XLNumberFormatKey.cs b/ClosedXML/Excel/Style/XLNumberFormatKey.cs index 7e979a902..226f6d8c2 100644 --- a/ClosedXML/Excel/Style/XLNumberFormatKey.cs +++ b/ClosedXML/Excel/Style/XLNumberFormatKey.cs @@ -1,42 +1,37 @@ -using System; +using System; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +internal readonly record struct XLNumberFormatKey { - internal struct XLNumberFormatKey : IEquatable - { - public int NumberFormatId { get; set; } + /// + /// The value -1 that is set to , if is + /// set to user-defined format (non-empty string). + /// + public const int CustomFormatNumberId = -1; - public string Format { get; set; } + /// + /// Number format identifier of predefined format, see . + /// If -1, the format is custom and stored in the . + /// + public required int NumberFormatId { get; init; } - public override int GetHashCode() - { - var hashCode = -759193072; - hashCode = hashCode * -1521134295 + NumberFormatId; - hashCode = hashCode * -1521134295 + (Format == null ? 0 : Format.GetHashCode()); - return hashCode; - } - - public bool Equals(XLNumberFormatKey other) - { - return - NumberFormatId == other.NumberFormatId - && string.Equals(Format, other.Format); - } + public required string Format { get; init; } - public override bool Equals(object obj) - { - if (obj is XLNumberFormatKey) - return Equals((XLNumberFormatKey)obj); - return base.Equals(obj); - } + public static XLNumberFormatKey ForFormat(string customFormat) + { + if (string.IsNullOrEmpty(customFormat)) + throw new ArgumentException(); - public override string ToString() + return new XLNumberFormatKey { - return $"{Format}/{NumberFormatId}"; - } - - public static bool operator ==(XLNumberFormatKey left, XLNumberFormatKey right) => left.Equals(right); + NumberFormatId = CustomFormatNumberId, + Format = customFormat, + }; + } - public static bool operator !=(XLNumberFormatKey left, XLNumberFormatKey right) => !(left.Equals(right)); + public override string ToString() + { + return $"{Format}/{NumberFormatId}"; } } diff --git a/ClosedXML/Excel/Style/XLNumberFormatValue.cs b/ClosedXML/Excel/Style/XLNumberFormatValue.cs index 360ae8ca3..5cf801cbb 100644 --- a/ClosedXML/Excel/Style/XLNumberFormatValue.cs +++ b/ClosedXML/Excel/Style/XLNumberFormatValue.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.Caching; +#nullable disable + +using ClosedXML.Excel.Caching; namespace ClosedXML.Excel { @@ -11,6 +13,9 @@ public static XLNumberFormatValue FromKey(ref XLNumberFormatKey key) return Repository.GetOrCreate(ref key); } + /// + /// General number format. + /// private static readonly XLNumberFormatKey DefaultKey = new XLNumberFormatKey { NumberFormatId = 0, @@ -21,6 +26,12 @@ public static XLNumberFormatValue FromKey(ref XLNumberFormatKey key) public XLNumberFormatKey Key { get; private set; } + /// + /// Id of the number format. Every workbook has + /// built-int formats that start at 0 (General format). The built-int formats are + /// not explicitly written and might differ depending on culture. Custom number formats + /// have a valid and the id is -1. + /// public int NumberFormatId { get { return Key.NumberFormatId; } } public string Format { get { return Key.Format; } } @@ -41,5 +52,11 @@ public override int GetHashCode() { return 1507230172 + Key.GetHashCode(); } + + internal XLNumberFormatValue WithNumberFormatId(int numberFormatId) + { + var keyCopy = Key with { NumberFormatId = numberFormatId }; + return FromKey(ref keyCopy); + } } } diff --git a/ClosedXML/Excel/Style/XLPredefinedFormat.cs b/ClosedXML/Excel/Style/XLPredefinedFormat.cs index e256a30fb..4f49f72c4 100644 --- a/ClosedXML/Excel/Style/XLPredefinedFormat.cs +++ b/ClosedXML/Excel/Style/XLPredefinedFormat.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace ClosedXML.Excel { @@ -159,8 +159,11 @@ public enum DateTime Hour12MinutesSeconds = 46, /// - /// mmss.0 + /// mm:ss.0 /// + /// + /// OOXML specification is missing colon. + /// MinutesSecondsMillis1 = 47, /// @@ -169,7 +172,7 @@ public enum DateTime Text = 49 } - private static IDictionary _formatCodes; + private static IDictionary? _formatCodes; internal static IDictionary FormatCodes { @@ -205,7 +208,7 @@ internal static IDictionary FormatCodes {40, "#,##0.00;[Red](#,##0.00)"}, {45, "mm:ss"}, {46, "[h]:mm:ss"}, - {47, "mmss.0"}, + {47, "mm:ss.0"}, {48, "##0.0E+0"}, {49, "@"} }; diff --git a/ClosedXML/Excel/Style/XLProtection.cs b/ClosedXML/Excel/Style/XLProtection.cs index 81045c4c5..0d21e6918 100644 --- a/ClosedXML/Excel/Style/XLProtection.cs +++ b/ClosedXML/Excel/Style/XLProtection.cs @@ -6,12 +6,12 @@ internal class XLProtection : IXLProtection { #region Static members - internal static XLProtectionKey GenerateKey(IXLProtection defaultProtection) + internal static XLProtectionKey GenerateKey(IXLProtection? defaultProtection) { if (defaultProtection == null) return XLProtectionValue.Default.Key; - if (defaultProtection is XLProtection) - return (defaultProtection as XLProtection).Key; + if (defaultProtection is XLProtection protection) + return protection.Key; return new XLProtectionKey { @@ -43,17 +43,17 @@ internal XLProtectionKey Key /// /// Style to attach the new instance to. /// Style value to use. - public XLProtection(XLStyle style, XLProtectionValue value) + public XLProtection(XLStyle? style, XLProtectionValue value) { _style = style ?? XLStyle.CreateEmptyStyle(); _value = value; } - public XLProtection(XLStyle style, XLProtectionKey key) : this(style, XLProtectionValue.FromKey(ref key)) + public XLProtection(XLStyle? style, XLProtectionKey key) : this(style, XLProtectionValue.FromKey(ref key)) { } - public XLProtection(XLStyle style = null, IXLProtection d = null) : this(style, GenerateKey(d)) + public XLProtection(XLStyle? style = null, IXLProtection? d = null) : this(style, GenerateKey(d)) { } @@ -66,7 +66,7 @@ public Boolean Locked get { return Key.Locked; } set { - Modify(k => { k.Locked = value; return k; }); + Modify(k => k with { Locked = value }); } } @@ -75,7 +75,7 @@ public Boolean Hidden get { return Key.Hidden; } set { - Modify(k => { k.Hidden = value; return k; }); + Modify(k => k with { Hidden = value }); } } @@ -111,9 +111,8 @@ private void Modify(Func modification) _style.Modify(styleKey => { - var protection = styleKey.Protection; - styleKey.Protection = modification(protection); - return styleKey; + var protection = modification(styleKey.Protection); + return styleKey with { Protection = protection }; }); } diff --git a/ClosedXML/Excel/Style/XLProtectionKey.cs b/ClosedXML/Excel/Style/XLProtectionKey.cs index ba4f01320..f42d5532d 100644 --- a/ClosedXML/Excel/Style/XLProtectionKey.cs +++ b/ClosedXML/Excel/Style/XLProtectionKey.cs @@ -1,42 +1,13 @@ -using System; +namespace ClosedXML.Excel; -namespace ClosedXML.Excel +internal readonly record struct XLProtectionKey { - internal struct XLProtectionKey : IEquatable - { - public bool Locked { get; set; } - - public bool Hidden { get; set; } - - public override int GetHashCode() - { - var hashCode = -1357408252; - hashCode = hashCode * -1521134295 + Locked.GetHashCode(); - hashCode = hashCode * -1521134295 + Hidden.GetHashCode(); - return hashCode; - } - - public bool Equals(XLProtectionKey other) - { - return - Locked == other.Locked - && Hidden == other.Hidden; - } + public required bool Locked { get; init; } - public override bool Equals(object obj) - { - if (obj is XLProtectionKey) - return Equals((XLProtectionKey)obj); - return base.Equals(obj); - } + public required bool Hidden { get; init; } - public override string ToString() - { - return (Locked ? "Locked" : "") + (Hidden ? "Hidden" : ""); - } - - public static bool operator ==(XLProtectionKey left, XLProtectionKey right) => left.Equals(right); - - public static bool operator !=(XLProtectionKey left, XLProtectionKey right) => !(left.Equals(right)); + public override string ToString() + { + return (Locked ? "Locked" : "") + (Hidden ? "Hidden" : ""); } } diff --git a/ClosedXML/Excel/Style/XLProtectionValue.cs b/ClosedXML/Excel/Style/XLProtectionValue.cs index fba99e289..fa58a3c06 100644 --- a/ClosedXML/Excel/Style/XLProtectionValue.cs +++ b/ClosedXML/Excel/Style/XLProtectionValue.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.Caching; +#nullable disable + +using ClosedXML.Excel.Caching; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/Style/XLStyle.cs b/ClosedXML/Excel/Style/XLStyle.cs index a09864df3..7409ab879 100644 --- a/ClosedXML/Excel/Style/XLStyle.cs +++ b/ClosedXML/Excel/Style/XLStyle.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Text; @@ -13,15 +15,16 @@ internal static XLStyleKey GenerateKey(IXLStyle initialStyle) { if (initialStyle == null) return Default.Key; - if (initialStyle is XLStyle) - return (initialStyle as XLStyle).Key; + if (initialStyle is XLStyle style) + return style.Key; return new XLStyleKey { - Font = XLFont.GenerateKey(initialStyle.Font), Alignment = XLAlignment.GenerateKey(initialStyle.Alignment), Border = XLBorder.GenerateKey(initialStyle.Border), Fill = XLFill.GenerateKey(initialStyle.Fill), + Font = XLFont.GenerateKey(initialStyle.Font), + IncludeQuotePrefix = initialStyle.IncludeQuotePrefix, NumberFormat = XLNumberFormat.GenerateKey(initialStyle.NumberFormat), Protection = XLProtection.GenerateKey(initialStyle.Protection) }; @@ -95,7 +98,7 @@ public IXLFont Font get { return new XLFont(this, Value.Font); } set { - Modify(k => { k.Font = XLFont.GenerateKey(value); return k; }); + Modify(k => k with { Font = XLFont.GenerateKey(value) }); } } @@ -104,7 +107,7 @@ public IXLAlignment Alignment get { return new XLAlignment(this, Value.Alignment); } set { - Modify(k => { k.Alignment = XLAlignment.GenerateKey(value); return k; }); + Modify(k => k with { Alignment = XLAlignment.GenerateKey(value) }); } } @@ -113,7 +116,7 @@ public IXLBorder Border get { return new XLBorder(_container, this, Value.Border); } set { - Modify(k => { k.Border = XLBorder.GenerateKey(value); return k; }); + Modify(k => k with { Border = XLBorder.GenerateKey(value) }); } } @@ -122,7 +125,7 @@ public IXLFill Fill get { return new XLFill(this, Value.Fill); } set { - Modify(k => { k.Fill = XLFill.GenerateKey(value); return k; }); + Modify(k => k with { Fill = XLFill.GenerateKey(value) }); } } @@ -131,7 +134,7 @@ public Boolean IncludeQuotePrefix get { return Value.IncludeQuotePrefix; } set { - Modify(k => { k.IncludeQuotePrefix = value; return k; }); + Modify(k => k with { IncludeQuotePrefix = value }); } } @@ -146,7 +149,7 @@ public IXLNumberFormat NumberFormat get { return new XLNumberFormat(this, Value.NumberFormat); } set { - Modify(k => { k.NumberFormat = XLNumberFormat.GenerateKey(value); return k; }); + Modify(k => k with { NumberFormat = XLNumberFormat.GenerateKey(value) }); } } @@ -155,7 +158,7 @@ public IXLProtection Protection get { return new XLProtection(this, Value.Protection); } set { - Modify(k => { k.Protection = XLProtection.GenerateKey(value); return k; }); + Modify(k => k with { Protection = XLProtection.GenerateKey(value) }); } } @@ -193,8 +196,7 @@ public bool Equals(IXLStyle other) if (otherS == null) return false; - return Key == otherS.Key && - _container == otherS._container; + return Key == otherS.Key; } public override bool Equals(object obj) diff --git a/ClosedXML/Excel/Style/XLStyleKey.cs b/ClosedXML/Excel/Style/XLStyleKey.cs index 93fc2b758..180895ea5 100644 --- a/ClosedXML/Excel/Style/XLStyleKey.cs +++ b/ClosedXML/Excel/Style/XLStyleKey.cs @@ -1,51 +1,27 @@ -using System; +using System; -namespace ClosedXML.Excel -{ - internal struct XLStyleKey : IEquatable - { - public XLAlignmentKey Alignment { get; set; } - - public XLBorderKey Border { get; set; } +namespace ClosedXML.Excel; - public XLFillKey Fill { get; set; } +internal readonly record struct XLStyleKey +{ + public required XLAlignmentKey Alignment { get; init; } - public XLFontKey Font { get; set; } + public required XLBorderKey Border { get; init; } - public Boolean IncludeQuotePrefix { get; set; } + public required XLFillKey Fill { get; init; } - public XLNumberFormatKey NumberFormat { get; set; } + public required XLFontKey Font { get; init; } - public XLProtectionKey Protection { get; set; } + public required Boolean IncludeQuotePrefix { get; init; } - public override int GetHashCode() - { - var hashCode = -476701294; - hashCode = hashCode * -1521134295 + Alignment.GetHashCode(); - hashCode = hashCode * -1521134295 + Border.GetHashCode(); - hashCode = hashCode * -1521134295 + Fill.GetHashCode(); - hashCode = hashCode * -1521134295 + Font.GetHashCode(); - hashCode = hashCode * -1521134295 + IncludeQuotePrefix.GetHashCode(); - hashCode = hashCode * -1521134295 + NumberFormat.GetHashCode(); - hashCode = hashCode * -1521134295 + Protection.GetHashCode(); - return hashCode; - } + public required XLNumberFormatKey NumberFormat { get; init; } - public bool Equals(XLStyleKey other) - { - return Alignment == other.Alignment && - Border == other.Border && - Fill == other.Fill && - Font == other.Font && - IncludeQuotePrefix == other.IncludeQuotePrefix && - NumberFormat == other.NumberFormat && - Protection == other.Protection; - } + public required XLProtectionKey Protection { get; init; } - public override string ToString() - { - return - this == XLStyle.Default.Key ? "Default" : + public override string ToString() + { + return + this == XLStyle.Default.Key ? "Default" : string.Format("Alignment: {0} Border: {1} Fill: {2} Font: {3} IncludeQuotePrefix: {4} NumberFormat: {5} Protection: {6}", Alignment == XLStyle.Default.Key.Alignment ? "Default" : Alignment.ToString(), Border == XLStyle.Default.Key.Border ? "Default" : Border.ToString(), @@ -54,35 +30,23 @@ public override string ToString() IncludeQuotePrefix == XLStyle.Default.Key.IncludeQuotePrefix ? "Default" : IncludeQuotePrefix.ToString(), NumberFormat == XLStyle.Default.Key.NumberFormat ? "Default" : NumberFormat.ToString(), Protection == XLStyle.Default.Key.Protection ? "Default" : Protection.ToString()); - } - - public override bool Equals(object obj) - { - if (obj is XLStyleKey) - return Equals((XLStyleKey)obj); - return base.Equals(obj); - } - - public static bool operator ==(XLStyleKey left, XLStyleKey right) => left.Equals(right); - - public static bool operator !=(XLStyleKey left, XLStyleKey right) => !(left.Equals(right)); + } - public void Deconstruct( - out XLAlignmentKey alignment, - out XLBorderKey border, - out XLFillKey fill, - out XLFontKey font, - out Boolean includeQuotePrefix, - out XLNumberFormatKey numberFormat, - out XLProtectionKey protection) - { - alignment = Alignment; - border = Border; - fill = Fill; - font = Font; - includeQuotePrefix = IncludeQuotePrefix; - numberFormat = NumberFormat; - protection = Protection; - } + public void Deconstruct( + out XLAlignmentKey alignment, + out XLBorderKey border, + out XLFillKey fill, + out XLFontKey font, + out Boolean includeQuotePrefix, + out XLNumberFormatKey numberFormat, + out XLProtectionKey protection) + { + alignment = Alignment; + border = Border; + fill = Fill; + font = Font; + includeQuotePrefix = IncludeQuotePrefix; + numberFormat = NumberFormat; + protection = Protection; } } diff --git a/ClosedXML/Excel/Style/XLStyleValue.cs b/ClosedXML/Excel/Style/XLStyleValue.cs index a19651985..e6132ce4b 100644 --- a/ClosedXML/Excel/Style/XLStyleValue.cs +++ b/ClosedXML/Excel/Style/XLStyleValue.cs @@ -1,18 +1,22 @@ -using ClosedXML.Excel.Caching; +using ClosedXML.Excel.Caching; using System; namespace ClosedXML.Excel { - internal class XLStyleValue + /// + /// An immutable style value. + /// + internal sealed class XLStyleValue : IEquatable { - private static readonly XLStyleRepository Repository = new XLStyleRepository(key => new XLStyleValue(key)); + private static readonly XLStyleRepository Repository = new(key => new XLStyleValue(key)); + private int? _hashCode; // Cached hash key public static XLStyleValue FromKey(ref XLStyleKey key) { return Repository.GetOrCreate(ref key); } - private static readonly XLStyleKey DefaultKey = new XLStyleKey + private static readonly XLStyleKey DefaultKey = new() { Alignment = XLAlignmentValue.Default.Key, Border = XLBorderValue.Default.Key, @@ -25,22 +29,6 @@ public static XLStyleValue FromKey(ref XLStyleKey key) internal static readonly XLStyleValue Default = FromKey(ref DefaultKey); - public XLStyleKey Key { get; private set; } - - public XLAlignmentValue Alignment { get; private set; } - - public XLBorderValue Border { get; private set; } - - public XLFillValue Fill { get; private set; } - - public XLFontValue Font { get; private set; } - - public Boolean IncludeQuotePrefix { get; private set; } - - public XLNumberFormatValue NumberFormat { get; private set; } - - public XLProtectionValue Protection { get; private set; } - internal XLStyleValue(XLStyleKey key) { Key = key; @@ -54,14 +42,39 @@ internal XLStyleValue(XLStyleKey key) Protection = XLProtectionValue.FromKey(ref protection); } + internal XLStyleKey Key { get; } + + internal XLAlignmentValue Alignment { get; } + + internal XLBorderValue Border { get; } + + internal XLFillValue Fill { get; } + + internal XLFontValue Font { get; } + + internal Boolean IncludeQuotePrefix { get; } + + internal XLNumberFormatValue NumberFormat { get; } + + internal XLProtectionValue Protection { get; } + public override bool Equals(object obj) { if (ReferenceEquals(this, obj)) return true; - var cached = obj as XLStyleValue; - return cached != null && - Key.Equals(cached.Key); + return Equals(obj as XLStyleValue); + } + + public bool Equals(XLStyleValue? other) + { + if (other is null) + return false; + + if (_hashCode.HasValue && other._hashCode.HasValue && _hashCode != other._hashCode) + return false; + + return Key.Equals(other.Key); } public override int GetHashCode() @@ -73,25 +86,109 @@ public override int GetHashCode() return _hashCode.Value; } - public static bool operator ==(XLStyleValue left, XLStyleValue right) + public static bool operator ==(XLStyleValue? left, XLStyleValue? right) { - if (ReferenceEquals(left, right)) - return true; - if (ReferenceEquals(left, null) && ReferenceEquals(right, null)) - return true; - if (ReferenceEquals(left, null) || ReferenceEquals(right, null)) - return false; - if (left._hashCode.HasValue && right._hashCode.HasValue && - left._hashCode != right._hashCode) - return false; - return left.Key.Equals(right.Key); + if (left is null) + return right is null; + + return left.Equals(right); } - public static bool operator !=(XLStyleValue left, XLStyleValue right) + public static bool operator !=(XLStyleValue? left, XLStyleValue? right) { return !(left == right); } - private int? _hashCode; + /// + /// Combine row and column styles into a combined style. This style is used by non-pinged + /// cells of a worksheet. + /// + internal static XLStyleValue Combine(XLStyleValue sheetStyle, XLStyleValue rowStyle, XLStyleValue colStyle) + { + var isRowSame = ReferenceEquals(sheetStyle, rowStyle); + var isColSame = ReferenceEquals(sheetStyle, colStyle); + + if (isRowSame && isColSame) + return sheetStyle; + + // At least one style is different, maybe both. + if (isRowSame) + return colStyle; + + if (isColSame) + return rowStyle; + + // Both styles are different from sheet one, merge. If both style components differ, + // row has a preference because Excel gives it preference. Generally, if there is + // row/col style conflict, all cells affected by conflict should be materialized (aka + // 'pinged') during row/col style modification and have their own style explicitly + // specified to avoid ambiguity, so we shouldn't really need to rely on this + // resolution, it's just last ditch effort. + var alignment = GetExplicitlySet(sheetStyle.Alignment, rowStyle.Alignment, colStyle.Alignment); + var border = GetExplicitlySet(sheetStyle.Border, rowStyle.Border, colStyle.Border); + var fill = GetExplicitlySet(sheetStyle.Fill, rowStyle.Fill, colStyle.Fill); + var font = GetExplicitlySet(sheetStyle.Font, rowStyle.Font, colStyle.Font); + var includeQuotePrefix = GetExplicitlySet(sheetStyle.IncludeQuotePrefix, rowStyle.IncludeQuotePrefix, colStyle.IncludeQuotePrefix); + var numberFormat = GetExplicitlySet(sheetStyle.NumberFormat, rowStyle.NumberFormat, colStyle.NumberFormat); + var protection = GetExplicitlySet(sheetStyle.Protection, rowStyle.Protection, colStyle.Protection); + + var combinedStyleKey = new XLStyleKey + { + Alignment = alignment.Key, + Border = border.Key, + Fill = fill.Key, + Font = font.Key, + IncludeQuotePrefix = includeQuotePrefix, + NumberFormat = numberFormat.Key, + Protection = protection.Key, + }; + return Repository.GetOrCreate(ref combinedStyleKey); + + static T GetExplicitlySet(T sheetComponent, T rowComponent, T colComponent) + where T : notnull + { + // Use reference equal to speed up the process instead of standard equals. + var rowHasSameComponent = typeof(T).IsClass + ? ReferenceEquals(sheetComponent, rowComponent) + : sheetComponent.Equals(rowComponent); + var colHasSameComponent = typeof(T).IsClass + ? ReferenceEquals(sheetComponent, colComponent) + : sheetComponent.Equals(colComponent); + + if (rowHasSameComponent && colHasSameComponent) + return sheetComponent; + + // At least one style is different, maybe both. + if (rowHasSameComponent) + return colComponent; + + // If col has same component as sheet, we should return row. + // If both are different, row component should have precedence. + return rowComponent; + } + } + + internal XLStyleValue WithAlignment(Func modify) + { + return WithAlignment(modify(Alignment)); + } + + internal XLStyleValue WithAlignment(XLAlignmentValue alignment) + { + var keyCopy = Key with { Alignment = alignment.Key }; + return FromKey(ref keyCopy); + } + + internal XLStyleValue WithIncludeQuotePrefix(bool includeQuotePrefix) + { + var keyCopy = Key with { IncludeQuotePrefix = includeQuotePrefix }; + return FromKey(ref keyCopy); + } + + internal XLStyleValue WithNumberFormat(XLNumberFormatValue numberFormat) + { + var keyCopy = Key with { NumberFormat = numberFormat.Key }; + return FromKey(ref keyCopy); + } } } diff --git a/ClosedXML/Excel/Style/XLStylizedBase.cs b/ClosedXML/Excel/Style/XLStylizedBase.cs index 325dd5847..0908d8ba4 100644 --- a/ClosedXML/Excel/Style/XLStylizedBase.cs +++ b/ClosedXML/Excel/Style/XLStylizedBase.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; @@ -15,28 +17,22 @@ internal abstract class XLStylizedBase : IXLStylized /// /// Read-only style property. /// - internal XLStyleValue StyleValue { get; private protected set; } + internal virtual XLStyleValue StyleValue { get; private protected set; } + + /// XLStyleValue IXLStylized.StyleValue { get { return StyleValue; } } - /// - /// Editable style of the workbook element. Modification of this property DOES affect styles of child objects as well - they will - /// be changed accordingly. Accessing this property causes a new instance generated so use this property - /// with caution. If you need only _read_ the style consider using property instead. - /// + /// public IXLStyle Style { get { return InnerStyle; } set { SetStyle(value, true); } } - /// - /// Editable style of the workbook element. Modification of this property DOES NOT affect styles of child objects. - /// Accessing this property causes a new instance generated so use this property with caution. If you need - /// only _read_ the style consider using property instead. - /// + /// public IXLStyle InnerStyle { get { return new XLStyle(this, StyleValue.Key); } @@ -50,15 +46,18 @@ public IXLStyle InnerStyle public abstract IXLRanges RangesUsed { get; } - public abstract IEnumerable Styles { get; } - #endregion Properties - protected XLStylizedBase(XLStyleValue styleValue = null) + protected XLStylizedBase(XLStyleValue styleValue) { StyleValue = styleValue ?? XLWorkbook.DefaultStyleValue; } + protected XLStylizedBase() + { + // Ctor only for XLCell that stores `StyleValue` in a slice. + } + #region Private methods private void SetStyle(IXLStyle style, bool propagate = false) @@ -104,10 +103,21 @@ void IXLStylized.ModifyStyle(Func modification) } } - private IEnumerable GetChildrenRecursively(XLStylizedBase parent) + private static HashSet GetChildrenRecursively(XLStylizedBase parent) { - return new List { parent } - .Union(parent.Children.Where(child => child != parent).SelectMany(child => GetChildrenRecursively(child))); + void Collect(XLStylizedBase root, HashSet collector) + { + collector.Add(root); + foreach (var child in root.Children) + { + Collect(child, collector); + } + } + + var results = new HashSet(); + Collect(parent, results); + + return results; } #endregion Private methods diff --git a/ClosedXML/Excel/Style/XLStylizedEmpty.cs b/ClosedXML/Excel/Style/XLStylizedEmpty.cs index 9f45588ff..3cc1a9a20 100644 --- a/ClosedXML/Excel/Style/XLStylizedEmpty.cs +++ b/ClosedXML/Excel/Style/XLStylizedEmpty.cs @@ -4,19 +4,11 @@ namespace ClosedXML.Excel { internal class XLStylizedEmpty : XLStylizedBase, IXLStylized { - public XLStylizedEmpty(IXLStyle defaultStyle) + public XLStylizedEmpty(IXLStyle? defaultStyle) : base((defaultStyle as XLStyle)?.Value) { } - public override IEnumerable Styles - { - get - { - yield return Style; - } - } - public override IXLRanges RangesUsed { get { return new XLRanges(); } diff --git a/ClosedXML/Excel/Style/XLTableStyleType.cs b/ClosedXML/Excel/Style/XLTableStyleType.cs new file mode 100644 index 000000000..72868d1cd --- /dev/null +++ b/ClosedXML/Excel/Style/XLTableStyleType.cs @@ -0,0 +1,36 @@ +namespace ClosedXML.Excel; + +/// +/// Which part of a table/pivot table should be the formatting applied to. +/// +internal enum XLTableStyleType +{ + WholeTable, + HeaderRow, + TotalRow, + FirstColumn, + LastColumn, + FirstRowStripe, + SecondRowStripe, + FirstColumnStripe, + SecondColumnStripe, + FirstHeaderCell, + LastHeaderCell, + FirstTotalCell, + LastTotalCell, + FirstSubtotalColumn, + SecondSubtotalColumn, + ThirdSubtotalColumn, + FirstSubtotalRow, + SecondSubtotalRow, + ThirdSubtotalRow, + BlankRow, + FirstColumnSubheading, + SecondColumnSubheading, + ThirdColumnSubheading, + FirstRowSubheading, + SecondRowSubheading, + ThirdRowSubheading, + PageFieldLabels, + PageFieldValues +} diff --git a/ClosedXML/Excel/Style/XLWorkbookStyles.cs b/ClosedXML/Excel/Style/XLWorkbookStyles.cs new file mode 100644 index 000000000..ef46d7996 --- /dev/null +++ b/ClosedXML/Excel/Style/XLWorkbookStyles.cs @@ -0,0 +1,45 @@ +using ClosedXML.Excel.Formatting; +using System.Collections.Generic; + +namespace ClosedXML.Excel; + +/// +/// A container for styles and formatting records in a workbook. +/// +internal class XLWorkbookStyles +{ + /// + /// The index is XfId, the value is formatting record. + /// + private readonly Dictionary _masterFormats; + + private readonly Dictionary _fontFormats; + + internal XLWorkbookStyles() + { + _masterFormats = new Dictionary(); + _fontFormats = new Dictionary(); + } + + internal XLStyleKey ApplyFontFormat(int fontId, ref XLStyleKey xlStyle) + { + var fontFormat = _fontFormats[fontId]; + var xlFont = fontFormat.ApplyTo(xlStyle.Font); + return xlStyle with { Font = xlFont }; + } + + internal void AddFontFormat(XLFontFormat fontFormat) + { + _fontFormats.Add(_fontFormats.Count, fontFormat); + } + + internal void AddFormat(uint? fontId) + { + var xfId = _masterFormats.Count; + XLFontFormat? font = fontId is not null ? _fontFormats[checked((int)fontId)] : null; + _masterFormats.Add(xfId, new XLCellFormat + { + Font = font + }); + } +} diff --git a/ClosedXML/Excel/Tables/IXLTable.cs b/ClosedXML/Excel/Tables/IXLTable.cs index e6c291240..348d56a5e 100644 --- a/ClosedXML/Excel/Tables/IXLTable.cs +++ b/ClosedXML/Excel/Tables/IXLTable.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections; using System.Collections.Generic; @@ -12,6 +14,11 @@ public interface IXLTable : IXLRange Boolean EmphasizeFirstColumn { get; set; } Boolean EmphasizeLastColumn { get; set; } IEnumerable Fields { get; } + + /// + /// Change the name of a table. Structural references to the table are not updated. + /// + /// If the new table name is already used by other table in the sheet. string Name { get; set; } Boolean ShowAutoFilter { get; set; } Boolean ShowColumnStripes { get; set; } @@ -26,6 +33,12 @@ public interface IXLTable : IXLRange /// Specify what you want to clear. new IXLTable Clear(XLClearOptions clearOptions = XLClearOptions.All); + /// + /// Get field of the table. + /// + /// Name of the field. Field names are case-insensitive. + /// Requested field. + /// Table doesn't contain field. IXLTableField Field(string fieldName); IXLTableField Field(int fieldIndex); @@ -120,21 +133,18 @@ public interface IXLTable : IXLRange /// Resizes the table to the specified range address. /// /// The new table range. - /// IXLTable Resize(IXLRange range); /// /// Resizes the table to the specified range address. /// /// The range boundaries. - /// IXLTable Resize(IXLRangeAddress rangeAddress); /// /// Resizes the table to the specified range address. /// /// The range boundaries. - /// IXLTable Resize(string rangeAddress); /// @@ -142,7 +152,6 @@ public interface IXLTable : IXLRange /// /// The first cell in the range. /// The last cell in the range. - /// IXLTable Resize(IXLCell firstCell, IXLCell lastCell); /// @@ -150,7 +159,6 @@ public interface IXLTable : IXLRange /// /// The first cell address in the worksheet. /// The last cell address in the worksheet. - /// IXLTable Resize(string firstCellAddress, string lastCellAddress); /// @@ -158,7 +166,6 @@ public interface IXLTable : IXLRange /// /// The first cell address in the worksheet. /// The last cell address in the worksheet. - /// IXLTable Resize(IXLAddress firstCellAddress, IXLAddress lastCellAddress); /// @@ -168,7 +175,6 @@ public interface IXLTable : IXLRange /// The first cell's column of the range to return. /// The last cell's row of the range to return. /// The last cell's column of the range to return. - /// IXLTable Resize(int firstCellRow, int firstCellColumn, int lastCellRow, int lastCellColumn); new IXLAutoFilter SetAutoFilter(); @@ -206,13 +212,11 @@ public interface IXLTable : IXLRange /// /// Converts the table to an enumerable of dynamic objects /// - /// IEnumerable AsDynamicEnumerable(); /// /// Converts the table to a standard .NET System.Data.DataTable /// - /// DataTable AsNativeDataTable(); IXLTable CopyTo(IXLWorksheet targetSheet); diff --git a/ClosedXML/Excel/Tables/IXLTableField.cs b/ClosedXML/Excel/Tables/IXLTableField.cs index c1e9107b1..5446304c6 100644 --- a/ClosedXML/Excel/Tables/IXLTableField.cs +++ b/ClosedXML/Excel/Tables/IXLTableField.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -37,10 +39,11 @@ public interface IXLTableField IXLCells DataCells { get; } /// - /// Gets the footer cell for the table field. + /// Gets the footer cell for the table field. /// /// - /// The footer cell. + /// The footer cell. null, if the table + /// doesn't have set . /// IXLCell TotalsCell { get; } @@ -48,7 +51,8 @@ public interface IXLTableField /// Gets the header cell for the table field. /// /// - /// The header cell. + /// The header cell.null, if the table + /// doesn't have set . /// IXLCell HeaderCell { get; } @@ -99,11 +103,12 @@ public interface IXLTableField XLTotalsRowFunction TotalsRowFunction { get; set; } /// - /// Gets or sets the totals row label. + /// Gets or sets the totals row label (the leftmost cell in the totals row). /// /// /// The totals row label. /// + /// If the totals row is not displayed for the table. String TotalsRowLabel { get; set; } /// diff --git a/ClosedXML/Excel/Tables/IXLTableRange.cs b/ClosedXML/Excel/Tables/IXLTableRange.cs index aeac6b81a..9f14a325a 100644 --- a/ClosedXML/Excel/Tables/IXLTableRange.cs +++ b/ClosedXML/Excel/Tables/IXLTableRange.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; @@ -9,9 +11,6 @@ public interface IXLTableRange : IXLRange IXLTableRow FirstRow(Func predicate = null); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLTableRow FirstRowUsed(Boolean includeFormats, Func predicate = null); - IXLTableRow FirstRowUsed(XLCellsUsedOptions options, Func predicate = null); IXLTableRow FirstRowUsed(Func predicate = null); @@ -22,9 +21,6 @@ public interface IXLTableRange : IXLRange IXLTableRow LastRow(Func predicate = null); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLTableRow LastRowUsed(Boolean includeFormats, Func predicate = null); - IXLTableRow LastRowUsed(XLCellsUsedOptions options, Func predicate = null); IXLTableRow LastRowUsed(Func predicate = null); @@ -33,7 +29,6 @@ public interface IXLTableRange : IXLRange /// Rows the specified row. /// /// 1-based row number relative to the first row of this range. - /// new IXLTableRow Row(int row); IXLTableRows Rows(Func predicate = null); @@ -43,14 +38,10 @@ public interface IXLTableRange : IXLRange /// /// The first row to return. 1-based row number relative to the first row of this range. /// The last row to return. 1-based row number relative to the first row of this range. - /// new IXLTableRows Rows(int firstRow, int lastRow); new IXLTableRows Rows(string rows); - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLTableRows RowsUsed(Boolean includeFormats, Func predicate = null); - IXLTableRows RowsUsed(XLCellsUsedOptions options, Func predicate = null); IXLTableRows RowsUsed(Func predicate = null); diff --git a/ClosedXML/Excel/Tables/IXLTableRow.cs b/ClosedXML/Excel/Tables/IXLTableRow.cs index 5722624ad..6695e79c9 100644 --- a/ClosedXML/Excel/Tables/IXLTableRow.cs +++ b/ClosedXML/Excel/Tables/IXLTableRow.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Tables/IXLTableRows.cs b/ClosedXML/Excel/Tables/IXLTableRows.cs index 95736844e..bc0ece5f2 100644 --- a/ClosedXML/Excel/Tables/IXLTableRows.cs +++ b/ClosedXML/Excel/Tables/IXLTableRows.cs @@ -1,5 +1,6 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned -using System; using System.Collections.Generic; namespace ClosedXML.Excel @@ -27,10 +28,7 @@ public interface IXLTableRows : IEnumerable /// /// Returns the collection of cells that have a value. /// - /// if set to true will return all cells with a value or a style different than the default. - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLCells CellsUsed(Boolean includeFormats); - + /// The options to determine whether a cell is used. IXLCells CellsUsed(XLCellsUsedOptions options); /// diff --git a/ClosedXML/Excel/Tables/IXLTables.cs b/ClosedXML/Excel/Tables/IXLTables.cs index a9ec66215..b91da93c1 100644 --- a/ClosedXML/Excel/Tables/IXLTables.cs +++ b/ClosedXML/Excel/Tables/IXLTables.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; diff --git a/ClosedXML/Excel/Tables/README.md b/ClosedXML/Excel/Tables/README.md new file mode 100644 index 000000000..b659620d2 --- /dev/null +++ b/ClosedXML/Excel/Tables/README.md @@ -0,0 +1,3 @@ +# Invariants + +`table/tableColumns/tableColumn[@totalsRowLabel]` attribute must match the cell value stored in the sheet at the totals row label cell. If the text in a cell doesn't match (or is missing), Excel considers the workbook to be corrupt and tries to repair it. diff --git a/ClosedXML/Excel/Tables/TableNameGenerator.cs b/ClosedXML/Excel/Tables/TableNameGenerator.cs new file mode 100644 index 000000000..828967e99 --- /dev/null +++ b/ClosedXML/Excel/Tables/TableNameGenerator.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel +{ + internal class TableNameGenerator + { + /// + /// Generate a non conflicting table name for the workbook + /// + /// Workbook to generate name for + /// "Base Table Name + /// Name for the table + internal static string GetNewTableName(IXLWorkbook workbook, string baseTableName = "Table") + { + var existingTableNames = new HashSet( + workbook.Worksheets + .SelectMany(ws => ws.Tables) + .Select(t => t.Name), + StringComparer.OrdinalIgnoreCase); + + var i = 1; + string tableName; + do + { + tableName = baseTableName + i; + i++; + } while (existingTableNames.Contains(tableName)); + + return tableName; + } + } +} diff --git a/ClosedXML/Excel/Tables/XLTable.cs b/ClosedXML/Excel/Tables/XLTable.cs index 8a3e1325a..23f78e5f0 100644 --- a/ClosedXML/Excel/Tables/XLTable.cs +++ b/ClosedXML/Excel/Tables/XLTable.cs @@ -1,9 +1,12 @@ -using System; +#nullable disable + +using System; using System.Collections; using System.Collections.Generic; using System.Data; using System.Diagnostics; using System.Dynamic; +using System.Globalization; using System.Linq; using System.Text; @@ -12,16 +15,10 @@ namespace ClosedXML.Excel [DebuggerDisplay("{Name}")] internal class XLTable : XLRange, IXLTable { - #region Private fields - private string _name; internal bool _showTotalsRow; internal HashSet _uniqueNames; - #endregion Private fields - - #region Constructor - /// /// The direct constructor should only be used in . /// @@ -30,9 +27,6 @@ public XLTable(XLRangeParameters xlRangeParameters) { InitializeValues(false); } - - #endregion Constructor - public override XLRangeType RangeType { get { return XLRangeType.Table; } @@ -51,22 +45,29 @@ public Dictionary FieldNames _lastRangeAddress = RangeAddress; RescanFieldNames(); - + return _fieldNames; } } + /// + /// Area of the range, including headings and totals, if table has them. + /// + internal XLSheetRange Area => XLSheetRange.FromRangeAddress(RangeAddress); + private void RescanFieldNames() { if (ShowHeaderRow) { - var oldFieldNames = _fieldNames ?? new Dictionary(); - _fieldNames = new Dictionary(); + var oldFieldNames = _fieldNames ?? CreateFieldNames(); + _fieldNames = CreateFieldNames(); var headersRow = HeadersRow(false); Int32 cellPos = 0; - foreach (var cell in headersRow.Cells()) + foreach (XLCell cell in headersRow.Cells()) { - var name = cell.GetString(); + var cellValue = cell.CachedValue; + var name = cellValue.ToString(CultureInfo.CurrentCulture); + if (oldFieldNames.TryGetValue(name, out IXLTableField tableField))// && tableField.Column.ColumnNumber() == cell.Address.ColumnNumber) { (tableField as XLTableField).Index = cellPos; @@ -79,13 +80,19 @@ private void RescanFieldNames() if (String.IsNullOrEmpty(name)) { name = GetUniqueName("Column", cellPos + 1, true); - cell.SetValue(name); - cell.DataType = XLDataType.Text; } if (_fieldNames.ContainsKey(name)) throw new ArgumentException("The header row contains more than one field name '" + name + "'."); _fieldNames.Add(name, new XLTableField(this, name) { Index = cellPos++ }); + + // Field names are the source of the truth that is projected + // to the cells and field names can be only text. Fix the cell, + // so cell fulfills its job of being dependent on the field name. + if (!cellValue.Equals(name)) + { + cell.SetValue(name, false, false); + } } } else @@ -105,7 +112,7 @@ private void RescanFieldNames() internal void AddFields(IEnumerable fieldNames) { - _fieldNames = new Dictionary(); + _fieldNames = CreateFieldNames(); Int32 cellPos = 0; foreach (var name in fieldNames) @@ -199,8 +206,8 @@ public String Name // Validation rules for table names var oldname = _name ?? string.Empty; - - if (!XLHelper.ValidateName("table", value, oldname, Worksheet.Tables.Select(t => t.Name), out String message)) + var tableNames = Worksheet.Tables.Select(t => t.Name); + if (!XLHelper.ValidateName("table", value, oldname, tableNames, out String message)) throw new ArgumentException(message, nameof(value)); _name = value; @@ -249,7 +256,7 @@ public IXLRangeRow HeadersRow() return HeadersRow(true); } - internal IXLRangeRow HeadersRow(Boolean scanForNewFieldsNames) + internal XLRangeRow HeadersRow(Boolean scanForNewFieldsNames) { if (!ShowHeaderRow) return null; @@ -293,7 +300,7 @@ public IXLTable Resize(IXLRangeAddress rangeAddress) public IXLTable Resize(string rangeAddress) { - return Resize(Worksheet.Range(RangeAddress)); + return Resize(Worksheet.Range(rangeAddress)); } public IXLTable Resize(IXLCell firstCell, IXLCell lastCell) @@ -341,7 +348,7 @@ public IXLTable Resize(IXLRange range) var co = 1; foreach (var c in firstRow.Cells()) { - if (String.IsNullOrWhiteSpace(((XLCell)c).InnerText)) + if (c.IsEmpty(XLCellsUsedOptions.Contents)) c.Value = GetUniqueName("Column", co, true); var header = c.GetString(); @@ -373,11 +380,11 @@ public IXLTable Resize(IXLRange range) { foreach (var f in this._fieldNames.Values) { - var c = this.TotalsRow().Cell(f.Index + 1); + var fieldColumn = f.Index + 1; + var c = this.TotalsRow().Cell(fieldColumn); if (!c.IsEmpty() && newHeaders.Contains(f.Name)) { f.TotalsRowLabel = c.GetFormattedString(); - c.DataType = XLDataType.Text; } } @@ -386,20 +393,16 @@ public IXLTable Resize(IXLRange range) foreach (var f in this._fieldNames.Values.Cast()) { f.UpdateTableFieldTotalsRowFormula(); - var c = this.TotalsRow().Cell(f.Index + 1); + var fieldColumn = f.Index + 1; + var c = this.TotalsRow().Cell(fieldColumn); if (!String.IsNullOrWhiteSpace(f.TotalsRowLabel)) { - c.DataType = XLDataType.Text; - //Remove previous row's label - var oldTotalsCell = this.Worksheet.Cell(oldTotalsRowNumber, f.Column.ColumnNumber()); - if (oldTotalsCell.Value.ToString() == f.TotalsRowLabel) - oldTotalsCell.Value = null; + var oldTotalsCell = Worksheet.Cell(oldTotalsRowNumber, f.Column.ColumnNumber()); + if (oldTotalsCell.Value.Equals(f.TotalsRowLabel)) + oldTotalsCell.Value = Blank.Value; } - if (f.TotalsRowFunction != XLTotalsRowFunction.None) - c.DataType = XLDataType.Number; - if (!string.IsNullOrEmpty(f.TotalsRowLabel)) c.SetValue(f.TotalsRowLabel); } @@ -535,8 +538,6 @@ private void InitializeValues(Boolean setAutofilter) if (setAutofilter) InitializeAutoFilter(); - AsRange().Row(1).DataType = XLDataType.Text; - if (RowCount() == 1) InsertRowsBelow(1); } @@ -553,13 +554,18 @@ internal void OnAddedToTables() foreach (IXLCell c in Row(1).Cells()) { // Be careful here. Fields names may actually be whitespace, but not empty - if (String.IsNullOrEmpty(((XLCell)c).InnerText)) - c.Value = GetUniqueName("Column", co, true); + if (c.IsEmpty(XLCellsUsedOptions.Contents)) + (c as XLCell).SetValue(GetUniqueName("Column", co, true), false, false); _uniqueNames.Add(c.GetString()); co++; } } + private static Dictionary CreateFieldNames() + { + return new Dictionary(StringComparer.CurrentCultureIgnoreCase); + } + private String GetUniqueName(String originalName, Int32 initialOffset, Boolean enforceOffset) { String name = String.Concat(originalName, enforceOffset ? initialOffset.ToInvariantString() : string.Empty); @@ -605,7 +611,7 @@ public Boolean ShowHeaderRow Int32 co = 1; foreach (IXLCell c in headersRow.Cells()) { - if (String.IsNullOrWhiteSpace(((XLCell)c).InnerText)) + if (String.IsNullOrWhiteSpace(c.GetString())) c.Value = GetUniqueName("Column", co, true); _uniqueNames.Add(c.GetString()); co++; @@ -663,9 +669,6 @@ public Boolean ShowHeaderRow // Invalidate fields' columns this.Fields.Cast().ForEach(f => f.Column = null); - - if (_showHeaderRow) - HeadersRow().DataType = XLDataType.Text; } } @@ -816,7 +819,7 @@ public DataTable AsNativeDataTable() foreach (var f in this.Fields) { - dr[f.Name] = row.Cell(f.Index + 1).Value; + dr[f.Name] = row.Cell(f.Index + 1).Value.ToObject(); } table.Rows.Add(dr); @@ -875,11 +878,11 @@ public IXLRange AppendData(IEnumerable data, Boolean propagateExtraColumns = fal public IXLRange AppendData(IEnumerable data, bool transpose, Boolean propagateExtraColumns = false) { - var castedData = data?.Cast(); - if (!(castedData?.Any() ?? false) || data is String) + var castedData = data?.Cast().ToArray() ?? Array.Empty(); + if (!castedData.Any() || data is String) return null; - var numberOfNewRows = castedData.Count(); + var numberOfNewRows = castedData.Length; var lastRowOfOldRange = this.DataRange.LastRow(); lastRowOfOldRange.InsertRowsBelow(numberOfNewRows); @@ -899,10 +902,11 @@ public IXLRange AppendData(DataTable dataTable, Boolean propagateExtraColumns = public IXLRange AppendData(IEnumerable data, Boolean propagateExtraColumns = false) { - if (!(data?.Any() ?? false) || data is String) + var materializedData = data?.ToArray() ?? Array.Empty(); + if (!materializedData.Any() || data is String) return null; - var numberOfNewRows = data.Count(); + var numberOfNewRows = materializedData.Length; if (numberOfNewRows == 0) return null; @@ -911,7 +915,7 @@ public IXLRange AppendData(IEnumerable data, Boolean propagateExtraColumns lastRowOfOldRange.InsertRowsBelow(numberOfNewRows); this.Fields.Cast().ForEach(f => f.Column = null); - var insertedRange = lastRowOfOldRange.RowBelow().FirstCell().InsertData(data); + var insertedRange = lastRowOfOldRange.RowBelow().FirstCell().InsertData(materializedData); PropagateExtraColumns(insertedRange.ColumnCount(), lastRowOfOldRange.RowNumber()); @@ -925,15 +929,15 @@ public IXLRange ReplaceData(IEnumerable data, Boolean propagateExtraColumns = fa public IXLRange ReplaceData(IEnumerable data, bool transpose, Boolean propagateExtraColumns = false) { - var castedData = data?.Cast(); - if (!(castedData?.Any() ?? false) || data is String) + var castedData = data?.Cast().ToArray() ?? Array.Empty(); + if (!castedData.Any() || data is String) throw new InvalidOperationException("Cannot replace table data with empty enumerable."); var firstDataRowNumber = this.DataRange.FirstRow().RowNumber(); var lastDataRowNumber = this.DataRange.LastRow().RowNumber(); // Resize table - var sizeDifference = castedData.Count() - this.DataRange.RowCount(); + var sizeDifference = castedData.Length - this.DataRange.RowCount(); if (sizeDifference > 0) this.DataRange.LastRow().InsertRowsBelow(sizeDifference); else if (sizeDifference < 0) @@ -968,14 +972,15 @@ public IXLRange ReplaceData(DataTable dataTable, Boolean propagateExtraColumns = public IXLRange ReplaceData(IEnumerable data, Boolean propagateExtraColumns = false) { - if (!(data?.Any() ?? false) || data is String) + var materializedData = data?.ToArray() ?? Array.Empty(); + if (!materializedData.Any() || data is String) throw new InvalidOperationException("Cannot replace table data with empty enumerable."); var firstDataRowNumber = this.DataRange.FirstRow().RowNumber(); var lastDataRowNumber = this.DataRange.LastRow().RowNumber(); // Resize table - var sizeDifference = data.Count() - this.DataRange.RowCount(); + var sizeDifference = materializedData.Length - DataRange.RowCount(); if (sizeDifference > 0) this.DataRange.LastRow().InsertRowsBelow(sizeDifference); else if (sizeDifference < 0) @@ -995,7 +1000,7 @@ public IXLRange ReplaceData(IEnumerable data, Boolean propagateExtraColumn // Invalidate table fields' columns this.Fields.Cast().ForEach(f => f.Column = null); - var replacedRange = this.DataRange.FirstCell().InsertData(data); + var replacedRange = this.DataRange.FirstCell().InsertData(materializedData); if (propagateExtraColumns) PropagateExtraColumns(replacedRange.ColumnCount(), lastDataRowNumber); @@ -1021,6 +1026,55 @@ private void PropagateExtraColumns(int numberOfNonExtraColumns, int previousLast } } + /// + /// Update headers fields and totals fields by data from the cells. Do not add a new fields or names. + /// + /// Area that contains cells with changed values that might affect header and totals fields. + internal void RefreshFieldsFromCells(XLSheetRange refreshArea) + { + var tableArea = Area; + if (ShowTotalsRow) + { + var totalsRow = tableArea.SliceFromBottom(1); + var intersection = totalsRow.Intersect(refreshArea); + if (intersection is not null) + { + var totalsRowNumber = totalsRow.BottomRow; + var valueSlice = Worksheet.Internals.CellsCollection.ValueSlice; + for (var column = intersection.Value.LeftColumn; column <= intersection.Value.RightColumn; ++column) + { + var fieldIndex = column - totalsRow.LeftColumn; + var field = Field(fieldIndex); + var value = valueSlice.GetCellValue(new XLSheetPoint(totalsRowNumber, column)); + + // Convert value to text, because Excel always converts values to text when replacing totals row. + field.TotalsRowLabel = value.ToString(CultureInfo.CurrentCulture); + } + } + } + + if (ShowHeaderRow) + { + var headersRow = Area.SliceFromTop(1); + var intersection = headersRow.Intersect(refreshArea); + if (intersection is not null) + { + var headersRowNumber = headersRow.TopRow; + var valueSlice = Worksheet.Internals.CellsCollection.ValueSlice; + for (var column = intersection.Value.LeftColumn; column <= intersection.Value.RightColumn; ++column) + { + var fieldIndex = column - headersRow.LeftColumn; + var field = Field(fieldIndex); + var value = valueSlice.GetCellValue(new XLSheetPoint(headersRowNumber, column)); + + // Convert to text, because headers row of a table can be only + // string in OOXML and Excel converts it to string as well. + field.Name = value.ToString(CultureInfo.CurrentCulture); + } + } + } + } + #endregion Append and replace data } } diff --git a/ClosedXML/Excel/Tables/XLTableField.cs b/ClosedXML/Excel/Tables/XLTableField.cs index 6d3f88ccc..55c671f49 100644 --- a/ClosedXML/Excel/Tables/XLTableField.cs +++ b/ClosedXML/Excel/Tables/XLTableField.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -9,10 +9,10 @@ namespace ClosedXML.Excel internal class XLTableField : IXLTableField { internal XLTotalsRowFunction totalsRowFunction; - internal String totalsRowLabel; + internal String? totalsRowLabel; private readonly XLTable table; - private IXLRangeColumn _column; + private IXLRangeColumn? _column; private Int32 index; private String name; @@ -44,16 +44,16 @@ public IXLCells DataCells { return Column.Cells(c => { - if (table.ShowHeaderRow && c == HeaderCell) + if (table.ShowHeaderRow && c.Equals(HeaderCell)) return false; - if (table.ShowTotalsRow && c == TotalsCell) + if (table.ShowTotalsRow && c.Equals(TotalsCell)) return false; return true; }); } } - public IXLCell HeaderCell + public IXLCell? HeaderCell { get { @@ -86,7 +86,7 @@ public String Name if (name == value) return; if (table.ShowHeaderRow) - (table.HeadersRow(false).Cell(Index + 1) as XLCell).SetValue(value, setTableHeader: false, checkMergedRanges: true); + ((XLCell)table.HeadersRow(false).Cell(Index + 1)).SetValue(value, setTableHeader: false, checkMergedRanges: true); table.RenameField(name, value); name = value; @@ -95,7 +95,7 @@ public String Name public IXLTable Table { get { return table; } } - public IXLCell TotalsCell + public IXLCell? TotalsCell { get { @@ -136,13 +136,13 @@ public XLTotalsRowFunction TotalsRowFunction } } - public String TotalsRowLabel + public String? TotalsRowLabel { get { return totalsRowLabel; } set { totalsRowFunction = XLTotalsRowFunction.None; - (table.TotalsRow().Cell(Index + 1) as XLCell).SetValue(value, setTableHeader: false, checkMergedRanges:true); + ((XLCell)table.TotalsRow().Cell(Index + 1)).SetValue(value, setTableHeader: false, checkMergedRanges: true); totalsRowLabel = value; } } @@ -173,7 +173,7 @@ public bool IsConsistentDataType() .Select(c => c.DataType); if (this.table.ShowTotalsRow) - dataTypes = dataTypes.Take(dataTypes.Count() - 1); + dataTypes = dataTypes.SkipLast(); var distinctDataTypes = dataTypes .GroupBy(dt => dt) @@ -190,7 +190,7 @@ public Boolean IsConsistentFormula() .Select(c => c.FormulaR1C1); if (this.table.ShowTotalsRow) - formulas = formulas.Take(formulas.Count() - 1); + formulas = formulas.SkipLast(); var distinctFormulas = formulas .GroupBy(f => f) @@ -208,7 +208,7 @@ public bool IsConsistentStyle() .Select(c => c.StyleValue); if (this.table.ShowTotalsRow) - styles = styles.Take(styles.Count() - 1); + styles = styles.SkipLast(); var distinctStyles = styles .Distinct(); @@ -250,7 +250,6 @@ internal void UpdateTableFieldTotalsRowFormula() var lastCell = table.LastRow().Cell(Index + 1); if (lastCell.DataType != XLDataType.Text) { - cell.DataType = lastCell.DataType; cell.Style.NumberFormat = lastCell.Style.NumberFormat; } } diff --git a/ClosedXML/Excel/Tables/XLTableRange.cs b/ClosedXML/Excel/Tables/XLTableRange.cs index 717f1491e..f2a97ab1e 100644 --- a/ClosedXML/Excel/Tables/XLTableRange.cs +++ b/ClosedXML/Excel/Tables/XLTableRange.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -8,6 +10,7 @@ internal class XLTableRange : XLRange, IXLTableRange { private readonly XLTable _table; private readonly XLRange _range; + public XLTableRange(XLRange range, XLTable table) : base(new XLRangeParameters(range.RangeAddress, range.Style)) { @@ -19,6 +22,7 @@ IXLTableRow IXLTableRange.FirstRow(Func predicate) { return FirstRow(predicate); } + public XLTableRow FirstRow(Func predicate = null) { if (predicate == null) @@ -39,20 +43,12 @@ IXLTableRow IXLTableRange.FirstRowUsed(Func predicate) { return FirstRowUsed(XLCellsUsedOptions.AllContents, predicate); } + public XLTableRow FirstRowUsed(Func predicate = null) { return FirstRowUsed(XLCellsUsedOptions.AllContents, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLTableRow IXLTableRange.FirstRowUsed(Boolean includeFormats, Func predicate) - { - return FirstRowUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, - predicate); - } - IXLTableRow IXLTableRange.FirstRowUsed(XLCellsUsedOptions options, Func predicate) { return FirstRowUsed(options, predicate); @@ -76,11 +72,11 @@ internal XLTableRow FirstRowUsed(XLCellsUsedOptions options, Func predicate) { return LastRow(predicate); } + public XLTableRow LastRow(Func predicate = null) { if (predicate == null) @@ -100,26 +96,17 @@ IXLTableRow IXLTableRange.LastRowUsed(Func predicate) { return LastRowUsed(XLCellsUsedOptions.AllContents, predicate); } + public XLTableRow LastRowUsed(Func predicate = null) { return LastRowUsed(XLCellsUsedOptions.AllContents, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLTableRow IXLTableRange.LastRowUsed(Boolean includeFormats, Func predicate) - { - return LastRowUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, - predicate); - } - IXLTableRow IXLTableRange.LastRowUsed(XLCellsUsedOptions options, Func predicate) { return LastRowUsed(options, predicate); } - internal XLTableRow LastRowUsed(XLCellsUsedOptions options, Func predicate = null) { if (predicate == null) @@ -142,6 +129,7 @@ IXLTableRow IXLTableRange.Row(int row) { return Row(row); } + public new XLTableRow Row(int row) { if (row <= 0 || row > XLHelper.MaxRowNumber + RangeAddress.FirstAddress.RowNumber - 1) @@ -205,15 +193,6 @@ public IXLTableRows Rows(Func predicate = null) return retVal; } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLTableRows RowsUsed(Boolean includeFormats, Func predicate = null) - { - return RowsUsed(includeFormats - ? XLCellsUsedOptions.AllContents - : XLCellsUsedOptions.All, - predicate); - } - IXLTableRows IXLTableRange.RowsUsed(XLCellsUsedOptions options, Func predicate) { return RowsUsed(options, predicate); @@ -238,24 +217,26 @@ IXLTableRows IXLTableRange.RowsUsed(Func predicate) { return RowsUsed(predicate); } + public IXLTableRows RowsUsed(Func predicate = null) { return RowsUsed(XLCellsUsedOptions.AllContents, predicate); } IXLTable IXLTableRange.Table { get { return _table; } } + public XLTable Table { get { return _table; } } public new IXLTableRows InsertRowsAbove(int numberOfRows) { - return XLHelper.InsertRowsWithoutEvents(base.InsertRowsAbove, this, numberOfRows, !Table.ShowTotalsRow ); + return XLHelper.InsertRowsWithoutEvents(base.InsertRowsAbove, this, numberOfRows, !Table.ShowTotalsRow); } + public new IXLTableRows InsertRowsBelow(int numberOfRows) { return XLHelper.InsertRowsWithoutEvents(base.InsertRowsBelow, this, numberOfRows, !Table.ShowTotalsRow); } - public new IXLRangeColumn Column(String column) { if (XLHelper.IsValidColumn(column)) diff --git a/ClosedXML/Excel/Tables/XLTableRow.cs b/ClosedXML/Excel/Tables/XLTableRow.cs index f863b677a..0f9882e0a 100644 --- a/ClosedXML/Excel/Tables/XLTableRow.cs +++ b/ClosedXML/Excel/Tables/XLTableRow.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel diff --git a/ClosedXML/Excel/Tables/XLTableRows.cs b/ClosedXML/Excel/Tables/XLTableRows.cs index b3cff490f..efda35d6a 100644 --- a/ClosedXML/Excel/Tables/XLTableRows.cs +++ b/ClosedXML/Excel/Tables/XLTableRows.cs @@ -9,30 +9,12 @@ internal class XLTableRows : XLStylizedBase, IXLTableRows, IXLStylized { private readonly List _ranges = new List(); - public XLTableRows(IXLStyle defaultStyle) : base((defaultStyle as XLStyle).Value) + public XLTableRows(IXLStyle defaultStyle) : base(((XLStyle)defaultStyle).Value) { } #region IXLStylized Members - public override IEnumerable Styles - { - get - { - yield return Style; - foreach (XLTableRow rng in _ranges) - { - yield return rng.Style; - foreach (XLCell r in rng.Worksheet.Internals.CellsCollection.GetCells( - rng.RangeAddress.FirstAddress.RowNumber, - rng.RangeAddress.FirstAddress.ColumnNumber, - rng.RangeAddress.LastAddress.RowNumber, - rng.RangeAddress.LastAddress.ColumnNumber)) - yield return r.Style; - } - } - } - protected override IEnumerable Children { get diff --git a/ClosedXML/Excel/Tables/XLTableTheme.cs b/ClosedXML/Excel/Tables/XLTableTheme.cs index ca70d172d..dbb4798e4 100644 --- a/ClosedXML/Excel/Tables/XLTableTheme.cs +++ b/ClosedXML/Excel/Tables/XLTableTheme.cs @@ -68,14 +68,14 @@ public sealed class XLTableTheme public static readonly XLTableTheme TableStyleDark2 = new XLTableTheme("TableStyleDark2"); public static readonly XLTableTheme TableStyleDark1 = new XLTableTheme("TableStyleDark1"); - public string Name { get; private set; } + public string Name { get; } public XLTableTheme(string name) { this.Name = name; } - private static IEnumerable allThemes; + private static IEnumerable? allThemes; public static IEnumerable GetAllThemes() { @@ -85,25 +85,17 @@ public static IEnumerable GetAllThemes() .ToArray())); } - public static XLTableTheme FromName(string name) + public static XLTableTheme? FromName(string name) { return GetAllThemes().FirstOrDefault(s => s.Name == name); } #region Overrides - public override bool Equals(object obj) + public override bool Equals(object? obj) { - if (obj == null) - { - return false; - } - XLTableTheme theme = obj as XLTableTheme; - if (theme == null) - { - return false; - } - return this.Name.Equals(theme.Name); + var theme = obj as XLTableTheme; + return theme is not null && Name.Equals(theme.Name); } public override int GetHashCode() @@ -118,4 +110,4 @@ public override string ToString() #endregion Overrides } -} \ No newline at end of file +} diff --git a/ClosedXML/Excel/Tables/XLTables.cs b/ClosedXML/Excel/Tables/XLTables.cs index 5d2fed91c..2558adb9b 100644 --- a/ClosedXML/Excel/Tables/XLTables.cs +++ b/ClosedXML/Excel/Tables/XLTables.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -6,24 +8,37 @@ namespace ClosedXML.Excel { using System.Collections; - internal class XLTables : IXLTables + internal class XLTables : IXLTables, IEnumerable { - private readonly Dictionary _tables; + private readonly Dictionary _tables; public XLTables() { - _tables = new Dictionary(StringComparer.OrdinalIgnoreCase); + _tables = new Dictionary(StringComparer.OrdinalIgnoreCase); Deleted = new HashSet(); } - internal ICollection Deleted { get; private set; } + internal ICollection Deleted { get; } #region IXLTables Members + bool IXLTables.TryGetTable(string tableName, out IXLTable table) + { + if (TryGetTable(tableName, out var foundTable)) + { + table = foundTable; + return true; + } + + table = default; + return false; + } + public void Add(IXLTable table) { - _tables.Add(table.Name, table); - (table as XLTable)?.OnAddedToTables(); + var xlTable = (XLTable)table; + _tables.Add(table.Name, xlTable); + xlTable.OnAddedToTables(); } public IXLTables Clear(XLClearOptions clearOptions = XLClearOptions.All) @@ -37,15 +52,16 @@ public Boolean Contains(String name) return _tables.ContainsKey(name); } - public IEnumerator GetEnumerator() + public Dictionary.ValueCollection.Enumerator GetEnumerator() { return _tables.Values.GetEnumerator(); } - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public void Remove(Int32 index) { @@ -54,14 +70,14 @@ public void Remove(Int32 index) public void Remove(String name) { - if (!_tables.TryGetValue(name, out IXLTable table)) + if (!_tables.TryGetValue(name, out var table)) throw new ArgumentOutOfRangeException(nameof(name), $"Unable to delete table because the table name {name} could not be found."); _tables.Remove(name); - var relId = (table as XLTable)?.RelId; + var relId = table.RelId; - if (relId != null) + if (relId is not null) Deleted.Add(relId); } @@ -72,13 +88,13 @@ public IXLTable Table(Int32 index) public IXLTable Table(String name) { - if (TryGetTable(name, out IXLTable table)) + if (TryGetTable(name, out XLTable table)) return table; throw new ArgumentOutOfRangeException(nameof(name), $"Table {name} was not found."); } - public bool TryGetTable(string tableName, out IXLTable table) + internal bool TryGetTable(string tableName, out XLTable table) { return _tables.TryGetValue(tableName, out table); } diff --git a/ClosedXML/Excel/XLCellValue.cs b/ClosedXML/Excel/XLCellValue.cs new file mode 100644 index 000000000..530ad844d --- /dev/null +++ b/ClosedXML/Excel/XLCellValue.cs @@ -0,0 +1,634 @@ +#nullable disable + +using System; +using System.Diagnostics; +using System.Globalization; +using ClosedXML.Excel.CalcEngine; +using ClosedXML.Extensions; + +namespace ClosedXML.Excel +{ + /// + /// A value of a single cell. It contains a value a specific . + /// Structure provides following group of methods: + /// + /// Is* properties to check type (, ...) + /// Get* methods that return the stored value or throw for incorrect type. + /// Explicit operators to convert XLCellValue to a concrete type. It is an equivalent of Get* methods. + /// TryConvert methods to try to get value of a specific type, even if the value is of a different type. + /// + /// + [DebuggerDisplay("{Type} {_text != null ? (object)_text : (object)_value}")] + public readonly struct XLCellValue : IEquatable, IEquatable, IEquatable, IEquatable, IEquatable, IEquatable, IEquatable, IEquatable, IEquatable + { + private readonly double _value; + private readonly string _text; + + private XLCellValue(Blank _) : this() + { + Type = XLDataType.Blank; + } + + private XLCellValue(bool logical) : this() + { + Type = XLDataType.Boolean; + _value = logical ? 1d : 0d; + } + + private XLCellValue(double number) : this() + { + if (Double.IsNaN(number) || Double.IsInfinity(number)) + throw new ArgumentException("Value can't be NaN or infinity.", nameof(number)); + + Type = XLDataType.Number; + _value = number; + } + + private XLCellValue(string text) : this() + { + if (text is null) + throw new ArgumentNullException(nameof(text)); + + if (text.Length > 32767) + throw new ArgumentOutOfRangeException(nameof(text), "Cells can hold a maximum of 32,767 characters."); + + Type = XLDataType.Text; + _text = text; + } + + private XLCellValue(XLError error) : this() + { + if (error < XLError.NullValue || error > XLError.NoValueAvailable) + throw new ArgumentOutOfRangeException(nameof(error)); + + Type = XLDataType.Error; + _value = (double)error; + } + + private XLCellValue(DateTime dateTime) : this() + { + Type = XLDataType.DateTime; + _value = dateTime.ToSerialDateTime(); + } + + private XLCellValue(TimeSpan timeSpan) : this() + { + Type = XLDataType.TimeSpan; + _value = timeSpan.ToSerialDateTime(); + } + + private XLCellValue(XLDataType type, double value) : this() + { + if (Double.IsNaN(value) || Double.IsInfinity(value)) + throw new ArgumentException("Value can't be NaN or infinity.", nameof(value)); + Type = type; + _value = value; + } + + /// + /// Type of the value. + /// + public XLDataType Type { get; } + + /// + /// Is the type of value Blank? + /// + public bool IsBlank => Type == XLDataType.Blank; + + /// + /// Is the type of value ? + /// + public bool IsBoolean => Type == XLDataType.Boolean; + + /// + /// Is the type of value ? + /// + public bool IsNumber => Type == XLDataType.Number; + + /// + /// Is the type of value ? + /// + public bool IsText => Type == XLDataType.Text; + + /// + /// Is the type of value ? + /// + public bool IsError => Type == XLDataType.Error; + + /// + /// Is the type of value ? + /// + public bool IsDateTime => Type == XLDataType.DateTime; + + /// + /// Is the type of value ? + /// + public bool IsTimeSpan => Type == XLDataType.TimeSpan; + + /// + /// Is the value or or . + /// + public bool IsUnifiedNumber => IsNumber || IsDateTime || IsTimeSpan; + + public static implicit operator XLCellValue(Blank blank) => new(blank); + public static implicit operator XLCellValue(bool logical) => new(logical); + public static implicit operator XLCellValue(string text) => text is not null ? new(text) : new(Blank.Value); + public static implicit operator XLCellValue(XLError error) => new(error); + public static implicit operator XLCellValue(DateTime dateTime) => new(dateTime); + public static implicit operator XLCellValue(TimeSpan timeSpan) => new(timeSpan); + + public static implicit operator XLCellValue(sbyte number) => new(number); + public static implicit operator XLCellValue(byte number) => new(number); + public static implicit operator XLCellValue(short number) => new(number); + public static implicit operator XLCellValue(ushort number) => new(number); + public static implicit operator XLCellValue(int number) => new(number); + public static implicit operator XLCellValue(uint number) => new(number); + public static implicit operator XLCellValue(long number) => new(number); + public static implicit operator XLCellValue(ulong number) => new(number); + public static implicit operator XLCellValue(float number) => new(number); + public static implicit operator XLCellValue(double number) => new(number); + public static implicit operator XLCellValue(decimal number) => new(decimal.ToDouble(number)); + + public static implicit operator XLCellValue(sbyte? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(byte? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(short? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(ushort? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(int? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(uint? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(long? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(ulong? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(float? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(double? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(decimal? numberOrBlank) => numberOrBlank.HasValue ? numberOrBlank.Value : Blank.Value; + + public static implicit operator XLCellValue(DateTime? dateTimeOrBlank) => dateTimeOrBlank.HasValue ? dateTimeOrBlank.Value : Blank.Value; + public static implicit operator XLCellValue(TimeSpan? timeSpanOrBlank) => timeSpanOrBlank.HasValue ? timeSpanOrBlank.Value : Blank.Value; + + /// + /// Creates an from an . If the type of the object has an implicit conversion operator then it is used. + /// Otherwise, the method is used to convert the provided object to a string. + /// + /// The following types and their nullable counterparts are supported without requiring to be converted to a string: + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// The object to convert. + /// An object that supplies culture-specific formatting information. + /// An representation of the object. + public static XLCellValue FromObject(object obj, IFormatProvider provider = null) + { + return obj switch + { + null => Blank.Value, + Blank blank => blank, + bool logical => logical, + string text => text, + XLError error => error, + DateTime dateTime => dateTime, + TimeSpan timeSpan => timeSpan, + sbyte number => number, + byte number => number, + short number => number, + ushort number => number, + int number => number, + uint number => number, + long number => number, + ulong number => number, + float number => number, + double number => number, + decimal number => number, + _ => Convert.ToString(obj, provider) + }; + } + + /// + /// A function used during data insertion to convert an object to XLCellValue. + /// + internal static XLCellValue FromInsertedObject(object value) + { + XLCellValue convertedValue = value switch + { + null => Blank.Value, + Blank blankValue => blankValue, + Boolean logical => logical, + SByte number => number, + Byte number => number, + Int16 number => number, + UInt16 number => number, + Int32 number => number, + UInt32 number => number, + Int64 number => number, + UInt64 number => number, + Single number => number, + Double number => number, + Decimal number => number, + String text => text, + XLError error => error, + DateTime date => date, + DateTimeOffset dateOfs => dateOfs.DateTime, + TimeSpan timeSpan => timeSpan, + _ => value.ToString() // Other things, like chars ect are just turned to string + }; + return convertedValue; + } + + /// + /// Try to convert a string into a string value, doing your best. If no other type can be + /// extracted, consider it a text. + /// + /// Text to parse into a value. + /// Culture used to parse numbers. + /// Parsed value. + internal static XLCellValue FromText(string text, CultureInfo culture) + { + // AutoFilter custom filter operand can be stored as `1 1/2` and Excel correctly + // interprets it as a `1.5`. Same for 2015-01-01, therefore use `TextToNumber` that + // should deal with any weird formats. + if (text is null) + return Blank.Value; + if (text == String.Empty) + return Blank.Value; + if (StringComparer.OrdinalIgnoreCase.Equals("TRUE", text)) + return true; + if (StringComparer.OrdinalIgnoreCase.Equals("FALSE", text)) + return false; + if (ScalarValue.TextToNumber(text, culture).TryPickT0(out var number, out _)) + return number; + if (XLErrorParser.TryParseError(text, out var error)) + return error; + + return text; + } + + /// + public static explicit operator Blank(XLCellValue value) => value.GetBlank(); + + /// + public static explicit operator Boolean(XLCellValue value) => value.GetBoolean(); + + /// + public static explicit operator Double(XLCellValue value) => value.GetNumber(); + + /// + public static explicit operator String(XLCellValue value) => value.GetText(); + + /// + public static explicit operator XLError(XLCellValue value) => value.GetError(); + + /// + public static implicit operator DateTime(XLCellValue value) => value.GetDateTime(); + + /// + public static implicit operator TimeSpan(XLCellValue value) => value.GetTimeSpan(); + + /// + /// If the value is of type , + /// return , otherwise throw . + /// + public Blank GetBlank() => IsBlank ? Blank.Value : throw new InvalidCastException(); + + /// + /// If the value is of type , + /// return logical, otherwise throw . + /// + public Boolean GetBoolean() => IsBoolean ? _value != 0d : throw new InvalidCastException(); + + /// + /// If the value is of type , + /// return number, otherwise throw . + /// + public Double GetNumber() => IsNumber ? _value : throw new InvalidCastException(); + + /// + /// If the value is of type , + /// return text, otherwise throw . + /// + public String GetText() => IsText ? _text : throw new InvalidCastException(); + + /// + /// If the value is of type , + /// return error, otherwise throw . + /// + public XLError GetError() => IsError ? (XLError)_value : throw new InvalidCastException(); + + /// + /// If the value is of type , + /// return date time, otherwise throw . + /// + public DateTime GetDateTime() => IsDateTime ? _value.ToSerialDateTime() : throw new InvalidCastException(); + + /// + /// If the value is of type , + /// return time span, otherwise throw . + /// + public TimeSpan GetTimeSpan() => IsTimeSpan ? _value.ToSerialTimeSpan() : throw new InvalidCastException(); + + internal static XLCellValue FromSerialDateTime(double serialDateTime) => new(XLDataType.DateTime, serialDateTime); + + internal static XLCellValue FromSerialTimeSpan(double serialTimeSpan) => new(XLDataType.TimeSpan, serialTimeSpan); + + /// + /// Get a number, either directly from type number or from serialized time span + /// or serialized date time. + /// + /// If type is not or + /// or . + public double GetUnifiedNumber() + { + if (IsUnifiedNumber) + return _value; + + throw new InvalidCastException("Value is not a number."); + } + + internal Object ToObject() + { + return Type switch + { + XLDataType.Blank => null, + XLDataType.Boolean => GetBoolean(), + XLDataType.Number => GetNumber(), + XLDataType.Text => GetText(), + XLDataType.Error => GetError(), + XLDataType.DateTime => GetDateTime(), + XLDataType.TimeSpan => GetTimeSpan(), + _ => throw new InvalidCastException() + }; + } + + /// + /// Return text representation of a value in current culture. + /// + public override string ToString() => ToString(CultureInfo.CurrentCulture); + + /// + /// Return text representation of a value in the specified culture. + /// + public string ToString(CultureInfo culture) => + Type switch + { + XLDataType.Blank => string.Empty, + XLDataType.Boolean => GetBoolean() ? "TRUE" : "FALSE", + XLDataType.Number => _value.ToString(culture), + XLDataType.Text => _text, + XLDataType.Error => GetError().ToDisplayString(), + XLDataType.DateTime => GetDateTime().ToString(culture), + XLDataType.TimeSpan => GetTimeSpan().ToExcelString(culture), + _ => throw new InvalidOperationException() + }; + + public bool Equals(XLCellValue other) + { + return Type == other.Type && _value.Equals(other._value) && _text == other._text; + } + + public bool Equals(Blank other) + { + return IsBlank; + } + + public bool Equals(bool other) + { + return IsBoolean && GetBoolean() == other; + } + + public bool Equals(double other) + { + return IsNumber && _value.Equals(other); + } + + /// + /// Is the cell value text and is equal to the ? + /// Text comparison is case sensitive. + /// + public bool Equals(string other) + { + return IsText && _text == other; + } + + public bool Equals(XLError other) + { + return IsError && GetError() == other; + } + + public bool Equals(DateTime other) + { + return IsDateTime && GetDateTime() == other; + } + + public bool Equals(TimeSpan other) + { + return IsTimeSpan && GetTimeSpan() == other; + } + + public bool Equals(int other) + { + return Equals((double)other); + } + + public override bool Equals(object obj) + { + return obj is XLCellValue other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = _value.GetHashCode(); + hashCode = (hashCode * 397) ^ (_text != null ? _text.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (int)Type; + return hashCode; + } + } + + /// + /// Get a value, if it is a . + /// + /// True if value was retrieved, false otherwise. + public bool TryGetText(out string value) + { + if (IsText) + { + value = _text; + return true; + } + + value = default; + return false; + } + + /// + /// Try to convert the value to a and return it. + /// Method succeeds, when value is + /// + /// Type . + /// Type and the text is empty. + /// + /// + public bool TryConvert(out Blank value) + { + var isBlankLike = IsBlank || (IsText && GetText().Length == 0); + if (isBlankLike) + { + value = Blank.Value; + return true; + } + + value = default; + return false; + } + + /// + /// Try to convert the value to a and return it. + /// Method succeeds, when value is + /// + /// Type . + /// Type , then the value of 0 means false and any other value is true. + /// Type and the value is TRUE or FALSE (case insensitive). Note that calc engine + /// generally doesn't coerce text to logical (e.g. arithmetic operations), though it happens in most function arguments (e.g. + /// IF or AND). + /// + /// + public bool TryConvert(out Boolean value) + { + switch (Type) + { + case XLDataType.Boolean: + value = GetBoolean(); + return true; + case XLDataType.Number: + value = GetNumber() != 0; + return true; + case XLDataType.Text when String.Equals(GetText(), "TRUE", StringComparison.OrdinalIgnoreCase): + value = true; + return true; + case XLDataType.Text when String.Equals(GetText(), "FALSE", StringComparison.OrdinalIgnoreCase): + value = false; + return true; + } + + value = default; + return false; + } + + /// + /// Try to convert the value to a and return it. + /// + /// Double value - return the value. + /// Boolean value - return the 0 for TRUE and 1 for FALSE. + /// Text value - use the VALUE semantic to convert a text to a number. + /// DateTime value - return the serial date time number. + /// TimeSpan value - return the serial time span value. + /// + /// + /// Note that the coercion is current culture specific (e.g. decimal separators can differ). + /// The converted value. Result is never infinity or NaN. + /// The culture used to convert the value for texts. + public bool TryConvert(out Double value, CultureInfo culture) + { + switch (Type) + { + case XLDataType.Number: + case XLDataType.DateTime: + case XLDataType.TimeSpan: + value = GetUnifiedNumber(); + return true; + case XLDataType.Boolean: + value = GetBoolean() ? 1 : 0; + return true; + case XLDataType.Text: + { + var coercionResult = ScalarValue.TextToNumber(GetText(), culture); + if (coercionResult.TryPickT0(out var number, out _)) + { + value = number; + return true; + } + + break; + } + } + + value = default; + return false; + } + + /// + /// Try to convert the value to a and return it. + /// Method succeeds, when value is + /// + /// Type . + /// Type and when the number is interpreted is a serial date time, it falls within the DateTime range. + /// Type and when the number is interpreted is a serial date time, it falls within the DateTime range. + /// + /// + public bool TryConvert(out DateTime value) + { + if (IsUnifiedNumber) + { + var number = GetUnifiedNumber(); + if (number.IsValidOADateNumber()) + { + value = number.ToSerialDateTime(); + return true; + } + } + + value = default; + return false; + } + + /// + /// Try to convert the value to a and return it. + /// Method succeeds, when value is + /// + /// Type . + /// Type , the number is interpreted is a time span date time. + /// Type and it can be parsed as a time span (accepts even hours over 24 hours). + /// + /// + /// The converted value. + /// The culture used to get time and decimal separators. + public bool TryConvert(out TimeSpan value, CultureInfo culture) + { + if (IsTimeSpan) + { + value = GetTimeSpan(); + return true; + } + + if (IsNumber) + { + value = GetUnifiedNumber().ToSerialTimeSpan(); + return true; + } + + if (IsText && TimeSpanParser.TryParseTime(GetText(), culture, out var ts)) + { + value = ts; + return true; + } + + value = default; + return false; + } + } +} diff --git a/ClosedXML/Excel/XLCellsUsedOptions.cs b/ClosedXML/Excel/XLCellsUsedOptions.cs index 076a35f99..a17b2fcbb 100644 --- a/ClosedXML/Excel/XLCellsUsedOptions.cs +++ b/ClosedXML/Excel/XLCellsUsedOptions.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/XLClearOptions.cs b/ClosedXML/Excel/XLClearOptions.cs index c6cbfd2df..aded18fc8 100644 --- a/ClosedXML/Excel/XLClearOptions.cs +++ b/ClosedXML/Excel/XLClearOptions.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; namespace ClosedXML.Excel { @@ -6,7 +8,6 @@ namespace ClosedXML.Excel public enum XLClearOptions { Contents = 1 << 0, - DataType = 1 << 1, NormalFormats = 1 << 2, ConditionalFormats = 1 << 3, Comments = 1 << 4, @@ -15,8 +16,8 @@ public enum XLClearOptions Sparklines = 1 << 7, AllFormats = NormalFormats | ConditionalFormats, - AllContents = Contents | DataType | Comments, - All = Contents | DataType | NormalFormats | ConditionalFormats | Comments | DataValidation | MergedRanges | Sparklines + AllContents = Contents | Comments, + All = Contents | NormalFormats | ConditionalFormats | Comments | DataValidation | MergedRanges | Sparklines } internal static class XLClearOptionsExtensions diff --git a/ClosedXML/Excel/XLConstants.cs b/ClosedXML/Excel/XLConstants.cs index 6a7cf31a9..cb02ef5c9 100644 --- a/ClosedXML/Excel/XLConstants.cs +++ b/ClosedXML/Excel/XLConstants.cs @@ -1,4 +1,7 @@ // Keep this file CodeMaid organised and cleaned +using System; +using System.Collections.Generic; + namespace ClosedXML.Excel { //Use the class to store magic strings or variables. @@ -8,6 +11,8 @@ public static class XLConstants internal const int MaxFunctionArguments = 255; // To keep allocation sane + internal const double ColumnWidthOffset = 0.710625; + #region Pivot Table constants public static class PivotTable @@ -26,5 +31,201 @@ internal static class Comment internal const string AlternateShapeTypeId = "_xssf_cell_comment"; internal const string ShapeTypeId = "_x0000_t202"; } + + /// + /// Functions that are marked with a prefix _xlfn in formulas, but not GUI. Officially, + /// they are called future functions. + /// + /// + /// Up to date for MS-XLSX 26.1 from 2024-12-11. + /// + internal static readonly Lazy> FutureFunctions = new(() => new[] + { + "ACOT", + "ACOTH", + "AGGREGATE", + "ARABIC", + "BASE", + "BETA.DIST", + "BETA.INV", + "BINOM.DIST", + "BINOM.DIST.RANGE", + "BINOM.INV", + "BITAND", + "BITLSHIFT", + "BITOR", + "BITRSHIFT", + "BITXOR", + "BYCOL", + "BYROW", + "CEILING.MATH", + "CEILING.PRECISE", + "CHISQ.DIST", + "CHISQ.DIST.RT", + "CHISQ.INV", + "CHISQ.INV.RT", + "CHISQ.TEST", + "CHOOSECOLS", + "CHOOSEROWS", + "COMBINA", + "CONCAT", + "CONFIDENCE.NORM", + "CONFIDENCE.T", + "COT", + "COTH", + "COVARIANCE.P", + "COVARIANCE.S", + "CSC", + "CSCH", + "DAYS", + "DECIMAL", + "DROP", + "ERF.PRECISE", + "ERFC.PRECISE", + "EXPAND", + "EXPON.DIST", + "F.DIST", + "F.DIST.RT", + "F.INV", + "F.INV.RT", + "F.TEST", + "FIELDVALUE", + "FILTERXML", + "FLOOR.MATH", + "FLOOR.PRECISE", + "FORECAST.ETS", + "FORECAST.ETS.CONFINT", + "FORECAST.ETS.SEASONALITY", + "FORECAST.ETS.STAT", + "FORECAST.LINEAR", + "FORMULATEXT", + "GAMMA", + "GAMMA.DIST", + "GAMMA.INV", + "GAMMALN.PRECISE", + "GAUSS", + "HSTACK", + "HYPGEOM.DIST", + "IFNA", + "IFS", + "IMCOSH", + "IMCOT", + "IMCSC", + "IMCSCH", + "IMSEC", + "IMSECH", + "IMSINH", + "IMTAN", + "ISFORMULA", + "ISOMITTED", + "ISOWEEKNUM", + "LAMBDA", + "LET", + "LOGNORM.DIST", + "LOGNORM.INV", + "MAKEARRAY", + "MAP", + "MAXIFS", + "MINIFS", + "MODE.MULT", + "MODE.SNGL", + "MUNIT", + "NEGBINOM.DIST", + "NORM.DIST", + "NORM.INV", + "NORM.S.DIST", + "NORM.S.INV", + "NUMBERVALUE", + "PDURATION", + "PERCENTILE.EXC", + "PERCENTILE.INC", + "PERCENTRANK.EXC", + "PERCENTRANK.INC", + "PERMUTATIONA", + "PHI", + "POISSON.DIST", + "PQSOURCE", + "PYTHON_STR", + "PYTHON_TYPE", + "PYTHON_TYPENAME", + "QUARTILE.EXC", + "QUARTILE.INC", + "QUERYSTRING", + "RANDARRAY", + "RANK.AVG", + "RANK.EQ", + "REDUCE", + "RRI", + "SCAN", + "SEC", + "SECH", + "SEQUENCE", + "SHEET", + "SHEETS", + "SKEW.P", + "SORTBY", + "STDEV.P", + "STDEV.S", + "SWITCH", + "T.DIST", + "T.DIST.2T", + "T.DIST.RT", + "T.INV", + "T.INV.2T", + "T.TEST", + "TAKE", + "TEXTAFTER", + "TEXTBEFORE", + "TEXTJOIN", + "TEXTSPLIT", + "TOCOL", + "TOROW", + "UNICHAR", + "UNICODE", + "UNIQUE", + "VAR.P", + "VAR.S", + "VSTACK", + "WEBSERVICE", + "WEIBULL.DIST", + "WRAPCOLS", + "WRAPROWS", + "XLOOKUP", + "XOR", + "Z.TEST", + }); + + /// + /// Functions that are marked with a prefix _xlfn._xlws in formulas, but not GUI. They + /// are part of the future functions. Unlike other future functions, they can only be used in + /// a worksheet, but not a macro sheet. In the grammar, they are marked as + /// worksheet-only-function-list. + /// + /// + /// Up to date for MS-XLSX 26.1 from 2024-12-11. + /// + internal static readonly Lazy> WorksheetOnlyFunctions = new(() => new[] + { + "FILTER", + "PY", + "SORT", + }); + + /// + /// Key: GUI name of future function, Value: prefixed name of future function. This doesn't + /// include all future functions, only ones that need a hidden prefix (e.g. ECMA.CEILING + /// is a future function without a prefix). + /// + internal static readonly Lazy> FutureFunctionMap = new(() => + { + var functionsMap = new Dictionary(XLHelper.FunctionComparer); + foreach (var futureFunction in XLConstants.FutureFunctions.Value) + functionsMap.Add(futureFunction, "_xlfn." + futureFunction); + + foreach (var futureFunction in XLConstants.WorksheetOnlyFunctions.Value) + functionsMap.Add(futureFunction, "_xlfn._xlws." + futureFunction); + + return functionsMap; + }); } } diff --git a/ClosedXML/Excel/XLFileSharing.cs b/ClosedXML/Excel/XLFileSharing.cs index 844977067..2a8be80af 100644 --- a/ClosedXML/Excel/XLFileSharing.cs +++ b/ClosedXML/Excel/XLFileSharing.cs @@ -1,10 +1,10 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned namespace ClosedXML.Excel { internal class XLFileSharing : IXLFileSharing { public bool ReadOnlyRecommended { get; set; } - public string UserName { get; set; } + public string? UserName { get; set; } } } diff --git a/ClosedXML/Excel/XLOutline.cs b/ClosedXML/Excel/XLOutline.cs index 1e296b7d1..03a8c71d7 100644 --- a/ClosedXML/Excel/XLOutline.cs +++ b/ClosedXML/Excel/XLOutline.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace ClosedXML.Excel { diff --git a/ClosedXML/Excel/XLSheetView.cs b/ClosedXML/Excel/XLSheetView.cs index 6c6c08c38..1043576ef 100644 --- a/ClosedXML/Excel/XLSheetView.cs +++ b/ClosedXML/Excel/XLSheetView.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; diff --git a/ClosedXML/Excel/XLTheme.cs b/ClosedXML/Excel/XLTheme.cs index c8677a20f..e4b2b8066 100644 --- a/ClosedXML/Excel/XLTheme.cs +++ b/ClosedXML/Excel/XLTheme.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +#nullable disable namespace ClosedXML.Excel { @@ -45,7 +44,7 @@ public XLColor ResolveThemeColor(XLThemeColor themeColor) case XLThemeColor.Accent4: return Accent4; - + case XLThemeColor.Accent5: return Accent5; diff --git a/ClosedXML/Excel/XLWorkbook.cs b/ClosedXML/Excel/XLWorkbook.cs index c58eaef1b..fc4970a2d 100644 --- a/ClosedXML/Excel/XLWorkbook.cs +++ b/ClosedXML/Excel/XLWorkbook.cs @@ -1,18 +1,20 @@ +#nullable disable + using ClosedXML.Excel.CalcEngine; using ClosedXML.Graphics; using DocumentFormat.OpenXml; using System; using System.Collections.Generic; using System.Data; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using ClosedXML.Excel.Formatting; using static ClosedXML.Excel.XLProtectionAlgorithm; namespace ClosedXML.Excel { - public enum XLEventTracking { Enabled, Disabled } - public enum XLCalculateMode { Auto, @@ -119,28 +121,17 @@ public static XLWorkbook OpenFromTemplate(String path) internal readonly List UnsupportedSheets = new List(); - public XLEventTracking EventTracking { get; set; } - - /// - /// Counter increasing at workbook data change. Serves to determine if the cell formula - /// has to be recalculated. - /// - internal long RecalculationCounter { get; private set; } - internal IXLGraphicEngine GraphicEngine { get; } internal double DpiX { get; } internal double DpiY { get; } - /// - /// Notify that workbook data has been changed which means that cached formula values - /// need to be re-evaluated. - /// - internal void InvalidateFormulas() - { - RecalculationCounter++; - } + internal XLPivotCaches PivotCachesInternal { get; } + + internal SharedStringTable SharedStringTable { get; } = new(); + + internal XLWorkbookStyles Styles { get; set; } = new(); #region Nested Type : XLLoadSource @@ -163,16 +154,26 @@ public IXLWorksheets Worksheets get { return WorksheetsInternal; } } + internal XLDefinedNames DefinedNamesInternal { get; } + + [Obsolete($"Use {nameof(DefinedNames)} instead.")] + public IXLDefinedNames NamedRanges => DefinedNamesInternal; + /// /// Gets an object to manipulate this workbook's named ranges. /// - public IXLNamedRanges NamedRanges { get; private set; } + public IXLDefinedNames DefinedNames => DefinedNamesInternal; /// /// Gets an object to manipulate this workbook's theme. /// public IXLTheme Theme { get; private set; } + /// + /// All pivot caches in the workbook, whether they have a pivot table or not. + /// + public IXLPivotCaches PivotCaches => PivotCachesInternal; + /// /// Gets or sets the default style for the workbook. /// All new worksheets will use this style. @@ -295,54 +296,86 @@ private void InitializeTheme() }; } - public IXLNamedRange NamedRange(String rangeName) +#nullable enable + [Obsolete($"Use {nameof(DefinedName)} instead.")] + public IXLDefinedName? NamedRange(String name) => DefinedName(name); + + /// + public IXLDefinedName? DefinedName(String name) { - if (rangeName.Contains("!")) + if (name.Contains("!")) { - var split = rangeName.Split('!'); + var split = name.Split('!'); var first = split[0]; var wsName = first.StartsWith("'") ? first.Substring(1, first.Length - 2) : first; - var name = split[1]; - if (TryGetWorksheet(wsName, out IXLWorksheet ws)) + var sheetlessName = split[1]; + if (TryGetWorksheet(wsName, out XLWorksheet ws)) { - var range = ws.NamedRange(name); - return range ?? NamedRange(name); + if (ws.DefinedNames.TryGetScopedValue(sheetlessName, out var sheetDefinedName)) + return sheetDefinedName; } - return null; + + name = sheetlessName; } - return NamedRanges.NamedRange(rangeName); + + return DefinedNamesInternal.TryGetScopedValue(name, out var definedName) ? definedName : null; } +#nullable disable public Boolean TryGetWorksheet(String name, out IXLWorksheet worksheet) { - return Worksheets.TryGetWorksheet(name, out worksheet); + if (TryGetWorksheet(name, out XLWorksheet foundSheet)) + { + worksheet = foundSheet; + return true; + } + + worksheet = default; + return false; + } + + internal Boolean TryGetWorksheet(String name, [NotNullWhen(true)] out XLWorksheet worksheet) + { + return WorksheetsInternal.TryGetWorksheet(name, out worksheet); } public IXLRange RangeFromFullAddress(String rangeAddress, out IXLWorksheet ws) { - ws = null; - if (!rangeAddress.Contains('!')) return null; + if (!rangeAddress.Contains('!')) + { + ws = null; + return null; + } var split = rangeAddress.Split('!'); var wsName = split[0].UnescapeSheetName(); - if (TryGetWorksheet(wsName, out ws)) + if (TryGetWorksheet(wsName, out XLWorksheet sheet)) { - return ws.Range(split[1]); + ws = sheet; + return sheet.Range(split[1]); } + + ws = null; return null; } public IXLCell CellFromFullAddress(String cellAddress, out IXLWorksheet ws) { - ws = null; - if (!cellAddress.Contains('!')) return null; + if (!cellAddress.Contains('!')) + { + ws = null; + return null; + } var split = cellAddress.Split('!'); var wsName = split[0].UnescapeSheetName(); - if (TryGetWorksheet(wsName, out ws)) + if (TryGetWorksheet(wsName, out XLWorksheet sheet)) { - return ws.Cell(split[1]); + ws = sheet; + return sheet.Cell(split[1]); } + + ws = null; return null; } @@ -572,16 +605,51 @@ internal static void CopyStream(Stream input, Stream output) public IXLTable Table(string tableName, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) { - var table = this.Worksheets - .SelectMany(ws => ws.Tables) - .FirstOrDefault(t => t.Name.Equals(tableName, comparisonType)); - - if (table == null) + if (!TryGetTable(tableName, out var table, comparisonType)) throw new ArgumentOutOfRangeException($"Table {tableName} was not found."); return table; } + /// + /// Try to find a table with in a workbook. + /// + internal bool TryGetTable(string tableName, out XLTable table, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + table = WorksheetsInternal + .SelectMany(ws => ws.Tables) + .FirstOrDefault(t => t.Name.Equals(tableName, comparisonType)); + + return table is not null; + } + + /// + /// Try to find a table that covers same area as the in a workbook. + /// + internal bool TryGetTable(XLBookArea area, out XLTable foundTable) + { + foreach (var sheet in WorksheetsInternal) + { + if (XLHelper.SheetComparer.Equals(sheet.Name, area.Name)) + { + foreach (var table in sheet.Tables) + { + if (table.Area != area.Area) + continue; + + foundTable = table; + return true; + } + + // No other sheet has correct name. + break; + } + } + + foundTable = null; + return false; + } + public IXLWorksheet Worksheet(String name) { return WorksheetsInternal.Worksheet(name); @@ -639,7 +707,6 @@ public IXLColumns FindColumns(Func predicate) /// The search text. /// The compare options. /// if set to true search formulae instead of cell values. - /// public IEnumerable Search(String searchText, CompareOptions compareOptions = CompareOptions.Ordinal, Boolean searchFormulae = false) { foreach (var ws in WorksheetsInternal) @@ -664,32 +731,22 @@ public IEnumerable Search(String searchText, CompareOptions compareOpti /// Creates a new Excel workbook. /// public XLWorkbook() - : this(XLEventTracking.Enabled) + : this(new LoadOptions()) { } internal XLWorkbook(String file, Boolean asTemplate) - : this(XLEventTracking.Enabled) + : this(new LoadOptions()) { LoadSheetsFromTemplate(file); } - public XLWorkbook(XLEventTracking eventTracking) - : this(new LoadOptions { EventTracking = eventTracking }) - { - } - /// /// Opens an existing workbook from a file. /// /// The file to open. public XLWorkbook(String file) - : this(file, XLEventTracking.Enabled) - { - } - - public XLWorkbook(String file, XLEventTracking eventTracking) - : this(file, new LoadOptions { EventTracking = eventTracking }) + : this(file, new LoadOptions()) { } @@ -710,12 +767,7 @@ public XLWorkbook(String file, LoadOptions loadOptions) /// /// The stream to open. public XLWorkbook(Stream stream) - : this(stream, XLEventTracking.Enabled) - { - } - - public XLWorkbook(Stream stream, XLEventTracking eventTracking) - : this(stream, new LoadOptions { EventTracking = eventTracking }) + : this(stream, new LoadOptions()) { } @@ -732,7 +784,9 @@ public XLWorkbook(Stream stream, LoadOptions loadOptions) public XLWorkbook(LoadOptions loadOptions) { - EventTracking = loadOptions.EventTracking; + if (loadOptions is null) + throw new ArgumentNullException(nameof(loadOptions)); + DpiX = loadOptions.Dpi.X; DpiY = loadOptions.Dpi.Y; GraphicEngine = loadOptions.GraphicEngine ?? LoadOptions.DefaultGraphicEngine ?? DefaultGraphicEngine.Instance.Value; @@ -757,7 +811,8 @@ public XLWorkbook(LoadOptions loadOptions) ShowZeros = DefaultShowZeros; RightToLeft = DefaultRightToLeft; WorksheetsInternal = new XLWorksheets(this); - NamedRanges = new XLNamedRanges(this); + DefinedNamesInternal = new XLDefinedNames(this); + PivotCachesInternal = new XLPivotCaches(this); CustomProperties = new XLCustomProperties(this); ShapeIdManager = new XLIdManager(); Author = Environment.UserName; @@ -778,7 +833,7 @@ internal sealed class UnsupportedSheet public IXLCell Cell(String namedCell) { - var namedRange = NamedRange(namedCell); + var namedRange = DefinedName(namedCell); if (namedRange != null) { return namedRange.Ranges?.FirstOrDefault()?.FirstCell(); @@ -794,7 +849,7 @@ public IXLCells Cells(String namedCells) public IXLRange Range(String range) { - var namedRange = NamedRange(range); + var namedRange = DefinedName(range); if (namedRange != null) return namedRange.Ranges.FirstOrDefault(); else @@ -860,11 +915,6 @@ public IXLWorksheet AddWorksheet(String sheetName, Int32 position) return Worksheets.Add(sheetName, position); } - public IXLWorksheet AddWorksheet(DataTable dataTable) - { - return Worksheets.Add(dataTable); - } - public void AddWorksheet(DataSet dataSet) { Worksheets.Add(dataSet); @@ -875,11 +925,21 @@ public void AddWorksheet(IXLWorksheet worksheet) worksheet.CopyTo(this, worksheet.Name); } + public IXLWorksheet AddWorksheet(DataTable dataTable) + { + return Worksheets.Add(dataTable); + } + public IXLWorksheet AddWorksheet(DataTable dataTable, String sheetName) { return Worksheets.Add(dataTable, sheetName); } + public IXLWorksheet AddWorksheet(DataTable dataTable, String sheetName, String tableName) + { + return Worksheets.Add(dataTable, sheetName, tableName); + } + private XLCalcEngine _calcEngine; internal XLCalcEngine CalcEngine @@ -887,9 +947,9 @@ internal XLCalcEngine CalcEngine get { return _calcEngine ??= new XLCalcEngine(CultureInfo.CurrentCulture); } } - public Object Evaluate(String expression) + public XLCellValue Evaluate(String expression) { - return CalcEngine.Evaluate(expression, this); + return CalcEngine.EvaluateFormula(expression, this).ToCellValue(); } /// @@ -897,8 +957,10 @@ public Object Evaluate(String expression) /// public void RecalculateAllFormulas() { - InvalidateFormulas(); - Worksheets.ForEach(sheet => sheet.RecalculateAllFormulas()); + foreach (var sheet in WorksheetsInternal) + sheet.Internals.CellsCollection.FormulaSlice.MarkDirty(XLSheetRange.Full); + + CalcEngine.Recalculate(this, null); } private static XLCalcEngine _calcEngineExpr; @@ -912,9 +974,17 @@ private static XLCalcEngine CalcEngineExpr /// /// Evaluate a formula and return a value. Formulas with references don't work and culture used for conversion is invariant. /// - public static Object EvaluateExpr(String expression) + public static XLCellValue EvaluateExpr(String expression) + { + return CalcEngineExpr.EvaluateFormula(expression).ToCellValue(); + } + + /// + /// Evaluate a formula and return a value. Use current culture. + /// + internal static XLCellValue EvaluateExprCurrent(String expression) { - return CalcEngineExpr.Evaluate(expression); + return new XLCalcEngine(CultureInfo.CurrentCulture).EvaluateFormula(expression).ToCellValue(); } public String Author { get; set; } @@ -971,22 +1041,6 @@ internal XLWorkbookProtection Protection } } - [Obsolete("Use Protect(String password, Algorithm algorithm, TElement allowedElements)")] - public IXLWorkbookProtection Protect(Boolean lockStructure, Boolean lockWindows, String password) - { - var allowedElements = XLWorkbookProtectionElements.Everything; - - var protection = Protection.Protect(password, DefaultProtectionAlgorithm, allowedElements); - - if (lockStructure) - protection.DisallowElement(XLWorkbookProtectionElements.Structure); - - if (lockWindows) - protection.DisallowElement(XLWorkbookProtectionElements.Windows); - - return protection; - } - public IXLWorkbookProtection Protect(Algorithm algorithm = DefaultProtectionAlgorithm) { return Protection.Protect(algorithm); @@ -998,18 +1052,6 @@ public IXLWorkbookProtection Protect(XLWorkbookProtectionElements allowedElement public IXLWorkbookProtection Protect(Algorithm algorithm, XLWorkbookProtectionElements allowedElements) => Protection.Protect(algorithm, allowedElements); - [Obsolete("Use Protect(String password, Algorithm algorithm, TElement allowedElements)")] - public IXLWorkbookProtection Protect(Boolean lockStructure) - { - return Protect(lockStructure, lockWindows: false, password: null); - } - - [Obsolete("Use Protect(String password, Algorithm algorithm, TElement allowedElements)")] - public IXLWorkbookProtection Protect(Boolean lockStructure, Boolean lockWindows) - { - return Protect(lockStructure, lockWindows, null); - } - public IXLWorkbookProtection Protect(String password, Algorithm algorithm = DefaultProtectionAlgorithm) { @@ -1060,6 +1102,22 @@ IXLElementProtection IXLProtectable.Unprotect(String password) return Unprotect(password); } + /// + /// Notify various component of a workbook that sheet has been added. + /// + internal void NotifyWorksheetAdded(XLWorksheet newSheet) + { + _calcEngine.OnAddedSheet(newSheet); + } + + /// + /// Notify various component of a workbook that sheet is about to be removed. + /// + internal void NotifyWorksheetDeleting(XLWorksheet sheet) + { + _calcEngine.OnDeletingSheet(sheet); + } + public override string ToString() { switch (_loadSource) @@ -1077,21 +1135,5 @@ public override string ToString() throw new NotImplementedException(); } } - - public void SuspendEvents() - { - foreach (var ws in WorksheetsInternal) - { - ws.SuspendEvents(); - } - } - - public void ResumeEvents() - { - foreach (var ws in WorksheetsInternal) - { - ws.ResumeEvents(); - } - } } } diff --git a/ClosedXML/Excel/XLWorkbookProperties.cs b/ClosedXML/Excel/XLWorkbookProperties.cs index e9d2bcb32..a33e22044 100644 --- a/ClosedXML/Excel/XLWorkbookProperties.cs +++ b/ClosedXML/Excel/XLWorkbookProperties.cs @@ -4,22 +4,17 @@ namespace ClosedXML.Excel { public class XLWorkbookProperties { - public XLWorkbookProperties() - { - Company = null; - Manager = null; - } - public String Author { get; set; } - public String Title { get; set; } - public String Subject { get; set; } - public String Category { get; set; } - public String Keywords { get; set; } - public String Comments { get; set; } - public String Status { get; set; } + public String? Author { get; set; } + public String? Title { get; set; } + public String? Subject { get; set; } + public String? Category { get; set; } + public String? Keywords { get; set; } + public String? Comments { get; set; } + public String? Status { get; set; } public DateTime Created { get; set; } public DateTime Modified { get; set; } - public String LastModifiedBy { get; set; } - public String Company { get; set; } - public String Manager { get; set; } + public String? LastModifiedBy { get; set; } + public String? Company { get; set; } + public String? Manager { get; set; } } } diff --git a/ClosedXML/Excel/XLWorkbook_ImageHandling.cs b/ClosedXML/Excel/XLWorkbook_ImageHandling.cs index 8ea8d2916..bcf72b91b 100644 --- a/ClosedXML/Excel/XLWorkbook_ImageHandling.cs +++ b/ClosedXML/Excel/XLWorkbook_ImageHandling.cs @@ -1,3 +1,5 @@ +#nullable disable + using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Drawing.Spreadsheet; using DocumentFormat.OpenXml.Packaging; @@ -10,35 +12,16 @@ namespace ClosedXML.Excel { public partial class XLWorkbook { - public static OpenXmlElement GetAnchorFromImageId(WorksheetPart worksheetPart, string relId) + internal static OpenXmlElement GetAnchorFromImageId(DrawingsPart drawingsPart, string relId) { - var drawingsPart = worksheetPart.DrawingsPart; var matchingAnchor = drawingsPart.WorksheetDrawing .Where(wsdr => wsdr.Descendants() .Any(x => x?.Blip?.Embed?.Value.Equals(relId) ?? false) ); - - if (!matchingAnchor.Any()) - return null; - else - return matchingAnchor.First(); - } - - public static OpenXmlElement GetAnchorFromImageIndex(WorksheetPart worksheetPart, Int32 index) - { - var drawingsPart = worksheetPart.DrawingsPart; - var matchingAnchor = drawingsPart.WorksheetDrawing - .Where(wsdr => wsdr.Descendants() - .Any(x => x.Id.Value.Equals(Convert.ToUInt32(index + 1))) - ); - - if (!matchingAnchor.Any()) - return null; - else - return matchingAnchor.First(); + return matchingAnchor.FirstOrDefault(); } - public static NonVisualDrawingProperties GetPropertiesFromAnchor(OpenXmlElement anchor) + internal static NonVisualDrawingProperties GetPropertiesFromAnchor(OpenXmlElement anchor) { if (!IsAllowedAnchor(anchor)) return null; @@ -54,7 +37,7 @@ public static NonVisualDrawingProperties GetPropertiesFromAnchor(OpenXmlElement .FirstOrDefault(); } - public static String GetImageRelIdFromAnchor(OpenXmlElement anchor) + internal static String GetImageRelIdFromAnchor(OpenXmlElement anchor) { if (!IsAllowedAnchor(anchor)) return null; diff --git a/ClosedXML/Excel/XLWorkbook_Load.cs b/ClosedXML/Excel/XLWorkbook_Load.cs index 1d70b5d99..57b42a46b 100644 --- a/ClosedXML/Excel/XLWorkbook_Load.cs +++ b/ClosedXML/Excel/XLWorkbook_Load.cs @@ -1,4 +1,5 @@ -using ClosedXML.Extensions; +#nullable disable + using ClosedXML.Utils; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; @@ -11,22 +12,21 @@ using System.Text; using System.Text.RegularExpressions; using System.Xml.Linq; +using ClosedXML.Excel.IO; using Ap = DocumentFormat.OpenXml.ExtendedProperties; using Op = DocumentFormat.OpenXml.CustomProperties; -using X14 = DocumentFormat.OpenXml.Office2010.Excel; using Xdr = DocumentFormat.OpenXml.Drawing.Spreadsheet; namespace ClosedXML.Excel { using Ap; + using ClosedXML.IO; using Drawings; using Op; using System.Drawing; public partial class XLWorkbook { - private readonly Dictionary _colorList = new Dictionary(); - private void Load(String file) { LoadSheets(file); @@ -62,14 +62,18 @@ private void LoadSheetsFromTemplate(String fileName) private void ResetAllRelIds() { - foreach (var ws in Worksheets.Cast()) + foreach (var pc in PivotCachesInternal) + pc.WorkbookCacheRelId = null; + + var sheetId = 1u; + foreach (var ws in WorksheetsInternal) { - ws.SheetId = 0; + // Ensure unique sheetId for each sheet. + ws.SheetId = sheetId++; ws.RelId = null; foreach (var pt in ws.PivotTables.Cast()) { - pt.WorkbookCacheRelId = null; pt.CacheDefinitionRelId = null; pt.RelId = null; } @@ -84,16 +88,20 @@ private void ResetAllRelIds() private void LoadSpreadsheetDocument(SpreadsheetDocument dSpreadsheet) { + var context = new LoadContext(); ShapeIdManager = new XLIdManager(); SetProperties(dSpreadsheet); SharedStringItem[] sharedStrings = null; - if (dSpreadsheet.WorkbookPart.GetPartsOfType().Any()) + var workbookPart = dSpreadsheet.WorkbookPart; + if (workbookPart.GetPartsOfType().Any()) { - var shareStringPart = dSpreadsheet.WorkbookPart.GetPartsOfType().First(); + var shareStringPart = workbookPart.GetPartsOfType().First(); sharedStrings = shareStringPart.SharedStringTable.Elements().ToArray(); } + LoadWorkbookTheme(workbookPart?.ThemePart, this); + if (dSpreadsheet.CustomFilePropertiesPart != null) { foreach (var m in dSpreadsheet.CustomFilePropertiesPart.Properties.Elements()) @@ -118,20 +126,20 @@ private void LoadSpreadsheetDocument(SpreadsheetDocument dSpreadsheet) } } - var wbProps = dSpreadsheet.WorkbookPart.Workbook.WorkbookProperties; + var wbProps = workbookPart.Workbook.WorkbookProperties; if (wbProps != null) Use1904DateSystem = OpenXmlHelper.GetBooleanValueAsBool(wbProps.Date1904, false); - var wbFilesharing = dSpreadsheet.WorkbookPart.Workbook.FileSharing; + var wbFilesharing = workbookPart.Workbook.FileSharing; if (wbFilesharing != null) { FileSharing.ReadOnlyRecommended = OpenXmlHelper.GetBooleanValueAsBool(wbFilesharing.ReadOnlyRecommended, false); FileSharing.UserName = wbFilesharing.UserName?.Value; } - LoadWorkbookProtection(dSpreadsheet.WorkbookPart.Workbook.WorkbookProtection, this); + LoadWorkbookProtection(workbookPart.Workbook.WorkbookProtection, this); - var calculationProperties = dSpreadsheet.WorkbookPart.Workbook.CalculationProperties; + var calculationProperties = workbookPart.Workbook.CalculationProperties; if (calculationProperties != null) { var calculateMode = calculationProperties.CalculationMode; @@ -169,17 +177,19 @@ private void LoadSpreadsheetDocument(SpreadsheetDocument dSpreadsheet) Properties.Manager = efp.Properties.GetFirstChild().Text; } - Stylesheet s = null; - if (dSpreadsheet.WorkbookPart.WorkbookStylesPart != null && - dSpreadsheet.WorkbookPart.WorkbookStylesPart.Stylesheet != null) + var stylesPart = workbookPart.WorkbookStylesPart; + if (stylesPart is not null) { - s = dSpreadsheet.WorkbookPart.WorkbookStylesPart.Stylesheet; + using var xmlReader = CreateTreeReader(stylesPart); + var stylesReader = new StylesReader(xmlReader, Styles); + stylesReader.Load(); } - NumberingFormats numberingFormats = s == null ? null : s.NumberingFormats; - Fills fills = s == null ? null : s.Fills; - Borders borders = s == null ? null : s.Borders; - Fonts fonts = s == null ? null : s.Fonts; + Stylesheet s = stylesPart?.Stylesheet; + NumberingFormats numberingFormats = s?.NumberingFormats; + context.LoadNumberFormats(numberingFormats); + Fills fills = s?.Fills; + Borders borders = s?.Borders; Int32 dfCount = 0; Dictionary differentialFormats; if (s != null && s.DifferentialFormats != null) @@ -187,118 +197,87 @@ private void LoadSpreadsheetDocument(SpreadsheetDocument dSpreadsheet) else differentialFormats = new Dictionary(); - var sheets = dSpreadsheet.WorkbookPart.Workbook.Sheets; + // If the loaded workbook has a changed "Normal" style, it might affect the default width of a column. + var normalStyle = s?.CellStyles?.Elements().FirstOrDefault(x => x.BuiltinId is not null && x.BuiltinId.Value == 0); + if (normalStyle != null) + { + var normalStyleKey = ((XLStyle)Style).Key; + LoadStyle(ref normalStyleKey, (Int32)normalStyle.FormatId.Value, s, fills, borders, numberingFormats, Styles); + Style = new XLStyle(null, normalStyleKey); + ColumnWidth = XLHelper.CalculateColumnWidth(8, Style.Font, this); + } + + // We loop through the sheets in 2 passes: first just to add the sheets and second to add all the data for the sheets. + // We do this mainly because it skips a very costly calculation invalidation step, but it also make things more consistent, + // e.g. when reading calculations that reference other sheets, we know that those sheets always already exist. + // That consistency point isn't required yet but could be taken advantage of in the future. + var sheets = workbookPart.Workbook.Sheets; Int32 position = 0; foreach (var dSheet in sheets.OfType()) { position++; - var sharedFormulasR1C1 = new Dictionary(); + var sheetName = dSheet.Name; + var sheetId = dSheet.SheetId.Value; - var worksheetPart = dSpreadsheet.WorkbookPart.GetPartById(dSheet.Id) as WorksheetPart; + if (string.IsNullOrEmpty(dSheet.Id)) + { + // Some non-Excel producers create sheets with empty relId. + var emptySheet = WorksheetsInternal.Add(sheetName, position, sheetId); + if (dSheet.State != null) + emptySheet.Visibility = dSheet.State.Value.ToClosedXml(); + + continue; + } + // Although relationship to worksheet is most common, there can be other types + // than worksheet, e.g. chartSheet. Since we can't load them, add them to list + // of unsupported sheets and copy them when saving. See Codeplex #6932. + var worksheetPart = workbookPart.GetPartById(dSheet.Id) as WorksheetPart; if (worksheetPart == null) { - UnsupportedSheets.Add(new UnsupportedSheet { SheetId = dSheet.SheetId.Value, Position = position }); + UnsupportedSheets.Add(new UnsupportedSheet { SheetId = sheetId, Position = position }); continue; } - var sheetName = dSheet.Name; - - var ws = (XLWorksheet)WorksheetsInternal.Add(sheetName, position); + var ws = WorksheetsInternal.Add(sheetName, position, sheetId); ws.RelId = dSheet.Id; - ws.SheetId = (Int32)dSheet.SheetId.Value; if (dSheet.State != null) ws.Visibility = dSheet.State.Value.ToClosedXml(); + } - ApplyStyle(ws, 0, s, fills, borders, fonts, numberingFormats); - - var styleList = new Dictionary();// {{0, ws.Style}}; - PageSetupProperties pageSetupProperties = null; - - lastRow = 0; + position = 0; + foreach (var dSheet in sheets.OfType()) + { + position++; + var sheetName = dSheet.Name; + var sheetId = dSheet.SheetId.Value; - using (var reader = OpenXmlReader.Create(worksheetPart)) + if (string.IsNullOrEmpty(dSheet.Id)) { - Type[] ignoredElements = new Type[] - { - typeof(CustomSheetViews) // Custom sheet views contain its own auto filter data, and more, which should be ignored for now - }; - - while (reader.Read()) - { - while (ignoredElements.Contains(reader.ElementType)) - reader.ReadNextSibling(); - - if (reader.ElementType == typeof(SheetFormatProperties)) - { - var sheetFormatProperties = (SheetFormatProperties)reader.LoadCurrentElement(); - if (sheetFormatProperties != null) - { - if (sheetFormatProperties.DefaultRowHeight != null) - ws.RowHeight = sheetFormatProperties.DefaultRowHeight; + // Some non-Excel producers create sheets with empty relId. + continue; + } - ws.RowHeightChanged = (sheetFormatProperties.CustomHeight != null && - sheetFormatProperties.CustomHeight.Value); + // Although relationship to worksheet is most common, there can be other types + // than worksheet, e.g. chartSheet. Since we can't load them, add them to list + // of unsupported sheets and copy them when saving. See Codeplex #6932. + var worksheetPart = workbookPart.GetPartById(dSheet.Id) as WorksheetPart; + if (worksheetPart == null) + { + continue; + } - if (sheetFormatProperties.DefaultColumnWidth != null) - { - ws.ColumnWidth = sheetFormatProperties.DefaultColumnWidth; - } - } - } - else if (reader.ElementType == typeof(SheetViews)) - LoadSheetViews((SheetViews)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(MergeCells)) - { - var mergedCells = (MergeCells)reader.LoadCurrentElement(); - if (mergedCells != null) - { - foreach (MergeCell mergeCell in mergedCells.Elements()) - ws.Range(mergeCell.Reference).Merge(false); - } - } - else if (reader.ElementType == typeof(Columns)) - LoadColumns(s, numberingFormats, fills, borders, fonts, ws, - (Columns)reader.LoadCurrentElement()); - else if (reader.ElementType == typeof(Row)) - { - LoadRows(s, numberingFormats, fills, borders, fonts, ws, sharedStrings, sharedFormulasR1C1, - styleList, (Row)reader.LoadCurrentElement()); - } - else if (reader.ElementType == typeof(AutoFilter)) - LoadAutoFilter((AutoFilter)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(SheetProtection)) - LoadSheetProtection((SheetProtection)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(DataValidations)) - LoadDataValidations((DataValidations)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(ConditionalFormatting)) - LoadConditionalFormatting((ConditionalFormatting)reader.LoadCurrentElement(), ws, differentialFormats); - else if (reader.ElementType == typeof(Hyperlinks)) - LoadHyperlinks((Hyperlinks)reader.LoadCurrentElement(), worksheetPart, ws); - else if (reader.ElementType == typeof(PrintOptions)) - LoadPrintOptions((PrintOptions)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(PageMargins)) - LoadPageMargins((PageMargins)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(PageSetup)) - LoadPageSetup((PageSetup)reader.LoadCurrentElement(), ws, pageSetupProperties); - else if (reader.ElementType == typeof(HeaderFooter)) - LoadHeaderFooter((HeaderFooter)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(SheetProperties)) - LoadSheetProperties((SheetProperties)reader.LoadCurrentElement(), ws, out pageSetupProperties); - else if (reader.ElementType == typeof(RowBreaks)) - LoadRowBreaks((RowBreaks)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(ColumnBreaks)) - LoadColumnBreaks((ColumnBreaks)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(WorksheetExtensionList)) - LoadExtensions((WorksheetExtensionList)reader.LoadCurrentElement(), ws); - else if (reader.ElementType == typeof(LegacyDrawing)) - ws.LegacyDrawingId = (reader.LoadCurrentElement() as LegacyDrawing).Id.Value; - } - reader.Close(); + if (!WorksheetsInternal.TryGetWorksheet(sheetName, out var ws)) + { + // This shouldn't be possible, as all worksheets should have already been added in the loop before this loop + continue; } - (ws.ConditionalFormats as XLConditionalFormats).ReorderAccordingToOriginalPriority(); + var worksheetPartReader = new WorksheetPartReader(); + worksheetPartReader.LoadWorksheet(ws, s, fills, borders, numberingFormats, worksheetPart, sharedStrings, differentialFormats, context); + + ws.ConditionalFormats.ReorderAccordingToOriginalPriority(); #region LoadTables @@ -354,7 +333,7 @@ private void LoadSpreadsheetDocument(SpreadsheetDocument dSpreadsheet) if (dTable.AutoFilter != null) { xlTable.ShowAutoFilter = true; - LoadAutoFilterColumns(dTable.AutoFilter, xlTable.AutoFilter); + AutoFilterReader.LoadAutoFilterColumns(dTable.AutoFilter, xlTable.AutoFilter); } else xlTable.ShowAutoFilter = false; @@ -426,7 +405,7 @@ private void LoadSpreadsheetDocument(SpreadsheetDocument dSpreadsheet) var runProperties = run.RunProperties; String text = run.Text.InnerText.FixNewLines(); var rt = xlComment.AddText(text); - LoadFont(runProperties, rt); + OpenXmlHelper.LoadFont(runProperties, rt); } if (shape != null) @@ -453,12 +432,12 @@ private void LoadSpreadsheetDocument(SpreadsheetDocument dSpreadsheet) #endregion LoadComments } - var workbook = dSpreadsheet.WorkbookPart.Workbook; + var workbook = workbookPart.Workbook; var bookViews = workbook.BookViews; if (bookViews != null && bookViews.FirstOrDefault() is WorkbookView workbookView) { - if (workbookView.ActiveTab == null) + if (workbookView.ActiveTab == null || !workbookView.ActiveTab.HasValue) { Worksheets.First().SetTabActive().Unhide(); } @@ -476,632 +455,39 @@ private void LoadSpreadsheetDocument(SpreadsheetDocument dSpreadsheet) } LoadDefinedNames(workbook); - #region Pivot tables - - // Delay loading of pivot tables until all sheets have been loaded - foreach (var dSheet in sheets.OfType()) + // Read cache definition before table definition + foreach (var pivotTableCacheDefinitionPart in workbookPart.GetPartsOfType()) { - var worksheetPart = dSpreadsheet.WorkbookPart.GetPartById(dSheet.Id) as WorksheetPart; - - if (worksheetPart != null) + var pivotCache = PivotTableCacheDefinitionPartReader.Load(workbookPart, pivotTableCacheDefinitionPart, this); + if (pivotTableCacheDefinitionPart.PivotTableCacheRecordsPart is { } recordsPart) { - var ws = (XLWorksheet)WorksheetsInternal.Worksheet(dSheet.Name); - - foreach (var pivotTablePart in worksheetPart.PivotTableParts) - { - var pivotTableCacheDefinitionPart = pivotTablePart.PivotTableCacheDefinitionPart; - var pivotTableDefinition = pivotTablePart.PivotTableDefinition; - - var target = ws.FirstCell(); - if (pivotTableDefinition?.Location?.Reference?.HasValue ?? false) - { - ws.Range(pivotTableDefinition.Location.Reference.Value).Clear(XLClearOptions.All); - target = ws.Range(pivotTableDefinition.Location.Reference.Value).FirstCell(); - } - - IXLRange source = null; - XLPivotTableSourceType sourceType = XLPivotTableSourceType.Range; - if (pivotTableCacheDefinitionPart?.PivotCacheDefinition?.CacheSource?.WorksheetSource != null) - { - // TODO: Implement other sources besides worksheetSource - // But for now assume names and references point directly to a range - var wss = pivotTableCacheDefinitionPart.PivotCacheDefinition.CacheSource.WorksheetSource; - - if (!String.IsNullOrEmpty(wss.Id)) - { - var externalRelationship = pivotTableCacheDefinitionPart.ExternalRelationships.FirstOrDefault(er => er.Id.Equals(wss.Id)); - if (externalRelationship?.IsExternal ?? false) - { - // We don't support external sources - continue; - } - } - - if (wss.Name != null) - { - var table = ws - .Workbook - .Worksheets - .SelectMany(ws1 => ws1.Tables) - .FirstOrDefault(t => t.Name.Equals(wss.Name.Value)); - - if (table != null) - { - sourceType = XLPivotTableSourceType.Table; - source = table; - } - else - { - sourceType = XLPivotTableSourceType.Range; - source = this.Range(wss.Name.Value); - } - } - else - { - sourceType = XLPivotTableSourceType.Range; - - IXLWorksheet sourceSheet; - if (wss.Sheet == null) - sourceSheet = ws; - else if (WorksheetsInternal.TryGetWorksheet(wss.Sheet.Value, out sourceSheet)) - source = this.Range(sourceSheet.Range(wss.Reference.Value).RangeAddress.ToStringRelative(includeSheet: true)); - } - - if (source == null) - continue; - } - - if (target != null && source != null) - { - XLPivotTable pt; - switch (sourceType) - { - case XLPivotTableSourceType.Range: - pt = ws.PivotTables.Add(pivotTableDefinition.Name, target, source) as XLPivotTable; - break; - - case XLPivotTableSourceType.Table: - pt = ws.PivotTables.Add(pivotTableDefinition.Name, target, source as XLTable) as XLPivotTable; - break; - - default: - throw new NotSupportedException($"Pivot table source type {sourceType} is not supported."); - } - - if (!String.IsNullOrWhiteSpace(StringValue.ToString(pivotTableDefinition?.ColumnHeaderCaption ?? String.Empty))) - pt.SetColumnHeaderCaption(StringValue.ToString(pivotTableDefinition.ColumnHeaderCaption)); - - if (!String.IsNullOrWhiteSpace(StringValue.ToString(pivotTableDefinition?.RowHeaderCaption ?? String.Empty))) - pt.SetRowHeaderCaption(StringValue.ToString(pivotTableDefinition.RowHeaderCaption)); - - pt.RelId = worksheetPart.GetIdOfPart(pivotTablePart); - pt.CacheDefinitionRelId = pivotTablePart.GetIdOfPart(pivotTableCacheDefinitionPart); - pt.WorkbookCacheRelId = dSpreadsheet.WorkbookPart.GetIdOfPart(pivotTableCacheDefinitionPart); - - if (pivotTableDefinition.MergeItem != null) pt.MergeAndCenterWithLabels = pivotTableDefinition.MergeItem.Value; - if (pivotTableDefinition.Indent != null) pt.RowLabelIndent = (int)pivotTableDefinition.Indent.Value; - if (pivotTableDefinition.PageOverThenDown != null) pt.FilterAreaOrder = pivotTableDefinition.PageOverThenDown.Value ? XLFilterAreaOrder.OverThenDown : XLFilterAreaOrder.DownThenOver; - if (pivotTableDefinition.PageWrap != null) pt.FilterFieldsPageWrap = (int)pivotTableDefinition.PageWrap.Value; - if (pivotTableDefinition.UseAutoFormatting != null) pt.AutofitColumns = pivotTableDefinition.UseAutoFormatting.Value; - if (pivotTableDefinition.PreserveFormatting != null) pt.PreserveCellFormatting = pivotTableDefinition.PreserveFormatting.Value; - if (pivotTableDefinition.RowGrandTotals != null) pt.ShowGrandTotalsRows = pivotTableDefinition.RowGrandTotals.Value; - if (pivotTableDefinition.ColumnGrandTotals != null) pt.ShowGrandTotalsColumns = pivotTableDefinition.ColumnGrandTotals.Value; - if (pivotTableDefinition.SubtotalHiddenItems != null) pt.FilteredItemsInSubtotals = pivotTableDefinition.SubtotalHiddenItems.Value; - if (pivotTableDefinition.MultipleFieldFilters != null) pt.AllowMultipleFilters = pivotTableDefinition.MultipleFieldFilters.Value; - if (pivotTableDefinition.CustomListSort != null) pt.UseCustomListsForSorting = pivotTableDefinition.CustomListSort.Value; - if (pivotTableDefinition.ShowDrill != null) pt.ShowExpandCollapseButtons = pivotTableDefinition.ShowDrill.Value; - if (pivotTableDefinition.ShowDataTips != null) pt.ShowContextualTooltips = pivotTableDefinition.ShowDataTips.Value; - if (pivotTableDefinition.ShowMemberPropertyTips != null) pt.ShowPropertiesInTooltips = pivotTableDefinition.ShowMemberPropertyTips.Value; - if (pivotTableDefinition.ShowHeaders != null) pt.DisplayCaptionsAndDropdowns = pivotTableDefinition.ShowHeaders.Value; - if (pivotTableDefinition.GridDropZones != null) pt.ClassicPivotTableLayout = pivotTableDefinition.GridDropZones.Value; - if (pivotTableDefinition.ShowEmptyRow != null) pt.ShowEmptyItemsOnRows = pivotTableDefinition.ShowEmptyRow.Value; - if (pivotTableDefinition.ShowEmptyColumn != null) pt.ShowEmptyItemsOnColumns = pivotTableDefinition.ShowEmptyColumn.Value; - if (pivotTableDefinition.ShowItems != null) pt.DisplayItemLabels = pivotTableDefinition.ShowItems.Value; - if (pivotTableDefinition.FieldListSortAscending != null) pt.SortFieldsAtoZ = pivotTableDefinition.FieldListSortAscending.Value; - if (pivotTableDefinition.PrintDrill != null) pt.PrintExpandCollapsedButtons = pivotTableDefinition.PrintDrill.Value; - if (pivotTableDefinition.ItemPrintTitles != null) pt.RepeatRowLabels = pivotTableDefinition.ItemPrintTitles.Value; - if (pivotTableDefinition.FieldPrintTitles != null) pt.PrintTitles = pivotTableDefinition.FieldPrintTitles.Value; - if (pivotTableDefinition.EnableDrill != null) pt.EnableShowDetails = pivotTableDefinition.EnableDrill.Value; - if (pivotTableCacheDefinitionPart.PivotCacheDefinition.SaveData != null) pt.SaveSourceData = pivotTableCacheDefinitionPart.PivotCacheDefinition.SaveData.Value; - - if (pivotTableCacheDefinitionPart.PivotCacheDefinition.MissingItemsLimit != null) - { - if (pivotTableCacheDefinitionPart.PivotCacheDefinition.MissingItemsLimit == 0U) - pt.ItemsToRetainPerField = XLItemsToRetain.None; - else if (pivotTableCacheDefinitionPart.PivotCacheDefinition.MissingItemsLimit == XLHelper.MaxRowNumber) - pt.ItemsToRetainPerField = XLItemsToRetain.Max; - } - - if (pivotTableDefinition.ShowMissing != null && pivotTableDefinition.MissingCaption != null) - pt.EmptyCellReplacement = pivotTableDefinition.MissingCaption.Value; - - if (pivotTableDefinition.ShowError != null && pivotTableDefinition.ErrorCaption != null) - pt.ErrorValueReplacement = pivotTableDefinition.ErrorCaption.Value; - - var pivotTableDefinitionExtensionList = pivotTableDefinition.GetFirstChild(); - var pivotTableDefinitionExtension = pivotTableDefinitionExtensionList?.GetFirstChild(); - var pivotTableDefinition2 = pivotTableDefinitionExtension?.GetFirstChild(); - if (pivotTableDefinition2 != null) - { - if (pivotTableDefinition2.EnableEdit != null) pt.EnableCellEditing = pivotTableDefinition2.EnableEdit.Value; - if (pivotTableDefinition2.HideValuesRow != null) pt.ShowValuesRow = !pivotTableDefinition2.HideValuesRow.Value; - } - - var pivotTableStyle = pivotTableDefinition.GetFirstChild(); - if (pivotTableStyle != null) - { - if (pivotTableStyle.Name != null) - pt.Theme = (XLPivotTableTheme)Enum.Parse(typeof(XLPivotTableTheme), pivotTableStyle.Name); - else - pt.Theme = XLPivotTableTheme.None; - - pt.ShowRowHeaders = OpenXmlHelper.GetBooleanValueAsBool(pivotTableStyle.ShowRowHeaders, false); - pt.ShowColumnHeaders = OpenXmlHelper.GetBooleanValueAsBool(pivotTableStyle.ShowColumnHeaders, false); - pt.ShowRowStripes = OpenXmlHelper.GetBooleanValueAsBool(pivotTableStyle.ShowRowStripes, false); - pt.ShowColumnStripes = OpenXmlHelper.GetBooleanValueAsBool(pivotTableStyle.ShowColumnStripes, false); - } - - // Subtotal configuration - if (pivotTableDefinition.PivotFields.Cast().All(pf => (pf.DefaultSubtotal == null || pf.DefaultSubtotal.Value) - && (pf.SubtotalTop == null || pf.SubtotalTop == true))) - pt.SetSubtotals(XLPivotSubtotals.AtTop); - else if (pivotTableDefinition.PivotFields.Cast().All(pf => (pf.DefaultSubtotal == null || pf.DefaultSubtotal.Value) - && (pf.SubtotalTop != null && pf.SubtotalTop.Value == false))) - pt.SetSubtotals(XLPivotSubtotals.AtBottom); - else - pt.SetSubtotals(XLPivotSubtotals.DoNotShow); - - // Row labels - if (pivotTableDefinition.RowFields != null) - { - foreach (var rf in pivotTableDefinition.RowFields.Cast()) - { - if (rf.Index < pivotTableDefinition.PivotFields.Count) - { - IXLPivotField pivotField = null; - if (rf.Index.Value == -2) - pivotField = pt.RowLabels.Add(XLConstants.PivotTable.ValuesSentinalLabel); - else - { - var pf = pivotTableDefinition.PivotFields.ElementAt(rf.Index.Value) as PivotField; - if (pf == null) - continue; - - var cacheField = pivotTableCacheDefinitionPart.PivotCacheDefinition.CacheFields.ElementAt(rf.Index.Value) as CacheField; - if (pt.SourceRangeFieldsAvailable.Contains(cacheField.Name?.Value)) - pivotField = pf.Name != null - ? pt.RowLabels.Add(cacheField.Name, pf.Name.Value) - : pt.RowLabels.Add(cacheField.Name.Value); - else - continue; - - if (pivotField != null) - { - LoadFieldOptions(pf, pivotField); - LoadSubtotals(pf, pivotField); - - if (pf.SortType != null) - { - pivotField.SetSort((XLPivotSortType)pf.SortType.Value); - } - } - } - } - } - } - - // Column labels - if (pivotTableDefinition.ColumnFields != null) - { - foreach (var cf in pivotTableDefinition.ColumnFields.Cast()) - { - IXLPivotField pivotField = null; - if (cf.Index.Value == -2) - pivotField = pt.ColumnLabels.Add(XLConstants.PivotTable.ValuesSentinalLabel); - else if (cf.Index < pivotTableDefinition.PivotFields.Count) - { - var pf = pivotTableDefinition.PivotFields.ElementAt(cf.Index.Value) as PivotField; - if (pf == null) - continue; - - var cacheField = pivotTableCacheDefinitionPart.PivotCacheDefinition.CacheFields.ElementAt(cf.Index.Value) as CacheField; - if (pt.SourceRangeFieldsAvailable.Contains(cacheField.Name?.Value)) - pivotField = pf.Name != null - ? pt.ColumnLabels.Add(cacheField.Name, pf.Name.Value) - : pt.ColumnLabels.Add(cacheField.Name.Value); - else - continue; - - if (pivotField != null) - { - LoadFieldOptions(pf, pivotField); - LoadSubtotals(pf, pivotField); - - if (pf.SortType != null) - { - pivotField.SetSort((XLPivotSortType)pf.SortType.Value); - } - } - } - } - } - - // Values - if (pivotTableDefinition.DataFields != null) - { - foreach (var df in pivotTableDefinition.DataFields.Cast()) - { - IXLPivotValue pivotValue = null; - if ((int)df.Field.Value == -2) - pivotValue = pt.Values.Add(XLConstants.PivotTable.ValuesSentinalLabel); - else if (df.Field.Value < pivotTableDefinition.PivotFields.Count) - { - var pf = pivotTableDefinition.PivotFields.ElementAt((int)df.Field.Value) as PivotField; - if (pf == null) - continue; - - var cacheField = pivotTableCacheDefinitionPart.PivotCacheDefinition.CacheFields.ElementAt((int)df.Field.Value) as CacheField; - - if (pf.Name != null) - pivotValue = pt.Values.Add(pf.Name.Value, df.Name.Value); - else if (cacheField.Name != null && pt.SourceRangeFieldsAvailable.Contains(cacheField.Name)) - pivotValue = pt.Values.Add(cacheField.Name.Value, df.Name.Value); - else - continue; - - if (df.NumberFormatId != null) pivotValue.NumberFormat.SetNumberFormatId((int)df.NumberFormatId.Value); - if (df.Subtotal != null) pivotValue = pivotValue.SetSummaryFormula(df.Subtotal.Value.ToClosedXml()); - if (df.ShowDataAs != null) - { - var calculation = df.ShowDataAs.Value.ToClosedXml(); - pivotValue = pivotValue.SetCalculation(calculation); - } - - if (df.BaseField?.Value != null) - { - var col = pt.SourceRange.Column(df.BaseField.Value + 1); - - var items = col.CellsUsed() - .Select(c => c.Value) - .Skip(1) // Skip header column - .Distinct().ToList(); - - pivotValue.BaseField = col.FirstCell().GetValue(); - - if (df.BaseItem?.Value != null) - { - var bi = (int)df.BaseItem.Value; - if (bi.Between(0, items.Count - 1)) - pivotValue.BaseItem = items[(int)df.BaseItem.Value].ToString(); - } - } - } - } - } - - // Filters - if (pivotTableDefinition.PageFields != null) - { - foreach (var pageField in pivotTableDefinition.PageFields.Cast()) - { - var pf = pivotTableDefinition.PivotFields.ElementAt(pageField.Field.Value) as PivotField; - if (pf == null) - continue; - - var cacheField = pivotTableCacheDefinitionPart.PivotCacheDefinition.CacheFields.ElementAt(pageField.Field.Value) as CacheField; - - if (!pt.SourceRangeFieldsAvailable.Contains(cacheField.Name?.Value)) - continue; - - var filterName = pf.Name?.Value ?? cacheField.Name?.Value; - - IXLPivotField rf; - if (pageField.Name?.Value != null) - rf = pt.ReportFilters.Add(filterName, pageField.Name.Value); - else - rf = pt.ReportFilters.Add(filterName); - - var openXmlItems = new List(); - if ((pageField.Item?.HasValue ?? false) - && pf.Items.Any() && cacheField.SharedItems.Any()) - { - if (!(pf.Items.ElementAt(Convert.ToInt32(pageField.Item.Value)) is Item item)) - continue; - - openXmlItems.Add(item); - } - else if (OpenXmlHelper.GetBooleanValueAsBool(pf.MultipleItemSelectionAllowed, false)) - { - openXmlItems.AddRange(pf.Items.Cast()); - } - - foreach (var item in openXmlItems) - { - if (!OpenXmlHelper.GetBooleanValueAsBool(item.Hidden, false) - && (item.Index?.HasValue ?? false)) - { - var sharedItem = cacheField.SharedItems.ElementAt(Convert.ToInt32((uint)item.Index)); - // https://msdn.microsoft.com/en-us/library/documentformat.openxml.spreadsheet.shareditems.aspx - switch (sharedItem) - { - case NumberItem numberItem: - rf.AddSelectedValue(Convert.ToDouble(numberItem.Val.Value)); - break; - - case DateTimeItem dateTimeItem: - rf.AddSelectedValue(Convert.ToDateTime(dateTimeItem.Val.Value)); - break; - - case BooleanItem booleanItem: - rf.AddSelectedValue(Convert.ToBoolean(booleanItem.Val.Value)); - break; - - case StringItem stringItem: - rf.AddSelectedValue(stringItem.Val.Value); - break; - - case MissingItem missingItem: - case ErrorItem errorItem: - // Ignore missing and error items - break; - - default: - throw new NotImplementedException(); - } - } - } - } - - pt.TargetCell = pt.TargetCell.CellAbove(pt.ReportFilters.Count() + 1); - } - - LoadPivotStyleFormats(pt, pivotTableDefinition, pivotTableCacheDefinitionPart.PivotCacheDefinition, differentialFormats); - } - } + using var reader = CreateTreeReader(recordsPart); + var recordsReader = new PivotCacheRecordsReader(reader, pivotCache); + recordsReader.ReadRecordsToCache(); } } - #endregion Pivot tables - } - - private void LoadPivotStyleFormats(XLPivotTable pt, PivotTableDefinition ptd, PivotCacheDefinition pcd, Dictionary differentialFormats) - { - if (ptd.Formats == null) - return; - - foreach (var format in ptd.Formats.OfType()) + // Delay loading of pivot tables until all sheets have been loaded + foreach (var dSheet in sheets.OfType()) { - var pivotArea = format.PivotArea; - if (pivotArea == null) - continue; - - var type = pivotArea.Type ?? PivotAreaValues.Normal; - var dataOnly = OpenXmlHelper.GetBooleanValueAsBool(pivotArea.DataOnly, true); - var labelOnly = OpenXmlHelper.GetBooleanValueAsBool(pivotArea.LabelOnly, false); - - if (dataOnly && labelOnly) - throw new InvalidOperationException("Cannot have dataOnly and labelOnly both set to true."); - - XLPivotStyleFormat styleFormat; - - if (pivotArea.Field == null && !(pivotArea.PivotAreaReferences?.OfType()?.Any() ?? false)) + if (string.IsNullOrEmpty(dSheet.Id)) { - // If the pivot field is null and doesn't have children (references), we assume this format is a grand total - // Example: - // - - var appliesTo = XLPivotStyleFormatElement.All; - if (dataOnly) - appliesTo = XLPivotStyleFormatElement.Data; - else if (labelOnly) - appliesTo = XLPivotStyleFormatElement.Label; - - var isRow = OpenXmlHelper.GetBooleanValueAsBool(pivotArea.GrandRow, false); - var isColumn = OpenXmlHelper.GetBooleanValueAsBool(pivotArea.GrandColumn, false); - - // Either of the two should be true, else this is an unsupported format - if (!isRow && !isColumn) - continue; - //throw new NotImplementedException(); - - if (isRow) - styleFormat = pt.StyleFormats.RowGrandTotalFormats.ForElement(appliesTo) as XLPivotStyleFormat; - else - styleFormat = pt.StyleFormats.ColumnGrandTotalFormats.ForElement(appliesTo) as XLPivotStyleFormat; + // Some non-Excel producers create sheets with empty relId. + continue; } - else - { - Int32 fieldIndex; - Boolean defaultSubtotal = false; - - if (pivotArea.Field != null) - fieldIndex = (Int32)pivotArea.Field; - else if (pivotArea.PivotAreaReferences?.OfType()?.Any() ?? false) - { - // The field we want does NOT have any children - var r = pivotArea.PivotAreaReferences.OfType().FirstOrDefault(r1 => !r1.Any()); - if (r == null) - continue; - - fieldIndex = Convert.ToInt32((UInt32)r.Field); - defaultSubtotal = OpenXmlHelper.GetBooleanValueAsBool(r.DefaultSubtotal, false); - } - else - throw new NotImplementedException(); - XLPivotField field = null; - if (fieldIndex == -2) - { - var axis = pivotArea.Axis.Value; - if (axis == PivotTableAxisValues.AxisRow) - field = (XLPivotField)pt.RowLabels.Single(f => f.SourceName == "{{Values}}"); - else if (axis == PivotTableAxisValues.AxisColumn) - field = (XLPivotField)pt.ColumnLabels.Single(f => f.SourceName == "{{Values}}"); - else - continue; - } - else - { - var fieldName = pt.SourceRangeFieldsAvailable.ElementAt(fieldIndex); - field = (XLPivotField)pt.ImplementedFields.SingleOrDefault(f => f.SourceName.Equals(fieldName)); + // The referenced sheet can also be ChartsheetPart. Only look for pivot tables in normal sheet parts. + var worksheetPart = workbookPart.GetPartById(dSheet.Id) as WorksheetPart; - if (field is null) - continue; - } + if (worksheetPart is not null) + { + var ws = (XLWorksheet)WorksheetsInternal.Worksheet(dSheet.Name); - if (defaultSubtotal) - { - // Subtotal format - // Example: - // - // - // - // - // - - styleFormat = field.StyleFormats.Subtotal as XLPivotStyleFormat; - } - else if (type == PivotAreaValues.Button) - { - // Header format - // Example: - // - styleFormat = field.StyleFormats.Header as XLPivotStyleFormat; - } - else if (labelOnly) - { - // Label format - // Example: - // - // - // - // - // - styleFormat = field.StyleFormats.Label as XLPivotStyleFormat; - } - else + foreach (var pivotTablePart in worksheetPart.PivotTableParts) { - // Assume DataValues format - // Example: - // - // - // - // - // - // - // - // - // - // - // - styleFormat = field.StyleFormats.DataValuesFormat as XLPivotStyleFormat; - - foreach (var reference in pivotArea.PivotAreaReferences.OfType()) - { - fieldIndex = unchecked((int)reference.Field.Value); - if (field.Offset == fieldIndex) - continue; // already handled - - var fieldItem = reference.OfType().First(); - var fieldItemValue = (int)fieldItem.Val.Value; - - if (fieldIndex == -2) - { - styleFormat = (styleFormat as XLPivotValueStyleFormat) - .ForValueField(pt.Values.ElementAt(fieldItemValue)) - as XLPivotValueStyleFormat; - } - else - { - var additionalFieldName = pt.SourceRangeFieldsAvailable.ElementAt(fieldIndex); - var additionalField = pt.ImplementedFields - .Single(f => f.SourceName == additionalFieldName); - - var cacheField = pcd.CacheFields.OfType() - .FirstOrDefault(cf => cf.Name == additionalFieldName); - - Predicate predicate = null; - if ((cacheField?.SharedItems?.Any() ?? false) - && fieldItemValue < cacheField.SharedItems.Count) - { - var value = cacheField.SharedItems.OfType().ElementAt(fieldItemValue).Val?.Value; - predicate = o => o.ToString() == value; - } - - styleFormat = (styleFormat as XLPivotValueStyleFormat) - .AndWith(additionalField, predicate) - as XLPivotValueStyleFormat; - } - } + PivotTableDefinitionPartReader.Load(workbookPart, differentialFormats, pivotTablePart, worksheetPart, ws, context); } - - styleFormat.AreaType = type.Value.ToClosedXml(); - styleFormat.Outline = OpenXmlHelper.GetBooleanValueAsBool(pivotArea.Outline, true); - styleFormat.CollapsedLevelsAreSubtotals = OpenXmlHelper.GetBooleanValueAsBool(pivotArea.CollapsedLevelsAreSubtotals, false); - } - - IXLStyle style = XLStyle.Default; - if (format.FormatId != null) - { - var df = differentialFormats[(Int32)format.FormatId.Value]; - LoadFont(df.Font, style.Font); - LoadFill(df.Fill, style.Fill, differentialFillFormat: true); - LoadBorder(df.Border, style.Border); - LoadNumberFormat(df.NumberingFormat, style.NumberFormat); } - - styleFormat.Style = style; - } - } - - private static void LoadFieldOptions(PivotField pf, IXLPivotField pivotField) - { - if (pf.SubtotalCaption != null) pivotField.SubtotalCaption = pf.SubtotalCaption; - if (pf.IncludeNewItemsInFilter != null) pivotField.IncludeNewItemsInFilter = pf.IncludeNewItemsInFilter.Value; - if (pf.Outline != null) pivotField.Outline = pf.Outline.Value; - if (pf.Compact != null) pivotField.Compact = pf.Compact.Value; - if (pf.InsertBlankRow != null) pivotField.InsertBlankLines = pf.InsertBlankRow.Value; - pivotField.ShowBlankItems = OpenXmlHelper.GetBooleanValueAsBool(pf.ShowAll, true); - if (pf.InsertPageBreak != null) pivotField.InsertPageBreaks = pf.InsertPageBreak.Value; - if (pf.SubtotalTop != null) pivotField.SubtotalsAtTop = pf.SubtotalTop.Value; - if (pf.AllDrilled != null) pivotField.Collapsed = !pf.AllDrilled.Value; - - var pivotFieldExtensionList = pf.GetFirstChild(); - var pivotFieldExtension = pivotFieldExtensionList?.GetFirstChild(); - var field2010 = pivotFieldExtension?.GetFirstChild(); - if (field2010?.FillDownLabels != null) pivotField.RepeatItemLabels = field2010.FillDownLabels.Value; - } - - private static void LoadSubtotals(PivotField pf, IXLPivotField pivotField) - { - if (pf.AverageSubTotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.Average); - if (pf.CountASubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.Count); - if (pf.CountSubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.CountNumbers); - if (pf.MaxSubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.Maximum); - if (pf.MinSubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.Minimum); - if (pf.ApplyStandardDeviationPInSubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.PopulationStandardDeviation); - if (pf.ApplyVariancePInSubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.PopulationVariance); - if (pf.ApplyProductInSubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.Product); - if (pf.ApplyStandardDeviationInSubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.StandardDeviation); - if (pf.SumSubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.Sum); - if (pf.ApplyVarianceInSubtotal != null) - pivotField.AddSubtotal(XLSubtotalFunction.Variance); - - if (pf.Items?.Any() ?? false) - { - var items = pf.Items.OfType().Where(i => i.Index != null && i.Index.HasValue); - if (!items.Any(i => i.HideDetails == null || BooleanValue.ToBoolean(i.HideDetails))) - pivotField.SetCollapsed(); } } @@ -1242,37 +628,19 @@ private String GetTableColumnName(string name) return name.Replace("_x000a_", Environment.NewLine).Replace("_x005f_x000a_", "_x000a_"); } - // This may be part of XLHelper or XLColor - // Leaving it here for now. Can't decide what to call it and where to put it. - /// - /// Parse VML ST_ColorType type from ECMA-376, Part 4 20.1.2.3. - /// - private XLColor ExtractVmlSimpleColor(String color) - { - var isPaletteEntry = color.IndexOf("[") >= 0; - if (isPaletteEntry) - { - int start = color.IndexOf("[") + 1; - int end = color.IndexOf("]", start); - return XLColor.FromIndex(Int32.Parse(color.Substring(start, end - start))); - } - - return color.StartsWith("#", StringComparison.Ordinal) - ? XLColor.FromHtml(color) - : XLColor.FromName(color); - } - private void LoadColorsAndLines(IXLDrawing drawing, XElement shape) { - var strokeColor = shape.Attribute("strokecolor"); - if (strokeColor != null) drawing.Style.ColorsAndLines.LineColor = ExtractVmlSimpleColor(strokeColor.Value); + var strokeColor = shape.Attribute(@"strokecolor"); + if (strokeColor is not null) + drawing.Style.ColorsAndLines.LineColor = XLColor.FromVmlColor(strokeColor.Value); - var strokeWeight = shape.Attribute("strokeweight"); + var strokeWeight = shape.Attribute(@"strokeweight"); if (strokeWeight != null && TryGetPtValue(strokeWeight.Value, out var lineWeight)) drawing.Style.ColorsAndLines.LineWeight = lineWeight; - var fillColor = shape.Attribute("fillcolor"); - if (fillColor != null && !fillColor.Value.ToLower().Contains("infobackground")) drawing.Style.ColorsAndLines.FillColor = ExtractVmlSimpleColor(fillColor.Value); + var fillColor = shape.Attribute(@"fillcolor"); + if (fillColor is not null) + drawing.Style.ColorsAndLines.FillColor = XLColor.FromVmlColor(fillColor.Value); var fill = shape.Elements().FirstOrDefault(e => e.Name.LocalName == "fill"); if (fill != null) @@ -1347,28 +715,61 @@ private void LoadColorsAndLines(IXLDrawing drawing, XElement shape) private void LoadTextBox(IXLDrawing xlDrawing, XElement textBox) { var attStyle = textBox.Attribute("style"); - if (attStyle != null) LoadTextBoxStyle(xlDrawing, attStyle); + if (attStyle != null) LoadTextBoxStyle(xlDrawing, attStyle); var attInset = textBox.Attribute("inset"); - if (attInset != null) LoadTextBoxInset(xlDrawing, attInset); + if (attInset != null) LoadTextBoxInset(xlDrawing, attInset); } private void LoadTextBoxInset(IXLDrawing xlDrawing, XAttribute attInset) { var split = attInset.Value.Split(','); - xlDrawing.Style.Margins.Left = GetInsetValue(split[0]); - xlDrawing.Style.Margins.Top = GetInsetValue(split[1]); - xlDrawing.Style.Margins.Right = GetInsetValue(split[2]); - xlDrawing.Style.Margins.Bottom = GetInsetValue(split[3]); + xlDrawing.Style.Margins.Left = GetInsetInInches(split[0], DpiX); + xlDrawing.Style.Margins.Top = GetInsetInInches(split[1], DpiY); + xlDrawing.Style.Margins.Right = GetInsetInInches(split[2], DpiX); + xlDrawing.Style.Margins.Bottom = GetInsetInInches(split[3], DpiY); } - private double GetInsetValue(string value) + /// + /// List of all VML length units and their conversion. Key is a name, value is a conversion + /// function to EMU. See documentation. + /// + /// + /// OI-29500 says Office also uses EMUs throughout VML as a valid unit system. + /// Relative units conversions are guesstimated by how Excel 2022 behaves for inset + /// attribute of TextBox element of a note/comment. Generally speaking, Excel + /// converts relative values to physical length (e.g. px to pt) and saves + /// them as such. The ex/em units are not interpreted as described in the + /// doc, but as 1/90th or an inch. The % seems to be always 0. + /// + private static readonly Dictionary> VmlLengthUnits = new() + { + {"in", (value, _) => Emu.From(value, AbsLengthUnit.Inch) }, + {"cm", (value, _) => Emu.From(value, AbsLengthUnit.Centimeter) }, + {"mm", (value, _) => Emu.From(value, AbsLengthUnit.Millimeter) }, + {"pt", (value, _) => Emu.From(value, AbsLengthUnit.Point) }, + {"pc", (value, _) => Emu.From(value, AbsLengthUnit.Pica) }, + {"emu", (value, _) => Emu.From(value , AbsLengthUnit.Emu) }, + {"px", (value, dpi) => Emu.From(value / dpi, AbsLengthUnit.Inch) }, + {"em", (value, _) => Emu.From(value * 72.0 / 90.0, AbsLengthUnit.Point) }, + {"ex", (value, _) => Emu.From(value * 72.0 / 90.0, AbsLengthUnit.Point) }, + {"%", (_, _) => Emu.ZeroPt }, + }; + + private static double GetInsetInInches(string value, double dpi) { - String v = value.Trim(); - if (v.EndsWith("pt")) - return Double.Parse(v.Substring(0, v.Length - 2), CultureInfo.InvariantCulture) / 72.0; - else - return Double.Parse(v.Substring(0, v.Length - 2), CultureInfo.InvariantCulture); + var unit = value.Trim(); + foreach (var (unitName, conversion) in VmlLengthUnits) + { + if (unit.EndsWith(unitName) && Double.TryParse(unit[..^unitName.Length], NumberStyles.Float, CultureInfo.InvariantCulture, out var unitValue)) + { + var insetEmu = conversion(unitValue, dpi) ?? Emu.ZeroPt; + return insetEmu.To(AbsLengthUnit.Inch); + } + } + + // Excel treats no/unexpected unit as 0 + return 0; } private static void LoadTextBoxStyle(IXLDrawing xlDrawing, XAttribute attStyle) @@ -1550,7 +951,7 @@ private void LoadDefinedNames(Workbook workbook) { if (area.Contains("[")) { - var ws = Worksheets.FirstOrDefault(w => (w as XLWorksheet).SheetId == (localSheetId + 1)); + var ws = WorksheetsInternal.FirstOrDefault(w => w.SheetId == (localSheetId + 1)); if (ws != null) { ws.PageSetup.PrintAreas.Add(area); @@ -1575,19 +976,19 @@ private void LoadDefinedNames(Workbook workbook) var comment = definedName.Comment; if (localSheetId == -1) { - if (NamedRanges.All(nr => nr.Name != name)) - (NamedRanges as XLNamedRanges).Add(name, text, comment, validateName: false, validateRangeAddress: false).Visible = visible; + if (DefinedNamesInternal.All(nr => nr.Name != name)) + DefinedNamesInternal.Add(name, text, comment, validateName: false, validateRangeAddress: false).Visible = visible; } else { - if (Worksheet(localSheetId + 1).NamedRanges.All(nr => nr.Name != name)) - (Worksheet(localSheetId + 1).NamedRanges as XLNamedRanges).Add(name, text, comment, validateName: false, validateRangeAddress: false).Visible = visible; + if (Worksheet(localSheetId + 1).DefinedNames.All(nr => nr.Name != name)) + ((XLDefinedNames)Worksheet(localSheetId + 1).DefinedNames).Add(name, text, comment, validateName: false, validateRangeAddress: false).Visible = visible; } } } } - private static Regex definedNameRegex = new Regex(@"\A'.*'!.*\z", RegexOptions.Compiled); + private static Regex definedNameRegex = new Regex(@"\A('?).*\1!.*\z", RegexOptions.Compiled); private IEnumerable validateDefinedNames(IEnumerable definedNames) { @@ -1659,1078 +1060,76 @@ private static void ParseReference(string item, out string sheetName, out string } } - private Int32 lastColumnNumber; - private void LoadCells(SharedStringItem[] sharedStrings, Stylesheet s, NumberingFormats numberingFormats, - Fills fills, Borders borders, Fonts fonts, Dictionary sharedFormulasR1C1, - XLWorksheet ws, Dictionary styleList, Cell cell, Int32 rowIndex) + private static void LoadWorkbookTheme(ThemePart tp, XLWorkbook wb) { - Int32 styleIndex = cell.StyleIndex != null ? Int32.Parse(cell.StyleIndex.InnerText) : 0; - - XLAddress cellAddress; - if (cell.CellReference == null) - { - cellAddress = new XLAddress(ws, rowIndex, ++lastColumnNumber, false, false); - } - else - { - cellAddress = XLAddress.Create(ws, cell.CellReference.Value); - lastColumnNumber = cellAddress.ColumnNumber; - } - - var xlCell = ws.Cell(in cellAddress); - - if (styleList.TryGetValue(styleIndex, out IXLStyle style)) - { - xlCell.InnerStyle = style; - } - else - { - ApplyStyle(xlCell, styleIndex, s, fills, borders, fonts, numberingFormats); - } + if (tp is null) + return; - if (cell.CellFormula?.SharedIndex != null && cell.CellFormula?.Reference != null) + var colorScheme = tp.Theme?.ThemeElements?.ColorScheme; + if (colorScheme is not null) { - String formula; - if (cell.CellFormula.FormulaType != null && cell.CellFormula.FormulaType == CellFormulaValues.Array) - formula = "{" + cell.CellFormula.Text + "}"; - else - formula = cell.CellFormula.Text; - - // Parent cell of shared formulas - // Child cells will use this shared index to set its R1C1 style formula - xlCell.FormulaReference = ws.Range(cell.CellFormula.Reference.Value).RangeAddress; - - xlCell.FormulaA1 = formula; - sharedFormulasR1C1.Add(cell.CellFormula.SharedIndex.Value, xlCell.FormulaR1C1); - - if (cell.DataType != null) + var background1 = colorScheme.Light1Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(background1)) { - switch (cell.DataType.Value) - { - case CellValues.Boolean: - xlCell.SetDataTypeFast(XLDataType.Boolean); - break; - - case CellValues.Number: - xlCell.SetDataTypeFast(XLDataType.Number); - break; - - case CellValues.Date: - xlCell.SetDataTypeFast(XLDataType.DateTime); - break; - - case CellValues.InlineString: - case CellValues.SharedString: - case CellValues.String: - xlCell.SetDataTypeFast(XLDataType.Text); - break; - } + wb.Theme.Background1 = XLColor.FromHexRgb(background1); } - } - else if (cell.CellFormula != null) - { - if (cell.CellFormula.SharedIndex != null) - xlCell.FormulaR1C1 = sharedFormulasR1C1[cell.CellFormula.SharedIndex.Value]; - else if (!String.IsNullOrWhiteSpace(cell.CellFormula.Text)) + var text1 = colorScheme.Dark1Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(text1)) { - String formula; - if (cell.CellFormula.FormulaType != null && cell.CellFormula.FormulaType == CellFormulaValues.Array) - formula = "{" + cell.CellFormula.Text + "}"; - else - formula = cell.CellFormula.Text; - - xlCell.FormulaA1 = formula; + wb.Theme.Text1 = XLColor.FromHexRgb(text1); } - - if (cell.CellFormula.Reference != null) + var background2 = colorScheme.Light2Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(background2)) { - foreach (var childCell in ws.Range(cell.CellFormula.Reference.Value).Cells(c => c.FormulaReference == null || !c.HasFormula)) - { - if (childCell.FormulaReference == null) - childCell.FormulaReference = ws.Range(cell.CellFormula.Reference.Value).RangeAddress; - - if (!childCell.HasFormula) - childCell.FormulaA1 = xlCell.FormulaA1; - } + wb.Theme.Background2 = XLColor.FromHexRgb(background2); } - - if (cell.DataType != null) + var text2 = colorScheme.Dark2Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(text2)) { - switch (cell.DataType.Value) - { - case CellValues.Boolean: - xlCell.SetDataTypeFast(XLDataType.Boolean); - break; - - case CellValues.Number: - xlCell.SetDataTypeFast(XLDataType.Number); - break; - - case CellValues.Date: - xlCell.SetDataTypeFast(XLDataType.DateTime); - break; - - case CellValues.InlineString: - case CellValues.SharedString: - case CellValues.String: - xlCell.SetDataTypeFast(XLDataType.Text); - break; - } + wb.Theme.Text2 = XLColor.FromHexRgb(text2); } - } - else if (cell.DataType != null) - { - if (cell.DataType == CellValues.InlineString) + var accent1 = colorScheme.Accent1Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(accent1)) { - xlCell.SetDataTypeFast(XLDataType.Text); - xlCell.ShareString = false; - - if (cell.InlineString != null) - { - if (cell.InlineString.Text != null) - xlCell.SetInternalCellValueString(cell.InlineString.Text.Text.FixNewLines()); - else - ParseCellValue(cell.InlineString, xlCell); - } - else - xlCell.SetInternalCellValueString(String.Empty); + wb.Theme.Accent1 = XLColor.FromHexRgb(accent1); } - else if (cell.DataType == CellValues.SharedString) + var accent2 = colorScheme.Accent2Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(accent2)) { - xlCell.SetDataTypeFast(XLDataType.Text); - - if (cell.CellValue != null - && Int32.TryParse(cell.CellValue.Text, XLHelper.NumberStyle, XLHelper.ParseCulture, out Int32 sharedStringId) - && sharedStringId >= 0 && sharedStringId < sharedStrings.Length) - { - xlCell.SharedStringId = sharedStringId; - var sharedString = sharedStrings[sharedStringId]; - ParseCellValue(sharedString, xlCell); - } - else - xlCell.SetInternalCellValueString(String.Empty); + wb.Theme.Accent2 = XLColor.FromHexRgb(accent2); } - else if (cell.DataType == CellValues.String) + var accent3 = colorScheme.Accent3Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(accent3)) { - xlCell.SetDataTypeFast(XLDataType.Text); - - if (!String.IsNullOrEmpty(cell.CellValue?.Text)) - xlCell.SetInternalCellValueString(cell.CellValue.Text); - else - xlCell.SetInternalCellValueString(String.Empty); + wb.Theme.Accent3 = XLColor.FromHexRgb(accent3); } - else if (cell.DataType == CellValues.Date) + var accent4 = colorScheme.Accent4Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(accent4)) { - xlCell.SetDataTypeFast(XLDataType.DateTime); - - if (cell.CellValue != null && !String.IsNullOrWhiteSpace(cell.CellValue.Text)) - xlCell.SetInternalCellValueString(Double.Parse(cell.CellValue.Text, XLHelper.NumberStyle, XLHelper.ParseCulture).ToInvariantString()); + wb.Theme.Accent4 = XLColor.FromHexRgb(accent4); } - else if (cell.DataType == CellValues.Boolean) + var accent5 = colorScheme.Accent5Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(accent5)) { - xlCell.SetDataTypeFast(XLDataType.Boolean); - if (cell.CellValue != null) - xlCell.SetInternalCellValueString(cell.CellValue.Text); + wb.Theme.Accent5 = XLColor.FromHexRgb(accent5); } - else if (cell.DataType == CellValues.Number) + var accent6 = colorScheme.Accent6Color?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(accent6)) { - if (s == null) - xlCell.SetDataTypeFast(XLDataType.Number); - else - xlCell.DataType = GetDataTypeFromCell(xlCell.StyleValue.NumberFormat); - - if (cell.CellValue != null && !String.IsNullOrWhiteSpace(cell.CellValue.Text)) - xlCell.SetInternalCellValueString(Double.Parse(cell.CellValue.Text, XLHelper.NumberStyle, XLHelper.ParseCulture).ToInvariantString()); + wb.Theme.Accent6 = XLColor.FromHexRgb(accent6); } - } - else if (cell.CellValue != null) - { - if (s == null) - { - xlCell.SetDataTypeFast(XLDataType.Number); - } - else - { - xlCell.DataType = GetDataTypeFromCell(xlCell.StyleValue.NumberFormat); - - if (!String.IsNullOrWhiteSpace(cell.CellValue.Text)) - xlCell.SetInternalCellValueString(Double.Parse(cell.CellValue.Text, CultureInfo.InvariantCulture).ToInvariantString()); - - var numberFormatId = ((CellFormat)(s.CellFormats).ElementAt(styleIndex)).NumberFormatId; - - if (numberFormatId?.HasValue ?? false) - { - var format = s.NumberingFormats? - .Cast() - .Where(nf => nf.NumberFormatId.Value == numberFormatId) - .Select(nf => nf.FormatCode.Value) - .FirstOrDefault(); - - if (format == null) - xlCell.InnerStyle.NumberFormat.NumberFormatId = Int32.Parse(numberFormatId); - else - xlCell.InnerStyle.NumberFormat.Format = format; - } - } - } - - if (xlCell.HasFormula) - { - if (cell.CellValue != null) - xlCell.SetInternalCellValueString(cell.CellValue.Text); - - xlCell.NeedsRecalculation = (xlCell.CachedValue == null); - } - - if (Use1904DateSystem && xlCell.DataType == XLDataType.DateTime) - { - // Internally ClosedXML stores cells as standard 1900-based style - // so if a workbook is in 1904-format, we do that adjustment here and when saving. - xlCell.SetValue(xlCell.GetDateTime().AddDays(1462)); - } - - if (!styleList.ContainsKey(styleIndex)) - styleList.Add(styleIndex, xlCell.Style); - } - - /// - /// Parses the cell value for normal or rich text - /// Input element should either be a shared string or inline string - /// - /// The element (either a shared string or inline string) - /// The cell. - private void ParseCellValue(RstType element, XLCell xlCell) - { - var runs = element.Elements(); - var phoneticRuns = element.Elements(); - var phoneticProperties = element.Elements(); - Boolean hasRuns = false; - foreach (Run run in runs) - { - var runProperties = run.RunProperties; - String text = run.Text.InnerText.FixNewLines(); - - if (runProperties == null) - xlCell.GetRichText().AddText(text, xlCell.Style.Font); - else - { - var rt = xlCell.GetRichText().AddText(text); - LoadFont(runProperties, rt); - } - if (!hasRuns) - hasRuns = true; - } - - if (!hasRuns) - xlCell.SetInternalCellValueString(XmlEncoder.DecodeString(element.Text.InnerText)); - - #region Load PhoneticProperties - - var pp = phoneticProperties.FirstOrDefault(); - if (pp != null) - { - if (pp.Alignment != null) - xlCell.GetRichText().Phonetics.Alignment = pp.Alignment.Value.ToClosedXml(); - if (pp.Type != null) - xlCell.GetRichText().Phonetics.Type = pp.Type.Value.ToClosedXml(); - - LoadFont(pp, xlCell.GetRichText().Phonetics); - } - - #endregion Load PhoneticProperties - - #region Load Phonetic Runs - - foreach (PhoneticRun pr in phoneticRuns) - { - xlCell.GetRichText().Phonetics.Add(pr.Text.InnerText.FixNewLines(), (Int32)pr.BaseTextStartIndex.Value, - (Int32)pr.EndingBaseIndex.Value); - } - - #endregion Load Phonetic Runs - } - - private void LoadNumberFormat(NumberingFormat nfSource, IXLNumberFormat nf) - { - if (nfSource == null) return; - - if (nfSource.NumberFormatId != null && nfSource.NumberFormatId.Value < XLConstants.NumberOfBuiltInStyles) - nf.NumberFormatId = (Int32)nfSource.NumberFormatId.Value; - else if (nfSource.FormatCode != null) - nf.Format = nfSource.FormatCode.Value; - } - - private void LoadBorder(Border borderSource, IXLBorder border) - { - if (borderSource == null) return; - - LoadBorderValues(borderSource.DiagonalBorder, border.SetDiagonalBorder, border.SetDiagonalBorderColor); - - if (borderSource.DiagonalUp != null) - border.DiagonalUp = borderSource.DiagonalUp.Value; - if (borderSource.DiagonalDown != null) - border.DiagonalDown = borderSource.DiagonalDown.Value; - - LoadBorderValues(borderSource.LeftBorder, border.SetLeftBorder, border.SetLeftBorderColor); - LoadBorderValues(borderSource.RightBorder, border.SetRightBorder, border.SetRightBorderColor); - LoadBorderValues(borderSource.TopBorder, border.SetTopBorder, border.SetTopBorderColor); - LoadBorderValues(borderSource.BottomBorder, border.SetBottomBorder, border.SetBottomBorderColor); - } - - private void LoadBorderValues(BorderPropertiesType source, Func setBorder, Func setColor) - { - if (source != null) - { - if (source.Style != null) - setBorder(source.Style.Value.ToClosedXml()); - if (source.Color != null) - setColor(source.Color.ToClosedXMLColor(_colorList)); - } - } - - // Differential fills store the patterns differently than other fills - // Actually differential fills make more sense. bg is bg and fg is fg - // 'Other' fills store the bg color in the fg field when pattern type is solid - private void LoadFill(Fill openXMLFill, IXLFill closedXMLFill, Boolean differentialFillFormat) - { - if (openXMLFill == null || openXMLFill.PatternFill == null) return; - - if (openXMLFill.PatternFill.PatternType != null) - closedXMLFill.PatternType = openXMLFill.PatternFill.PatternType.Value.ToClosedXml(); - else - closedXMLFill.PatternType = XLFillPatternValues.Solid; - - switch (closedXMLFill.PatternType) - { - case XLFillPatternValues.None: - break; - - case XLFillPatternValues.Solid: - if (differentialFillFormat) - { - if (openXMLFill.PatternFill.BackgroundColor != null) - closedXMLFill.BackgroundColor = openXMLFill.PatternFill.BackgroundColor.ToClosedXMLColor(_colorList); - else - closedXMLFill.BackgroundColor = XLColor.FromIndex(64); - } - else - { - // yes, source is foreground! - if (openXMLFill.PatternFill.ForegroundColor != null) - closedXMLFill.BackgroundColor = openXMLFill.PatternFill.ForegroundColor.ToClosedXMLColor(_colorList); - else - closedXMLFill.BackgroundColor = XLColor.FromIndex(64); - } - break; - - default: - if (openXMLFill.PatternFill.ForegroundColor != null) - closedXMLFill.PatternColor = openXMLFill.PatternFill.ForegroundColor.ToClosedXMLColor(_colorList); - - if (openXMLFill.PatternFill.BackgroundColor != null) - closedXMLFill.BackgroundColor = openXMLFill.PatternFill.BackgroundColor.ToClosedXMLColor(_colorList); - else - closedXMLFill.BackgroundColor = XLColor.FromIndex(64); - break; - } - } - - private void LoadFont(OpenXmlElement fontSource, IXLFontBase fontBase) - { - if (fontSource == null) return; - - fontBase.Bold = GetBoolean(fontSource.Elements().FirstOrDefault()); - var fontColor = fontSource.Elements().FirstOrDefault(); - if (fontColor != null) - fontBase.FontColor = fontColor.ToClosedXMLColor(_colorList); - - var fontFamilyNumbering = - fontSource.Elements().FirstOrDefault(); - if (fontFamilyNumbering != null && fontFamilyNumbering.Val != null) - fontBase.FontFamilyNumbering = - (XLFontFamilyNumberingValues)Int32.Parse(fontFamilyNumbering.Val.ToString()); - var runFont = fontSource.Elements().FirstOrDefault(); - if (runFont != null) - { - if (runFont.Val != null) - fontBase.FontName = runFont.Val; - } - var fontSize = fontSource.Elements().FirstOrDefault(); - if (fontSize != null) - { - if ((fontSize).Val != null) - fontBase.FontSize = (fontSize).Val; - } - - fontBase.Italic = GetBoolean(fontSource.Elements().FirstOrDefault()); - fontBase.Shadow = GetBoolean(fontSource.Elements().FirstOrDefault()); - fontBase.Strikethrough = GetBoolean(fontSource.Elements().FirstOrDefault()); - - var underline = fontSource.Elements().FirstOrDefault(); - if (underline != null) - { - fontBase.Underline = underline.Val != null ? underline.Val.Value.ToClosedXml() : XLFontUnderlineValues.Single; - } - - var verticalTextAlignment = fontSource.Elements().FirstOrDefault(); - - if (verticalTextAlignment == null) return; - - fontBase.VerticalAlignment = verticalTextAlignment.Val != null ? verticalTextAlignment.Val.Value.ToClosedXml() : XLFontVerticalTextAlignmentValues.Baseline; - } - - private Int32 lastRow; - - private void LoadRows(Stylesheet s, NumberingFormats numberingFormats, Fills fills, Borders borders, Fonts fonts, - XLWorksheet ws, SharedStringItem[] sharedStrings, - Dictionary sharedFormulasR1C1, Dictionary styleList, - Row row) - { - Int32 rowIndex = row.RowIndex == null ? ++lastRow : (Int32)row.RowIndex.Value; - var xlRow = ws.Row(rowIndex, false); - - if (row.Height != null) - xlRow.Height = row.Height; - else - { - xlRow.Loading = true; - xlRow.Height = ws.RowHeight; - xlRow.Loading = false; - } - - if (row.Hidden != null && row.Hidden) - xlRow.Hide(); - - if (row.Collapsed != null && row.Collapsed) - xlRow.Collapsed = true; - - if (row.OutlineLevel != null && row.OutlineLevel > 0) - xlRow.OutlineLevel = row.OutlineLevel; - - if (row.CustomFormat != null) - { - Int32 styleIndex = row.StyleIndex != null ? Int32.Parse(row.StyleIndex.InnerText) : -1; - if (styleIndex >= 0) + var hyperlink = colorScheme.Hyperlink?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(hyperlink)) { - ApplyStyle(xlRow, styleIndex, s, fills, borders, fonts, numberingFormats); + wb.Theme.Hyperlink = XLColor.FromHexRgb(hyperlink); } - else + var followedHyperlink = colorScheme.FollowedHyperlinkColor?.RgbColorModelHex?.Val?.Value; + if (!string.IsNullOrEmpty(followedHyperlink)) { - xlRow.Style = ws.Style; + wb.Theme.FollowedHyperlink = XLColor.FromHexRgb(followedHyperlink); } } - - lastColumnNumber = 0; - foreach (Cell cell in row.Elements()) - LoadCells(sharedStrings, s, numberingFormats, fills, borders, fonts, sharedFormulasR1C1, ws, styleList, - cell, rowIndex); - } - - private void LoadColumns(Stylesheet s, NumberingFormats numberingFormats, Fills fills, Borders borders, - Fonts fonts, XLWorksheet ws, Columns columns) - { - if (columns == null) return; - - var wsDefaultColumn = - columns.Elements().FirstOrDefault(c => c.Max == XLHelper.MaxColumnNumber); - - if (wsDefaultColumn != null && wsDefaultColumn.Width != null) - ws.ColumnWidth = wsDefaultColumn.Width - ColumnWidthOffset; - - Int32 styleIndexDefault = wsDefaultColumn != null && wsDefaultColumn.Style != null - ? Int32.Parse(wsDefaultColumn.Style.InnerText) - : -1; - if (styleIndexDefault >= 0) - ApplyStyle(ws, styleIndexDefault, s, fills, borders, fonts, numberingFormats); - - foreach (Column col in columns.Elements()) - { - //IXLStylized toApply; - if (col.Max == XLHelper.MaxColumnNumber) continue; - - var xlColumns = (XLColumns)ws.Columns(col.Min, col.Max); - if (col.Width != null) - { - Double width = col.Width - ColumnWidthOffset; - //if (width < 0) width = 0; - xlColumns.Width = width; - } - else - xlColumns.Width = ws.ColumnWidth; - - if (col.Hidden != null && col.Hidden) - xlColumns.Hide(); - - if (col.Collapsed != null && col.Collapsed) - xlColumns.CollapseOnly(); - - if (col.OutlineLevel != null) - { - var outlineLevel = col.OutlineLevel; - xlColumns.ForEach(c => c.OutlineLevel = outlineLevel); - } - - Int32 styleIndex = col.Style != null ? Int32.Parse(col.Style.InnerText) : -1; - if (styleIndex >= 0) - { - ApplyStyle(xlColumns, styleIndex, s, fills, borders, fonts, numberingFormats); - } - else - { - xlColumns.Style = ws.Style; - } - } - } - - private static XLDataType GetDataTypeFromCell(XLNumberFormatValue numberFormat) - { - var numberFormatId = numberFormat.NumberFormatId; - if (numberFormatId == 46U) - return XLDataType.TimeSpan; - else if ((numberFormatId >= 14 && numberFormatId <= 22) || - (numberFormatId >= 45 && numberFormatId <= 47)) - return XLDataType.DateTime; - else if (numberFormatId == 49) - return XLDataType.Text; - else - { - if (!String.IsNullOrWhiteSpace(numberFormat.Format)) - { - var dataType = GetDataTypeFromFormat(numberFormat.Format); - return dataType.HasValue ? dataType.Value : XLDataType.Number; - } - else - return XLDataType.Number; - } - } - - private static XLDataType? GetDataTypeFromFormat(String format) - { - int length = format.Length; - String f = format.ToLower(); - for (Int32 i = 0; i < length; i++) - { - Char c = f[i]; - if (c == '"') - i = f.IndexOf('"', i + 1); - else if (c == '0' || c == '#' || c == '?') - return XLDataType.Number; - else if (c == 'y' || c == 'm' || c == 'd' || c == 'h' || c == 's') - return XLDataType.DateTime; - } - return null; - } - - private static void LoadAutoFilter(AutoFilter af, XLWorksheet ws) - { - if (af != null) - { - ws.Range(af.Reference.Value).SetAutoFilter(); - var autoFilter = ws.AutoFilter; - LoadAutoFilterSort(af, ws, autoFilter); - LoadAutoFilterColumns(af, autoFilter); - } - } - - private static void LoadAutoFilterColumns(AutoFilter af, XLAutoFilter autoFilter) - { - foreach (var filterColumn in af.Elements()) - { - Int32 column = (int)filterColumn.ColumnId.Value + 1; - if (filterColumn.CustomFilters != null) - { - var filterList = new List(); - autoFilter.Column(column).FilterType = XLFilterType.Custom; - autoFilter.Filters.Add(column, filterList); - XLConnector connector = filterColumn.CustomFilters.And != null && filterColumn.CustomFilters.And.Value ? XLConnector.And : XLConnector.Or; - - Boolean isText = false; - foreach (var filter in filterColumn.CustomFilters.OfType()) - { - String val = filter.Val.Value; - if (!Double.TryParse(val, out Double dTest)) - { - isText = true; - break; - } - } - - foreach (var filter in filterColumn.CustomFilters.OfType()) - { - var xlFilter = new XLFilter { Connector = connector }; - if (isText) - { - // TODO: Treat text BETWEEN functions better - if (filter.Val.Value.StartsWith("*") && filter.Val.Value.EndsWith("*")) - { - var value = filter.Val.Value.Substring(1, filter.Val.Value.Length - 2); - xlFilter.Value = filter.Val.Value; - xlFilter.Condition = s => XLFilterColumn.ContainsFunction(value, s); - } - else if (filter.Val.Value.StartsWith("*")) - { - var value = filter.Val.Value.Substring(1); - xlFilter.Value = filter.Val.Value; - xlFilter.Condition = s => XLFilterColumn.EndsWithFunction(value, s); - } - else if (filter.Val.Value.EndsWith("*")) - { - var value = filter.Val.Value.Substring(0, filter.Val.Value.Length - 1); - xlFilter.Value = filter.Val.Value; - xlFilter.Condition = s => XLFilterColumn.BeginsWithFunction(value, s); - } - else - xlFilter.Value = filter.Val.Value; - } - else - xlFilter.Value = Double.Parse(filter.Val.Value, CultureInfo.InvariantCulture); - - if (filter.Operator != null) - xlFilter.Operator = filter.Operator.Value.ToClosedXml(); - else - xlFilter.Operator = XLFilterOperator.Equal; - - // Unhandled instances - we should actually improve this - if (xlFilter.Condition == null) - { - Func condition = null; - switch (xlFilter.Operator) - { - case XLFilterOperator.Equal: - if (isText) - condition = o => o.ToString().Equals(xlFilter.Value.ToString(), StringComparison.OrdinalIgnoreCase); - else - condition = o => (o as IComparable).CompareTo(xlFilter.Value) == 0; - break; - - case XLFilterOperator.EqualOrGreaterThan: condition = o => (o as IComparable).CompareTo(xlFilter.Value) >= 0; break; - case XLFilterOperator.EqualOrLessThan: condition = o => (o as IComparable).CompareTo(xlFilter.Value) <= 0; break; - case XLFilterOperator.GreaterThan: condition = o => (o as IComparable).CompareTo(xlFilter.Value) > 0; break; - case XLFilterOperator.LessThan: condition = o => (o as IComparable).CompareTo(xlFilter.Value) < 0; break; - case XLFilterOperator.NotEqual: - if (isText) - condition = o => !o.ToString().Equals(xlFilter.Value.ToString(), StringComparison.OrdinalIgnoreCase); - else - condition = o => (o as IComparable).CompareTo(xlFilter.Value) != 0; - break; - } - - xlFilter.Condition = condition; - } - - filterList.Add(xlFilter); - } - } - else if (filterColumn.Filters != null) - { - if (filterColumn.Filters.Elements().All(element => element is Filter)) - autoFilter.Column(column).FilterType = XLFilterType.Regular; - else if (filterColumn.Filters.Elements().All(element => element is DateGroupItem)) - autoFilter.Column(column).FilterType = XLFilterType.DateTimeGrouping; - else - throw new NotSupportedException(String.Format("Mixing regular filters and date group filters in a single autofilter column is not supported. Column {0} of {1}", column, autoFilter.Range.ToString())); - - var filterList = new List(); - - autoFilter.Filters.Add((int)filterColumn.ColumnId.Value + 1, filterList); - - Boolean isText = false; - foreach (var filter in filterColumn.Filters.OfType()) - { - String val = filter.Val.Value; - if (!Double.TryParse(val, NumberStyles.Any, null, out Double dTest)) - { - isText = true; - break; - } - } - - foreach (var filter in filterColumn.Filters.OfType()) - { - var xlFilter = new XLFilter { Connector = XLConnector.Or, Operator = XLFilterOperator.Equal }; - - Func condition; - if (isText) - { - xlFilter.Value = filter.Val.Value; - condition = o => o.ToString().Equals(xlFilter.Value.ToString(), StringComparison.OrdinalIgnoreCase); - } - else - { - xlFilter.Value = Double.Parse(filter.Val.Value, NumberStyles.Any); - condition = o => (o as IComparable).CompareTo(xlFilter.Value) == 0; - } - - xlFilter.Condition = condition; - filterList.Add(xlFilter); - } - - foreach (var dateGroupItem in filterColumn.Filters.OfType()) - { - bool valid = true; - - if (!(dateGroupItem.DateTimeGrouping?.HasValue ?? false)) - continue; - - var xlDateGroupFilter = new XLFilter - { - Connector = XLConnector.Or, - Operator = XLFilterOperator.Equal, - DateTimeGrouping = dateGroupItem.DateTimeGrouping?.Value.ToClosedXml() ?? XLDateTimeGrouping.Year - }; - - int year = 1900; - int month = 1; - int day = 1; - int hour = 0; - int minute = 0; - int second = 0; - - if (xlDateGroupFilter.DateTimeGrouping >= XLDateTimeGrouping.Year) - { - if (dateGroupItem?.Year?.HasValue ?? false) - year = (int)dateGroupItem.Year?.Value; - else - valid &= false; - } - - if (xlDateGroupFilter.DateTimeGrouping >= XLDateTimeGrouping.Month) - { - if (dateGroupItem?.Month?.HasValue ?? false) - month = (int)dateGroupItem.Month?.Value; - else - valid &= false; - } - - if (xlDateGroupFilter.DateTimeGrouping >= XLDateTimeGrouping.Day) - { - if (dateGroupItem?.Day?.HasValue ?? false) - day = (int)dateGroupItem.Day?.Value; - else - valid &= false; - } - - if (xlDateGroupFilter.DateTimeGrouping >= XLDateTimeGrouping.Hour) - { - if (dateGroupItem?.Hour?.HasValue ?? false) - hour = (int)dateGroupItem.Hour?.Value; - else - valid &= false; - } - - if (xlDateGroupFilter.DateTimeGrouping >= XLDateTimeGrouping.Minute) - { - if (dateGroupItem?.Minute?.HasValue ?? false) - minute = (int)dateGroupItem.Minute?.Value; - else - valid &= false; - } - - if (xlDateGroupFilter.DateTimeGrouping >= XLDateTimeGrouping.Second) - { - if (dateGroupItem?.Second?.HasValue ?? false) - second = (int)dateGroupItem.Second?.Value; - else - valid &= false; - } - - var date = new DateTime(year, month, day, hour, minute, second); - xlDateGroupFilter.Value = date; - - xlDateGroupFilter.Condition = date2 => XLDateTimeGroupFilteredColumn.IsMatch(date, (DateTime)date2, xlDateGroupFilter.DateTimeGrouping); - - if (valid) - filterList.Add(xlDateGroupFilter); - } - } - else if (filterColumn.Top10 != null) - { - var xlFilterColumn = autoFilter.Column(column); - autoFilter.Filters.Add(column, null); - xlFilterColumn.FilterType = XLFilterType.TopBottom; - if (filterColumn.Top10.Percent != null && filterColumn.Top10.Percent.Value) - xlFilterColumn.TopBottomType = XLTopBottomType.Percent; - else - xlFilterColumn.TopBottomType = XLTopBottomType.Items; - - if (filterColumn.Top10.Top != null && !filterColumn.Top10.Top.Value) - xlFilterColumn.TopBottomPart = XLTopBottomPart.Bottom; - else - xlFilterColumn.TopBottomPart = XLTopBottomPart.Top; - - xlFilterColumn.TopBottomValue = (int)filterColumn.Top10.Val.Value; - } - else if (filterColumn.DynamicFilter != null) - { - autoFilter.Filters.Add(column, null); - var xlFilterColumn = autoFilter.Column(column); - xlFilterColumn.FilterType = XLFilterType.Dynamic; - if (filterColumn.DynamicFilter.Type != null) - xlFilterColumn.DynamicType = filterColumn.DynamicFilter.Type.Value.ToClosedXml(); - else - xlFilterColumn.DynamicType = XLFilterDynamicType.AboveAverage; - - xlFilterColumn.DynamicValue = filterColumn.DynamicFilter.Val.Value; - } - } - } - - private static void LoadAutoFilterSort(AutoFilter af, XLWorksheet ws, IXLAutoFilter autoFilter) - { - var sort = af.Elements().FirstOrDefault(); - if (sort != null) - { - var condition = sort.Elements().FirstOrDefault(); - if (condition != null) - { - Int32 column = ws.Range(condition.Reference.Value).FirstCell().Address.ColumnNumber - autoFilter.Range.FirstCell().Address.ColumnNumber + 1; - autoFilter.SortColumn = column; - autoFilter.Sorted = true; - autoFilter.SortOrder = condition.Descending != null && condition.Descending.Value ? XLSortOrder.Descending : XLSortOrder.Ascending; - } - } - } - - private static void LoadSheetProtection(SheetProtection sp, XLWorksheet ws) - { - if (sp == null) return; - - ws.Protection.IsProtected = OpenXmlHelper.GetBooleanValueAsBool(sp.Sheet, false); - - var algorithmName = sp.AlgorithmName?.Value ?? string.Empty; - if (String.IsNullOrEmpty(algorithmName)) - { - ws.Protection.PasswordHash = sp.Password?.Value ?? string.Empty; - ws.Protection.Base64EncodedSalt = string.Empty; - } - else if (DescribedEnumParser.IsValidDescription(algorithmName)) - { - ws.Protection.Algorithm = DescribedEnumParser.FromDescription(algorithmName); - ws.Protection.PasswordHash = sp.HashValue?.Value ?? string.Empty; - ws.Protection.SpinCount = sp.SpinCount?.Value ?? 0; - ws.Protection.Base64EncodedSalt = sp.SaltValue?.Value ?? string.Empty; - } - - ws.Protection.AllowElement(XLSheetProtectionElements.FormatCells, !OpenXmlHelper.GetBooleanValueAsBool(sp.FormatCells, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.FormatColumns, !OpenXmlHelper.GetBooleanValueAsBool(sp.FormatColumns, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.FormatRows, !OpenXmlHelper.GetBooleanValueAsBool(sp.FormatRows, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.InsertColumns, !OpenXmlHelper.GetBooleanValueAsBool(sp.InsertColumns, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.InsertHyperlinks, !OpenXmlHelper.GetBooleanValueAsBool(sp.InsertHyperlinks, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.InsertRows, !OpenXmlHelper.GetBooleanValueAsBool(sp.InsertRows, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.DeleteColumns, !OpenXmlHelper.GetBooleanValueAsBool(sp.DeleteColumns, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.DeleteRows, !OpenXmlHelper.GetBooleanValueAsBool(sp.DeleteRows, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.AutoFilter, !OpenXmlHelper.GetBooleanValueAsBool(sp.AutoFilter, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.PivotTables, !OpenXmlHelper.GetBooleanValueAsBool(sp.PivotTables, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.Sort, !OpenXmlHelper.GetBooleanValueAsBool(sp.Sort, true)); - ws.Protection.AllowElement(XLSheetProtectionElements.EditScenarios, !OpenXmlHelper.GetBooleanValueAsBool(sp.Scenarios, true)); - - ws.Protection.AllowElement(XLSheetProtectionElements.EditObjects, !OpenXmlHelper.GetBooleanValueAsBool(sp.Objects, false)); - ws.Protection.AllowElement(XLSheetProtectionElements.SelectLockedCells, !OpenXmlHelper.GetBooleanValueAsBool(sp.SelectLockedCells, false)); - ws.Protection.AllowElement(XLSheetProtectionElements.SelectUnlockedCells, !OpenXmlHelper.GetBooleanValueAsBool(sp.SelectUnlockedCells, false)); - } - - private static void LoadDataValidations(DataValidations dataValidations, XLWorksheet ws) - { - if (dataValidations == null) return; - - foreach (DataValidation dvs in dataValidations.Elements()) - { - String txt = dvs.SequenceOfReferences.InnerText; - if (String.IsNullOrWhiteSpace(txt)) continue; - foreach (var rangeAddress in txt.Split(' ')) - { - var dvt = new XLDataValidation(ws.Range(rangeAddress)); - ws.DataValidations.Add(dvt, skipIntersectionsCheck: true); - if (dvs.AllowBlank != null) dvt.IgnoreBlanks = dvs.AllowBlank; - if (dvs.ShowDropDown != null) dvt.InCellDropdown = !dvs.ShowDropDown.Value; - if (dvs.ShowErrorMessage != null) dvt.ShowErrorMessage = dvs.ShowErrorMessage; - if (dvs.ShowInputMessage != null) dvt.ShowInputMessage = dvs.ShowInputMessage; - if (dvs.PromptTitle != null) dvt.InputTitle = dvs.PromptTitle; - if (dvs.Prompt != null) dvt.InputMessage = dvs.Prompt; - if (dvs.ErrorTitle != null) dvt.ErrorTitle = dvs.ErrorTitle; - if (dvs.Error != null) dvt.ErrorMessage = dvs.Error; - if (dvs.ErrorStyle != null) dvt.ErrorStyle = dvs.ErrorStyle.Value.ToClosedXml(); - if (dvs.Type != null) dvt.AllowedValues = dvs.Type.Value.ToClosedXml(); - if (dvs.Operator != null) dvt.Operator = dvs.Operator.Value.ToClosedXml(); - if (dvs.Formula1 != null) dvt.MinValue = dvs.Formula1.Text; - if (dvs.Formula2 != null) dvt.MaxValue = dvs.Formula2.Text; - } - } - } - - /// - /// Loads the conditional formatting. - /// - // https://msdn.microsoft.com/en-us/library/documentformat.openxml.spreadsheet.conditionalformattingrule%28v=office.15%29.aspx?f=255&MSPPError=-2147217396 - private void LoadConditionalFormatting(ConditionalFormatting conditionalFormatting, XLWorksheet ws, - Dictionary differentialFormats) - { - if (conditionalFormatting == null) return; - - foreach (var fr in conditionalFormatting.Elements()) - { - var ranges = conditionalFormatting.SequenceOfReferences.Items - .Select(sor => ws.Range(sor.Value)); - var conditionalFormat = new XLConditionalFormat(ranges); - - conditionalFormat.StopIfTrue = OpenXmlHelper.GetBooleanValueAsBool(fr.StopIfTrue, false); - - if (fr.FormatId != null) - { - LoadFont(differentialFormats[(Int32)fr.FormatId.Value].Font, conditionalFormat.Style.Font); - LoadFill(differentialFormats[(Int32)fr.FormatId.Value].Fill, conditionalFormat.Style.Fill, - differentialFillFormat: true); - LoadBorder(differentialFormats[(Int32)fr.FormatId.Value].Border, conditionalFormat.Style.Border); - LoadNumberFormat(differentialFormats[(Int32)fr.FormatId.Value].NumberingFormat, - conditionalFormat.Style.NumberFormat); - } - - // The conditional formatting type is compulsory. If it doesn't exist, skip the entire rule. - if (fr.Type == null) continue; - conditionalFormat.ConditionalFormatType = fr.Type.Value.ToClosedXml(); - conditionalFormat.OriginalPriority = fr.Priority?.Value ?? Int32.MaxValue; - - if (conditionalFormat.ConditionalFormatType == XLConditionalFormatType.CellIs && fr.Operator != null) - conditionalFormat.Operator = fr.Operator.Value.ToClosedXml(); - - if (!String.IsNullOrWhiteSpace(fr.Text)) - conditionalFormat.Values.Add(GetFormula(fr.Text.Value)); - - if (conditionalFormat.ConditionalFormatType == XLConditionalFormatType.Top10) - { - if (fr.Percent != null) - conditionalFormat.Percent = fr.Percent.Value; - if (fr.Bottom != null) - conditionalFormat.Bottom = fr.Bottom.Value; - if (fr.Rank != null) - conditionalFormat.Values.Add(GetFormula(fr.Rank.Value.ToString())); - } - else if (conditionalFormat.ConditionalFormatType == XLConditionalFormatType.TimePeriod) - { - if (fr.TimePeriod != null) - conditionalFormat.TimePeriod = fr.TimePeriod.Value.ToClosedXml(); - else - conditionalFormat.TimePeriod = XLTimePeriod.Yesterday; - } - - if (fr.Elements().Any()) - { - var colorScale = fr.Elements().First(); - ExtractConditionalFormatValueObjects(conditionalFormat, colorScale); - } - else if (fr.Elements().Any()) - { - var dataBar = fr.Elements().First(); - if (dataBar.ShowValue != null) - conditionalFormat.ShowBarOnly = !dataBar.ShowValue.Value; - - var id = fr.Descendants().FirstOrDefault(); - if (id != null && id.Text != null && !String.IsNullOrWhiteSpace(id.Text)) - conditionalFormat.Id = new Guid(id.Text.Substring(1, id.Text.Length - 2)); - - ExtractConditionalFormatValueObjects(conditionalFormat, dataBar); - } - else if (fr.Elements().Any()) - { - var iconSet = fr.Elements().First(); - if (iconSet.ShowValue != null) - conditionalFormat.ShowIconOnly = !iconSet.ShowValue.Value; - if (iconSet.Reverse != null) - conditionalFormat.ReverseIconOrder = iconSet.Reverse.Value; - - if (iconSet.IconSetValue != null) - conditionalFormat.IconSetStyle = iconSet.IconSetValue.Value.ToClosedXml(); - else - conditionalFormat.IconSetStyle = XLIconSetStyle.ThreeTrafficLights1; - - ExtractConditionalFormatValueObjects(conditionalFormat, iconSet); - } - else - { - foreach (var formula in fr.Elements()) - { - if (formula.Text != null - && (conditionalFormat.ConditionalFormatType == XLConditionalFormatType.CellIs - || conditionalFormat.ConditionalFormatType == XLConditionalFormatType.Expression)) - { - conditionalFormat.Values.Add(GetFormula(formula.Text)); - } - } - } - - ws.ConditionalFormats.Add(conditionalFormat); - } - } - - private void LoadExtensions(WorksheetExtensionList extensions, XLWorksheet ws) - { - if (extensions == null) - { - return; - } - - foreach (var conditionalFormattingRule in extensions - .Descendants() - .Where(cf => - cf.Type != null - && cf.Type.HasValue - && cf.Type.Value == ConditionalFormatValues.DataBar)) - { - var xlConditionalFormat = ws.ConditionalFormats - .Cast() - .SingleOrDefault(cf => cf.Id.WrapInBraces() == conditionalFormattingRule.Id); - if (xlConditionalFormat != null) - { - var negativeFillColor = conditionalFormattingRule.Descendants().SingleOrDefault(); - xlConditionalFormat.Colors.Add(negativeFillColor.ToClosedXMLColor(_colorList)); - } - } - - foreach (var slg in extensions - .Descendants() - .SelectMany(sparklineGroups => sparklineGroups.Descendants())) - { - var xlSparklineGroup = (ws.SparklineGroups as XLSparklineGroups).Add(); - - if (slg.Formula != null) - xlSparklineGroup.DateRange = Range(slg.Formula.Text); - - var xlSparklineStyle = xlSparklineGroup.Style; - if (slg.FirstMarkerColor != null) xlSparklineStyle.FirstMarkerColor = slg.FirstMarkerColor.ToClosedXMLColor(); - if (slg.LastMarkerColor != null) xlSparklineStyle.LastMarkerColor = slg.LastMarkerColor.ToClosedXMLColor(); - if (slg.HighMarkerColor != null) xlSparklineStyle.HighMarkerColor = slg.HighMarkerColor.ToClosedXMLColor(); - if (slg.LowMarkerColor != null) xlSparklineStyle.LowMarkerColor = slg.LowMarkerColor.ToClosedXMLColor(); - if (slg.SeriesColor != null) xlSparklineStyle.SeriesColor = slg.SeriesColor.ToClosedXMLColor(); - if (slg.NegativeColor != null) xlSparklineStyle.NegativeColor = slg.NegativeColor.ToClosedXMLColor(); - if (slg.MarkersColor != null) xlSparklineStyle.MarkersColor = slg.MarkersColor.ToClosedXMLColor(); - xlSparklineGroup.Style = xlSparklineStyle; - - if (slg.DisplayHidden != null) xlSparklineGroup.DisplayHidden = slg.DisplayHidden; - if (slg.LineWeight != null) xlSparklineGroup.LineWeight = slg.LineWeight; - if (slg.Type != null) xlSparklineGroup.Type = slg.Type.Value.ToClosedXml(); - if (slg.DisplayEmptyCellsAs != null) xlSparklineGroup.DisplayEmptyCellsAs = slg.DisplayEmptyCellsAs.Value.ToClosedXml(); - - xlSparklineGroup.ShowMarkers = XLSparklineMarkers.None; - if (OpenXmlHelper.GetBooleanValueAsBool(slg.Markers, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.Markers; - if (OpenXmlHelper.GetBooleanValueAsBool(slg.High, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.HighPoint; - if (OpenXmlHelper.GetBooleanValueAsBool(slg.Low, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.LowPoint; - if (OpenXmlHelper.GetBooleanValueAsBool(slg.First, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.FirstPoint; - if (OpenXmlHelper.GetBooleanValueAsBool(slg.Last, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.LastPoint; - if (OpenXmlHelper.GetBooleanValueAsBool(slg.Negative, false)) xlSparklineGroup.ShowMarkers |= XLSparklineMarkers.NegativePoints; - - if (slg.AxisColor != null) xlSparklineGroup.HorizontalAxis.Color = XLColor.FromHtml(slg.AxisColor.Rgb.Value); - if (slg.DisplayXAxis != null) xlSparklineGroup.HorizontalAxis.IsVisible = slg.DisplayXAxis; - if (slg.RightToLeft != null) xlSparklineGroup.HorizontalAxis.RightToLeft = slg.RightToLeft; - - if (slg.ManualMax != null) xlSparklineGroup.VerticalAxis.ManualMax = slg.ManualMax; - if (slg.ManualMin != null) xlSparklineGroup.VerticalAxis.ManualMin = slg.ManualMin; - if (slg.MinAxisType != null) xlSparklineGroup.VerticalAxis.MinAxisType = slg.MinAxisType.Value.ToClosedXml(); - if (slg.MaxAxisType != null) xlSparklineGroup.VerticalAxis.MaxAxisType = slg.MaxAxisType.Value.ToClosedXml(); - - slg.Descendants().SelectMany(sls => sls.Descendants()) - .ForEach(sl => xlSparklineGroup.Add(sl.ReferenceSequence?.Text, sl.Formula?.Text)); - } } private static void LoadWorkbookProtection(WorkbookProtection wp, XLWorkbook wb) @@ -2757,274 +1156,6 @@ private static void LoadWorkbookProtection(WorkbookProtection wp, XLWorkbook wb) wb.Protection.AllowElement(XLWorkbookProtectionElements.Windows, !OpenXmlHelper.GetBooleanValueAsBool(wp.LockWindows, false)); } - private static XLFormula GetFormula(String value) - { - var formula = new XLFormula(); - formula._value = value; - formula.IsFormula = !(value[0] == '"' && value.EndsWith("\"")); - return formula; - } - - private void ExtractConditionalFormatValueObjects(XLConditionalFormat conditionalFormat, OpenXmlElement element) - { - foreach (var c in element.Elements()) - { - if (c.Type != null) - conditionalFormat.ContentTypes.Add(c.Type.Value.ToClosedXml()); - if (c.Val != null) - conditionalFormat.Values.Add(new XLFormula { Value = c.Val.Value }); - else - conditionalFormat.Values.Add(null); - - if (c.GreaterThanOrEqual != null) - conditionalFormat.IconSetOperators.Add(c.GreaterThanOrEqual.Value ? XLCFIconSetOperator.EqualOrGreaterThan : XLCFIconSetOperator.GreaterThan); - else - conditionalFormat.IconSetOperators.Add(XLCFIconSetOperator.EqualOrGreaterThan); - } - foreach (var c in element.Elements()) - { - conditionalFormat.Colors.Add(c.ToClosedXMLColor(_colorList)); - } - } - - private static void LoadHyperlinks(Hyperlinks hyperlinks, WorksheetPart worksheetPart, XLWorksheet ws) - { - var hyperlinkDictionary = new Dictionary(); - if (worksheetPart.HyperlinkRelationships != null) - hyperlinkDictionary = worksheetPart.HyperlinkRelationships.ToDictionary(hr => hr.Id, hr => hr.Uri); - - if (hyperlinks == null) return; - - foreach (Hyperlink hl in hyperlinks.Elements()) - { - if (hl.Reference.Value.Equals("#REF")) continue; - String tooltip = hl.Tooltip != null ? hl.Tooltip.Value : String.Empty; - var xlRange = ws.Range(hl.Reference.Value); - foreach (XLCell xlCell in xlRange.Cells()) - { - xlCell.SettingHyperlink = true; - - if (hl.Id != null) - xlCell.SetHyperlink(new XLHyperlink(hyperlinkDictionary[hl.Id], tooltip)); - else if (hl.Location != null) - xlCell.SetHyperlink(new XLHyperlink(hl.Location.Value, tooltip)); - else - xlCell.SetHyperlink(new XLHyperlink(hl.Reference.Value, tooltip)); - - xlCell.SettingHyperlink = false; - } - } - } - - private static void LoadColumnBreaks(ColumnBreaks columnBreaks, XLWorksheet ws) - { - if (columnBreaks == null) return; - - foreach (Break columnBreak in columnBreaks.Elements().Where(columnBreak => columnBreak.Id != null)) - { - ws.PageSetup.ColumnBreaks.Add(Int32.Parse(columnBreak.Id.InnerText)); - } - } - - private static void LoadRowBreaks(RowBreaks rowBreaks, XLWorksheet ws) - { - if (rowBreaks == null) return; - - foreach (Break rowBreak in rowBreaks.Elements()) - ws.PageSetup.RowBreaks.Add(Int32.Parse(rowBreak.Id.InnerText)); - } - - private void LoadSheetProperties(SheetProperties sheetProperty, XLWorksheet ws, out PageSetupProperties pageSetupProperties) - { - pageSetupProperties = null; - if (sheetProperty == null) return; - - if (sheetProperty.TabColor != null) - ws.TabColor = sheetProperty.TabColor.ToClosedXMLColor(_colorList); - - if (sheetProperty.OutlineProperties != null) - { - if (sheetProperty.OutlineProperties.SummaryBelow != null) - { - ws.Outline.SummaryVLocation = sheetProperty.OutlineProperties.SummaryBelow - ? XLOutlineSummaryVLocation.Bottom - : XLOutlineSummaryVLocation.Top; - } - - if (sheetProperty.OutlineProperties.SummaryRight != null) - { - ws.Outline.SummaryHLocation = sheetProperty.OutlineProperties.SummaryRight - ? XLOutlineSummaryHLocation.Right - : XLOutlineSummaryHLocation.Left; - } - } - - if (sheetProperty.PageSetupProperties != null) - pageSetupProperties = sheetProperty.PageSetupProperties; - } - - private static void LoadHeaderFooter(HeaderFooter headerFooter, XLWorksheet ws) - { - if (headerFooter == null) return; - - if (headerFooter.AlignWithMargins != null) - ws.PageSetup.AlignHFWithMargins = headerFooter.AlignWithMargins; - if (headerFooter.ScaleWithDoc != null) - ws.PageSetup.ScaleHFWithDocument = headerFooter.ScaleWithDoc; - - if (headerFooter.DifferentFirst != null) - ws.PageSetup.DifferentFirstPageOnHF = headerFooter.DifferentFirst; - if (headerFooter.DifferentOddEven != null) - ws.PageSetup.DifferentOddEvenPagesOnHF = headerFooter.DifferentOddEven; - - // Footers - var xlFooter = (XLHeaderFooter)ws.PageSetup.Footer; - var evenFooter = headerFooter.EvenFooter; - if (evenFooter != null) - xlFooter.SetInnerText(XLHFOccurrence.EvenPages, evenFooter.Text); - var oddFooter = headerFooter.OddFooter; - if (oddFooter != null) - xlFooter.SetInnerText(XLHFOccurrence.OddPages, oddFooter.Text); - var firstFooter = headerFooter.FirstFooter; - if (firstFooter != null) - xlFooter.SetInnerText(XLHFOccurrence.FirstPage, firstFooter.Text); - // Headers - var xlHeader = (XLHeaderFooter)ws.PageSetup.Header; - var evenHeader = headerFooter.EvenHeader; - if (evenHeader != null) - xlHeader.SetInnerText(XLHFOccurrence.EvenPages, evenHeader.Text); - var oddHeader = headerFooter.OddHeader; - if (oddHeader != null) - xlHeader.SetInnerText(XLHFOccurrence.OddPages, oddHeader.Text); - var firstHeader = headerFooter.FirstHeader; - if (firstHeader != null) - xlHeader.SetInnerText(XLHFOccurrence.FirstPage, firstHeader.Text); - - ((XLHeaderFooter)ws.PageSetup.Header).SetAsInitial(); - ((XLHeaderFooter)ws.PageSetup.Footer).SetAsInitial(); - } - - private static void LoadPageSetup(PageSetup pageSetup, XLWorksheet ws, PageSetupProperties pageSetupProperties) - { - if (pageSetup == null) return; - - if (pageSetup.PaperSize != null) - ws.PageSetup.PaperSize = (XLPaperSize)Int32.Parse(pageSetup.PaperSize.InnerText); - if (pageSetup.Scale != null) - ws.PageSetup.Scale = Int32.Parse(pageSetup.Scale.InnerText); - if (pageSetupProperties != null && pageSetupProperties.FitToPage != null && pageSetupProperties.FitToPage.Value) - { - if (pageSetup.FitToWidth == null) - ws.PageSetup.PagesWide = 1; - else - ws.PageSetup.PagesWide = Int32.Parse(pageSetup.FitToWidth.InnerText); - - if (pageSetup.FitToHeight == null) - ws.PageSetup.PagesTall = 1; - else - ws.PageSetup.PagesTall = Int32.Parse(pageSetup.FitToHeight.InnerText); - } - if (pageSetup.PageOrder != null) - ws.PageSetup.PageOrder = pageSetup.PageOrder.Value.ToClosedXml(); - if (pageSetup.Orientation != null) - ws.PageSetup.PageOrientation = pageSetup.Orientation.Value.ToClosedXml(); - if (pageSetup.BlackAndWhite != null) - ws.PageSetup.BlackAndWhite = pageSetup.BlackAndWhite; - if (pageSetup.Draft != null) - ws.PageSetup.DraftQuality = pageSetup.Draft; - if (pageSetup.CellComments != null) - ws.PageSetup.ShowComments = pageSetup.CellComments.Value.ToClosedXml(); - if (pageSetup.Errors != null) - ws.PageSetup.PrintErrorValue = pageSetup.Errors.Value.ToClosedXml(); - if (pageSetup.HorizontalDpi != null) ws.PageSetup.HorizontalDpi = (Int32)pageSetup.HorizontalDpi.Value; - if (pageSetup.VerticalDpi != null) ws.PageSetup.VerticalDpi = (Int32)pageSetup.VerticalDpi.Value; - if (pageSetup.FirstPageNumber?.HasValue ?? false) - ws.PageSetup.FirstPageNumber = pageSetup.FirstPageNumber.Value; - } - - private static void LoadPageMargins(PageMargins pageMargins, XLWorksheet ws) - { - if (pageMargins == null) return; - - if (pageMargins.Bottom != null) - ws.PageSetup.Margins.Bottom = pageMargins.Bottom; - if (pageMargins.Footer != null) - ws.PageSetup.Margins.Footer = pageMargins.Footer; - if (pageMargins.Header != null) - ws.PageSetup.Margins.Header = pageMargins.Header; - if (pageMargins.Left != null) - ws.PageSetup.Margins.Left = pageMargins.Left; - if (pageMargins.Right != null) - ws.PageSetup.Margins.Right = pageMargins.Right; - if (pageMargins.Top != null) - ws.PageSetup.Margins.Top = pageMargins.Top; - } - - private static void LoadPrintOptions(PrintOptions printOptions, XLWorksheet ws) - { - if (printOptions == null) return; - - if (printOptions.GridLines != null) - ws.PageSetup.ShowGridlines = printOptions.GridLines; - if (printOptions.HorizontalCentered != null) - ws.PageSetup.CenterHorizontally = printOptions.HorizontalCentered; - if (printOptions.VerticalCentered != null) - ws.PageSetup.CenterVertically = printOptions.VerticalCentered; - if (printOptions.Headings != null) - ws.PageSetup.ShowRowAndColumnHeadings = printOptions.Headings; - } - - private static void LoadSheetViews(SheetViews sheetViews, XLWorksheet ws) - { - if (sheetViews == null) return; - - var sheetView = sheetViews.Elements().FirstOrDefault(); - - if (sheetView == null) return; - - if (sheetView.RightToLeft != null) ws.RightToLeft = sheetView.RightToLeft.Value; - if (sheetView.ShowFormulas != null) ws.ShowFormulas = sheetView.ShowFormulas.Value; - if (sheetView.ShowGridLines != null) ws.ShowGridLines = sheetView.ShowGridLines.Value; - if (sheetView.ShowOutlineSymbols != null) - ws.ShowOutlineSymbols = sheetView.ShowOutlineSymbols.Value; - if (sheetView.ShowRowColHeaders != null) ws.ShowRowColHeaders = sheetView.ShowRowColHeaders.Value; - if (sheetView.ShowRuler != null) ws.ShowRuler = sheetView.ShowRuler.Value; - if (sheetView.ShowWhiteSpace != null) ws.ShowWhiteSpace = sheetView.ShowWhiteSpace.Value; - if (sheetView.ShowZeros != null) ws.ShowZeros = sheetView.ShowZeros.Value; - if (sheetView.TabSelected != null) ws.TabSelected = sheetView.TabSelected.Value; - - var selection = sheetView.Elements().FirstOrDefault(); - if (selection != null) - { - if (selection.SequenceOfReferences != null) - ws.Ranges(selection.SequenceOfReferences.InnerText.Replace(" ", ",")).Select(); - - if (selection.ActiveCell != null) - ws.Cell(selection.ActiveCell).SetActive(); - } - - if (sheetView.ZoomScale != null) - ws.SheetView.ZoomScale = (int)UInt32Value.ToUInt32(sheetView.ZoomScale); - if (sheetView.ZoomScaleNormal != null) - ws.SheetView.ZoomScaleNormal = (int)UInt32Value.ToUInt32(sheetView.ZoomScaleNormal); - if (sheetView.ZoomScalePageLayoutView != null) - ws.SheetView.ZoomScalePageLayoutView = (int)UInt32Value.ToUInt32(sheetView.ZoomScalePageLayoutView); - if (sheetView.ZoomScaleSheetLayoutView != null) - ws.SheetView.ZoomScaleSheetLayoutView = (int)UInt32Value.ToUInt32(sheetView.ZoomScaleSheetLayoutView); - - var pane = sheetView.Elements().FirstOrDefault(); - if (new[] { PaneStateValues.Frozen, PaneStateValues.FrozenSplit }.Contains(pane?.State?.Value ?? PaneStateValues.Split)) - { - if (pane.HorizontalSplit != null) - ws.SheetView.SplitColumn = (Int32)pane.HorizontalSplit.Value; - if (pane.VerticalSplit != null) - ws.SheetView.SplitRow = (Int32)pane.VerticalSplit.Value; - } - - if (XLHelper.IsValidA1Address(sheetView.TopLeftCell)) - ws.SheetView.TopLeftCellAddress = ws.Cell(sheetView.TopLeftCell.Value).Address; - } - private void SetProperties(SpreadsheetDocument dSpreadsheet) { var p = dSpreadsheet.PackageProperties; @@ -3042,33 +1173,24 @@ private void SetProperties(SpreadsheetDocument dSpreadsheet) Properties.Title = p.Title; } - private void ApplyStyle(IXLStylized xlStylized, Int32 styleIndex, Stylesheet s, Fills fills, Borders borders, - Fonts fonts, NumberingFormats numberingFormats) + internal static void LoadStyle(ref XLStyleKey xlStyle, Int32 styleIndex, Stylesheet s, Fills fills, Borders borders, + NumberingFormats numberingFormats, XLWorkbookStyles styles) { - if (s == null) return; //No Stylesheet, no Styles + if (s == null || s.CellFormats is null) return; //No Stylesheet, no Styles var cellFormat = (CellFormat)s.CellFormats.ElementAt(styleIndex); - var xlStyle = XLStyle.Default.Key; - - xlStyle.IncludeQuotePrefix = OpenXmlHelper.GetBooleanValueAsBool(cellFormat.QuotePrefix, false); + var xlIncludeQuotePrefix = OpenXmlHelper.GetBooleanValueAsBool(cellFormat.QuotePrefix, false); + xlStyle = xlStyle with { IncludeQuotePrefix = xlIncludeQuotePrefix }; if (cellFormat.ApplyProtection != null) { var protection = cellFormat.Protection; + var xlProtection = XLProtectionValue.Default.Key; + if (protection is not null) + xlProtection = OpenXmlHelper.ProtectionToClosedXml(protection, xlProtection); - if (protection == null) - xlStyle.Protection = XLProtectionValue.Default.Key; - else - { - xlStyle.Protection = new XLProtectionKey - { - Hidden = protection.Hidden != null && protection.Hidden.HasValue && - protection.Hidden.Value, - Locked = protection.Locked == null || - (protection.Locked.HasValue && protection.Locked.Value) - }; - } + xlStyle = xlStyle with { Protection = xlProtection }; } if (UInt32HasValue(cellFormat.FillId)) @@ -3076,146 +1198,33 @@ private void ApplyStyle(IXLStylized xlStylized, Int32 styleIndex, Stylesheet s, var fill = (Fill)fills.ElementAt((Int32)cellFormat.FillId.Value); if (fill.PatternFill != null) { - LoadFill(fill, xlStylized.InnerStyle.Fill, differentialFillFormat: false); + var xlFill = new XLFill(); + OpenXmlHelper.LoadFill(fill, xlFill, differentialFillFormat: false); + xlStyle = xlStyle with { Fill = xlFill.Key }; } - xlStyle.Fill = (xlStylized.InnerStyle as XLStyle).Value.Key.Fill; } var alignment = cellFormat.Alignment; if (alignment != null) { - var xlAlignment = xlStyle.Alignment; - if (alignment.Horizontal != null) - xlAlignment.Horizontal = alignment.Horizontal.Value.ToClosedXml(); - if (alignment.Indent != null && alignment.Indent != 0) - xlAlignment.Indent = Int32.Parse(alignment.Indent.ToString()); - if (alignment.JustifyLastLine != null) - xlAlignment.JustifyLastLine = alignment.JustifyLastLine; - if (alignment.ReadingOrder != null) - { - xlAlignment.ReadingOrder = - (XLAlignmentReadingOrderValues)Int32.Parse(alignment.ReadingOrder.ToString()); - } - if (alignment.RelativeIndent != null) - xlAlignment.RelativeIndent = alignment.RelativeIndent; - if (alignment.ShrinkToFit != null) - xlAlignment.ShrinkToFit = alignment.ShrinkToFit; - if (alignment.TextRotation != null) - xlAlignment.TextRotation = (Int32)alignment.TextRotation.Value; - if (alignment.Vertical != null) - xlAlignment.Vertical = alignment.Vertical.Value.ToClosedXml(); - if (alignment.WrapText != null) - xlAlignment.WrapText = alignment.WrapText; - - xlStyle.Alignment = xlAlignment; + var xlAlignment = OpenXmlHelper.AlignmentToClosedXml(alignment, xlStyle.Alignment); + xlStyle = xlStyle with { Alignment = xlAlignment }; } if (UInt32HasValue(cellFormat.BorderId)) { uint borderId = cellFormat.BorderId.Value; var border = (Border)borders.ElementAt((Int32)borderId); - var xlBorder = xlStyle.Border; - if (border != null) + if (border is not null) { - var bottomBorder = border.BottomBorder; - if (bottomBorder != null) - { - if (bottomBorder.Style != null) - xlBorder.BottomBorder = bottomBorder.Style.Value.ToClosedXml(); - - if (bottomBorder.Color != null) - xlBorder.BottomBorderColor = bottomBorder.Color.ToClosedXMLColor(_colorList).Key; - } - var topBorder = border.TopBorder; - if (topBorder != null) - { - if (topBorder.Style != null) - xlBorder.TopBorder = topBorder.Style.Value.ToClosedXml(); - if (topBorder.Color != null) - xlBorder.TopBorderColor = topBorder.Color.ToClosedXMLColor(_colorList).Key; - } - var leftBorder = border.LeftBorder; - if (leftBorder != null) - { - if (leftBorder.Style != null) - xlBorder.LeftBorder = leftBorder.Style.Value.ToClosedXml(); - if (leftBorder.Color != null) - xlBorder.LeftBorderColor = leftBorder.Color.ToClosedXMLColor(_colorList).Key; - } - var rightBorder = border.RightBorder; - if (rightBorder != null) - { - if (rightBorder.Style != null) - xlBorder.RightBorder = rightBorder.Style.Value.ToClosedXml(); - if (rightBorder.Color != null) - xlBorder.RightBorderColor = rightBorder.Color.ToClosedXMLColor(_colorList).Key; - } - var diagonalBorder = border.DiagonalBorder; - if (diagonalBorder != null) - { - if (diagonalBorder.Style != null) - xlBorder.DiagonalBorder = diagonalBorder.Style.Value.ToClosedXml(); - if (diagonalBorder.Color != null) - xlBorder.DiagonalBorderColor = diagonalBorder.Color.ToClosedXMLColor(_colorList).Key; - if (border.DiagonalDown != null) - xlBorder.DiagonalDown = border.DiagonalDown; - if (border.DiagonalUp != null) - xlBorder.DiagonalUp = border.DiagonalUp; - } - - xlStyle.Border = xlBorder; + var xlBorder = OpenXmlHelper.BorderToClosedXml(border, xlStyle.Border); + xlStyle = xlStyle with { Border = xlBorder }; } } - if (UInt32HasValue(cellFormat.FontId)) + if (cellFormat.FontId?.Value is { } fontId) { - var fontId = cellFormat.FontId; - var font = (DocumentFormat.OpenXml.Spreadsheet.Font)fonts.ElementAt((Int32)fontId.Value); - - var xlFont = xlStyle.Font; - if (font != null) - { - xlFont.Bold = GetBoolean(font.Bold); - - if (font.Color != null) - xlFont.FontColor = font.Color.ToClosedXMLColor(_colorList).Key; - - if (font.FontFamilyNumbering != null && (font.FontFamilyNumbering).Val != null) - { - xlFont.FontFamilyNumbering = - (XLFontFamilyNumberingValues)Int32.Parse((font.FontFamilyNumbering).Val.ToString()); - } - if (font.FontName != null) - { - if ((font.FontName).Val != null) - xlFont.FontName = (font.FontName).Val; - } - if (font.FontSize != null) - { - if ((font.FontSize).Val != null) - xlFont.FontSize = (font.FontSize).Val; - } - - xlFont.Italic = GetBoolean(font.Italic); - xlFont.Shadow = GetBoolean(font.Shadow); - xlFont.Strikethrough = GetBoolean(font.Strike); - - if (font.Underline != null) - { - xlFont.Underline = font.Underline.Val != null - ? (font.Underline).Val.Value.ToClosedXml() - : XLFontUnderlineValues.Single; - } - - if (font.VerticalTextAlignment != null) - { - xlFont.VerticalAlignment = font.VerticalTextAlignment.Val != null - ? (font.VerticalTextAlignment).Val.Value.ToClosedXml() - : XLFontVerticalTextAlignmentValues.Baseline; - } - - xlStyle.Font = xlFont; - } + xlStyle = styles.ApplyFontFormat((int)fontId, ref xlStyle); } if (UInt32HasValue(cellFormat.NumberFormatId)) @@ -3238,19 +1247,12 @@ private void ApplyStyle(IXLStylized xlStylized, Int32 styleIndex, Stylesheet s, var xlNumberFormat = xlStyle.NumberFormat; if (formatCode.Length > 0) { - xlNumberFormat.Format = formatCode; - xlNumberFormat.NumberFormatId = -1; + xlNumberFormat = XLNumberFormatKey.ForFormat(formatCode); } else - xlNumberFormat.NumberFormatId = (Int32)numberFormatId.Value; - xlStyle.NumberFormat = xlNumberFormat; + xlNumberFormat = xlNumberFormat with { NumberFormatId = (Int32)numberFormatId.Value }; + xlStyle = xlStyle with { NumberFormat = xlNumberFormat }; } - - //When loading columns we must propagate style to each column but not deeper. In other cases we do not propagate at all. - if (xlStylized is IXLColumns columns) - columns.Cast().ForEach(col => col.InnerStyle = new XLStyle(col, xlStyle)); - else - xlStylized.InnerStyle = new XLStyle(xlStylized, xlStyle); } private static Boolean UInt32HasValue(UInt32Value value) @@ -3258,16 +1260,10 @@ private static Boolean UInt32HasValue(UInt32Value value) return value != null && value.HasValue; } - private static Boolean GetBoolean(BooleanPropertyType property) + private static XmlTreeReader CreateTreeReader(OpenXmlPart openXmlPart) { - if (property != null) - { - if (property.Val != null) - return property.Val; - return true; - } - - return false; + var stream = openXmlPart.GetStream(FileMode.Open); + return new XmlTreeReader(stream, XmlToEnumMapper.Instance, false); } } } diff --git a/ClosedXML/Excel/XLWorkbook_Save.NestedTypes.cs b/ClosedXML/Excel/XLWorkbook_Save.NestedTypes.cs index 308ebe73e..ddca7d826 100644 --- a/ClosedXML/Excel/XLWorkbook_Save.NestedTypes.cs +++ b/ClosedXML/Excel/XLWorkbook_Save.NestedTypes.cs @@ -1,5 +1,9 @@ +#nullable disable + +using DocumentFormat.OpenXml.Packaging; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; namespace ClosedXML.Excel @@ -13,23 +17,66 @@ internal sealed class SaveContext public SaveContext() { DifferentialFormats = new Dictionary(); - PivotTables = new Dictionary(); RelIdGenerator = new RelIdGenerator(); SharedFonts = new Dictionary(); - SharedNumberFormats = new Dictionary(); + SharedNumberFormats = new Dictionary(); SharedStyles = new Dictionary(); TableId = 0; TableNames = new HashSet(); + PivotSourceCacheId = 0; } public Dictionary DifferentialFormats { get; private set; } - public IDictionary PivotTables { get; private set; } public RelIdGenerator RelIdGenerator { get; private set; } public Dictionary SharedFonts { get; private set; } - public Dictionary SharedNumberFormats { get; private set; } + public Dictionary SharedNumberFormats { get; private set; } public Dictionary SharedStyles { get; private set; } public uint TableId { get; set; } public HashSet TableNames { get; private set; } + + /// + /// A free id that can be used by the workbook to reference to a pivot cache. + /// The PivotCaches element in a workbook connects the parts with pivot + /// cache parts. + /// + public uint PivotSourceCacheId { get; set; } + + /// + /// A dictionary of extra info for pivot during saving. The key is . + /// + public IDictionary PivotSources { get; } + + /// + /// A map of shared string ids. The index is the actual index from sharedStringId and + /// value is an mapped stringId to write to a file. The mapped stringId has no gaps + /// between ids. + /// + public List SstMap { get; set; } + +#nullable enable + internal int GetSharedStringId(XLCell xlCell, string text) + { + var sharedStringId = SstMap[xlCell.MemorySstId]; + if (sharedStringId < 0) + { + throw new UnreachableException($"Unable to find text '{text}' in shared string table for cell {xlCell.SheetPoint}. " + + "That likely means reference counting is broken. As a stop-gap, try to set the " + + "text value to an unused cell to increase number of references for the text."); + } + + return sharedStringId; + } + + internal int? GetNumberFormat(XLNumberFormatValue? numberFormat) + { + if (numberFormat is null) + return null; + + return SharedNumberFormats.TryGetValue(numberFormat, out var customFormat) + ? customFormat.NumberFormatId + : numberFormat.NumberFormatId; + } + #nullable disable } #endregion Nested type: SaveContext @@ -47,38 +94,62 @@ internal enum RelType internal sealed class RelIdGenerator { - private readonly Dictionary> _relIds = new Dictionary>(); + private readonly Dictionary> _relIds = new(); public void AddValues(IEnumerable values, RelType relType) { - if (!_relIds.TryGetValue(relType, out List list)) + if (!_relIds.TryGetValue(relType, out var set)) { - list = new List(); - _relIds.Add(relType, list); + set = new HashSet(); + _relIds.Add(relType, set); } - list.AddRange(values.Where(v => !list.Contains(v))); + + set.UnionWith(values); } - public String GetNext() + /// + /// Add all existing rel ids present on the parts or workbook to the generator, so they are not duplicated again. + /// + public void AddExistingValues(WorkbookPart workbookPart, XLWorkbook xlWorkbook) { - return GetNext(RelType.Workbook); + AddValues(workbookPart.Parts.Select(p => p.RelationshipId), RelType.Workbook); + AddValues(xlWorkbook.WorksheetsInternal.Cast().Where(ws => !String.IsNullOrWhiteSpace(ws.RelId)).Select(ws => ws.RelId), RelType.Workbook); + AddValues(xlWorkbook.WorksheetsInternal.Cast().Where(ws => !String.IsNullOrWhiteSpace(ws.LegacyDrawingId)).Select(ws => ws.LegacyDrawingId), RelType.Workbook); + AddValues(xlWorkbook.WorksheetsInternal + .Cast() + .SelectMany(ws => ws.Tables.Cast()) + .Where(t => !String.IsNullOrWhiteSpace(t.RelId)) + .Select(t => t.RelId), RelType.Workbook); + + foreach (var xlWorksheet in xlWorkbook.WorksheetsInternal.Cast()) + { + // if the worksheet is a new one, it doesn't have RelId yet. + if (string.IsNullOrEmpty(xlWorksheet.RelId) || !workbookPart.TryGetPartById(xlWorksheet.RelId, out var part)) + continue; + + var worksheetPart = (WorksheetPart)part; + AddValues(worksheetPart.HyperlinkRelationships.Select(hr => hr.Id), RelType.Workbook); + AddValues(worksheetPart.Parts.Select(p => p.RelationshipId), RelType.Workbook); + if (worksheetPart.DrawingsPart != null) + AddValues(worksheetPart.DrawingsPart.Parts.Select(p => p.RelationshipId), RelType.Workbook); + } } public String GetNext(RelType relType) { - if (!_relIds.TryGetValue(relType, out List list)) + if (!_relIds.TryGetValue(relType, out var set)) { - list = new List(); - _relIds.Add(relType, list); + set = new HashSet(); + _relIds.Add(relType, set); } - Int32 id = list.Count + 1; + var id = set.Count + 1; while (true) { - String relId = String.Concat("rId", id); - if (!list.Contains(relId)) + var relId = String.Concat("rId", id); + if (!set.Contains(relId)) { - list.Add(relId); + set.Add(relId); return relId; } id++; @@ -153,16 +224,14 @@ internal struct StyleInfo internal struct PivotTableFieldInfo { - public XLDataType DataType; public Boolean MixedDataType; - public IEnumerable DistinctValues; + public IReadOnlyList DistinctValues; public Boolean IsTotallyBlankField; } - internal struct PivotTableInfo + internal struct PivotSourceInfo { public IDictionary Fields; - public Guid Guid; } #endregion Nested type: Pivot tables diff --git a/ClosedXML/Excel/XLWorkbook_Save.cs b/ClosedXML/Excel/XLWorkbook_Save.cs index b58187a08..d6439066f 100644 --- a/ClosedXML/Excel/XLWorkbook_Save.cs +++ b/ClosedXML/Excel/XLWorkbook_Save.cs @@ -1,17 +1,10 @@ -using ClosedXML.Excel.ContentManagers; -using ClosedXML.Excel.Exceptions; +#nullable disable + using ClosedXML.Extensions; -using ClosedXML.Utils; using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.CustomProperties; -using DocumentFormat.OpenXml.Drawing; -using DocumentFormat.OpenXml.ExtendedProperties; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; using DocumentFormat.OpenXml.Validation; -using DocumentFormat.OpenXml.VariantTypes; -using DocumentFormat.OpenXml.Vml.Office; -using DocumentFormat.OpenXml.Vml.Spreadsheet; using System; using System.Collections.Generic; using System.Globalization; @@ -21,84 +14,24 @@ using System.Threading; using System.Xml; using System.Xml.Linq; -using Anchor = DocumentFormat.OpenXml.Vml.Spreadsheet.Anchor; -using BackgroundColor = DocumentFormat.OpenXml.Spreadsheet.BackgroundColor; -using BottomBorder = DocumentFormat.OpenXml.Spreadsheet.BottomBorder; -using Break = DocumentFormat.OpenXml.Spreadsheet.Break; -using Field = DocumentFormat.OpenXml.Spreadsheet.Field; -using Fill = DocumentFormat.OpenXml.Spreadsheet.Fill; -using Fonts = DocumentFormat.OpenXml.Spreadsheet.Fonts; -using FontScheme = DocumentFormat.OpenXml.Drawing.FontScheme; -using ForegroundColor = DocumentFormat.OpenXml.Spreadsheet.ForegroundColor; -using GradientFill = DocumentFormat.OpenXml.Drawing.GradientFill; -using GradientStop = DocumentFormat.OpenXml.Drawing.GradientStop; -using Hyperlink = DocumentFormat.OpenXml.Spreadsheet.Hyperlink; -using LeftBorder = DocumentFormat.OpenXml.Spreadsheet.LeftBorder; -using OfficeExcel = DocumentFormat.OpenXml.Office.Excel; -using Outline = DocumentFormat.OpenXml.Drawing.Outline; using Path = System.IO.Path; -using PatternFill = DocumentFormat.OpenXml.Spreadsheet.PatternFill; -using Properties = DocumentFormat.OpenXml.ExtendedProperties.Properties; -using RightBorder = DocumentFormat.OpenXml.Spreadsheet.RightBorder; -using Run = DocumentFormat.OpenXml.Spreadsheet.Run; -using RunProperties = DocumentFormat.OpenXml.Spreadsheet.RunProperties; -using Table = DocumentFormat.OpenXml.Spreadsheet.Table; -using Text = DocumentFormat.OpenXml.Spreadsheet.Text; -using TopBorder = DocumentFormat.OpenXml.Spreadsheet.TopBorder; -using Underline = DocumentFormat.OpenXml.Spreadsheet.Underline; -using VerticalTextAlignment = DocumentFormat.OpenXml.Spreadsheet.VerticalTextAlignment; -using Vml = DocumentFormat.OpenXml.Vml; -using X14 = DocumentFormat.OpenXml.Office2010.Excel; -using Xdr = DocumentFormat.OpenXml.Drawing.Spreadsheet; +using ClosedXML.Excel.IO; +using System.Diagnostics; namespace ClosedXML.Excel { public partial class XLWorkbook { - private const Double ColumnWidthOffset = 0.710625; - - //private Dictionary context.SharedStyles; - - private static readonly EnumValue CvSharedString = new EnumValue(CellValues.SharedString); - private static readonly EnumValue CvInlineString = new EnumValue(CellValues.InlineString); - private static readonly EnumValue CvNumber = new EnumValue(CellValues.Number); - private static readonly EnumValue CvDate = new EnumValue(CellValues.Date); - private static readonly EnumValue CvBoolean = new EnumValue(CellValues.Boolean); - - private static EnumValue GetCellValueType(XLCell xlCell) - { - switch (xlCell.DataType) - { - case XLDataType.Text: - return xlCell.ShareString ? CvSharedString : CvInlineString; - - case XLDataType.Number: - return CvNumber; - - case XLDataType.DateTime: - return CvDate; - - case XLDataType.Boolean: - return CvBoolean; - - case XLDataType.TimeSpan: - return CvNumber; - - default: - throw new NotImplementedException(); - } - } - - private Boolean Validate(SpreadsheetDocument package) + private void Validate(SpreadsheetDocument package) { var backupCulture = Thread.CurrentThread.CurrentCulture; - IEnumerable errors; + IList errors; try { Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; var validator = new OpenXmlValidator(); - errors = validator.Validate(package); + errors = validator.Validate(package).ToArray(); } finally { @@ -110,7 +43,6 @@ private Boolean Validate(SpreadsheetDocument package) var message = string.Join("\r\n", errors.Select(e => string.Format("Part {0}, Path {1}: {2}", e.Part.Uri, e.Path.XPath, e.Description)).ToArray()); throw new ApplicationException(message); } - return true; } private void CreatePackage(String filePath, SpreadsheetDocumentType spreadsheetDocumentType, SaveOptions options) @@ -222,8 +154,6 @@ private void DeleteSheetAndDependencies(WorkbookPart wbPart, string sheetId) // Adds child parts and generates content of the specified part. private void CreateParts(SpreadsheetDocument document, SaveOptions options) { - this.SuspendEvents(); - var context = new SaveContext(); var workbookPart = document.WorkbookPart ?? document.AddWorkbookPart(); @@ -244,38 +174,30 @@ private void CreateParts(SpreadsheetDocument document, SaveOptions options) worksheets.Deleted.ToList().ForEach(ws => DeleteSheetAndDependencies(workbookPart, ws)); // Ensure all RelId's have been added to the context - context.RelIdGenerator.AddValues(workbookPart.Parts.Select(p => p.RelationshipId), RelType.Workbook); - context.RelIdGenerator.AddValues(WorksheetsInternal.Cast().Where(ws => !String.IsNullOrWhiteSpace(ws.RelId)).Select(ws => ws.RelId), RelType.Workbook); - context.RelIdGenerator.AddValues(WorksheetsInternal.Cast().Where(ws => !String.IsNullOrWhiteSpace(ws.LegacyDrawingId)).Select(ws => ws.LegacyDrawingId), RelType.Workbook); - context.RelIdGenerator.AddValues(WorksheetsInternal - .Cast() - .SelectMany(ws => ws.Tables.Cast()) - .Where(t => !String.IsNullOrWhiteSpace(t.RelId)) - .Select(t => t.RelId), RelType.Workbook); + context.RelIdGenerator.AddExistingValues(workbookPart, this); var extendedFilePropertiesPart = document.ExtendedFilePropertiesPart ?? document.AddNewPart( context.RelIdGenerator.GetNext(RelType.Workbook)); - GenerateExtendedFilePropertiesPartContent(extendedFilePropertiesPart); + ExtendedFilePropertiesPartWriter.GenerateContent(extendedFilePropertiesPart, this); - GenerateWorkbookPartContent(workbookPart, options, context); + WorkbookPartWriter.GenerateContent(workbookPart, this, options, context); var sharedStringTablePart = workbookPart.SharedStringTablePart ?? workbookPart.AddNewPart( context.RelIdGenerator.GetNext(RelType.Workbook)); - GenerateSharedStringTablePartContent(sharedStringTablePart, context); + SharedStringTableWriter.GenerateSharedStringTablePartContent(this, sharedStringTablePart, context); var workbookStylesPart = workbookPart.WorkbookStylesPart ?? workbookPart.AddNewPart( context.RelIdGenerator.GetNext(RelType.Workbook)); - GenerateWorkbookStylesPartContent(workbookStylesPart, context); + WorkbookStylesPartWriter.GenerateContent(workbookStylesPart, this, context); - var cacheRelIds = WorksheetsInternal - .Cast() - .SelectMany(s => s.PivotTables.Cast().Select(pt => pt.WorkbookCacheRelId)) + var cacheRelIds = PivotCachesInternal + .Select(ps => ps.WorkbookCacheRelId) .Where(relId => !string.IsNullOrWhiteSpace(relId)) .Distinct(); @@ -285,32 +207,53 @@ private void CreateParts(SpreadsheetDocument document, SaveOptions options) pivotTableCacheDefinitionPart.PivotCacheDefinition.CacheFields.RemoveAllChildren(); } + var allPivotTables = WorksheetsInternal.SelectMany(ws => ws.PivotTables).ToList(); + + // Phase 1 - Synchronize all pivot cache parts in the document, so each + // source that will be saved has all required parts created and relationship + // ids are set (in this case `Workbook.PivotCaches` relationship table). + // Only sources that are used by a table are saved. + SynchronizePivotTableParts(workbookPart, allPivotTables, context); + + // Phase 2 - All parts and relationships are set, fill in the parts. + if (allPivotTables.Any()) + { + GeneratePivotCaches(workbookPart, context); + } + foreach (var worksheet in WorksheetsInternal.Cast().OrderBy(w => w.Position)) { WorksheetPart worksheetPart; var wsRelId = worksheet.RelId; + bool partIsEmpty; if (workbookPart.Parts.Any(p => p.RelationshipId == wsRelId)) + { worksheetPart = (WorksheetPart)workbookPart.GetPartById(wsRelId); + partIsEmpty = false; + } else + { worksheetPart = workbookPart.AddNewPart(wsRelId); - - context.RelIdGenerator.AddValues(worksheetPart.HyperlinkRelationships.Select(hr => hr.Id), RelType.Workbook); - context.RelIdGenerator.AddValues(worksheetPart.Parts.Select(p => p.RelationshipId), RelType.Workbook); - if (worksheetPart.DrawingsPart != null) - context.RelIdGenerator.AddValues(worksheetPart.DrawingsPart.Parts.Select(p => p.RelationshipId), RelType.Workbook); + partIsEmpty = true; + } var worksheetHasComments = worksheet.Internals.CellsCollection.GetCells(c => c.HasComment).Any(); var commentsPart = worksheetPart.WorksheetCommentsPart; + + // VML part is the source of truth for shapes of comments, form controls and likely others. + // Excel won't display any shape without VML. The drawing part is always present, but is likely + // only different rendering of VML (more precisely the shapes behind VML). var vmlDrawingPart = worksheetPart.VmlDrawingParts.FirstOrDefault(); - var hasAnyVmlElements = DeleteExistingComments(worksheetPart, worksheet, commentsPart, vmlDrawingPart); + var hasAnyVmlElements = DeleteExistingCommentsShapes(vmlDrawingPart); if (worksheetHasComments) { + // If sheet has comments, we must keep VML in legacy drawing part to display them + // as well as comments part for semantic reasons. if (commentsPart == null) { commentsPart = worksheetPart.AddNewPart(context.RelIdGenerator.GetNext(RelType.Workbook)); - commentsPart.Comments = new Comments(); } if (vmlDrawingPart == null) @@ -318,51 +261,67 @@ private void CreateParts(SpreadsheetDocument document, SaveOptions options) if (String.IsNullOrWhiteSpace(worksheet.LegacyDrawingId)) { worksheet.LegacyDrawingId = context.RelIdGenerator.GetNext(RelType.Workbook); - worksheet.LegacyDrawingIsNew = true; } vmlDrawingPart = worksheetPart.AddNewPart(worksheet.LegacyDrawingId); } - GenerateWorksheetCommentsPartContent(commentsPart, worksheet); - hasAnyVmlElements = GenerateVmlDrawingPartContent(vmlDrawingPart, worksheet); + CommentPartWriter.GenerateWorksheetCommentsPartContent(commentsPart, worksheet); + hasAnyVmlElements = VmlDrawingPartWriter.GenerateContent(vmlDrawingPart, worksheet); + } + else + { + // There are no comments in the worksheet = the comment part is no longer needed, + // but VML part might contain other shapes, like form controls. + if (commentsPart is not null) + worksheetPart.DeletePart(commentsPart); } - // Remove empty parts - if (commentsPart != null && (commentsPart.RootElement?.ChildElements?.Count ?? 0) == 0) - worksheetPart.DeletePart(commentsPart); - - if (!hasAnyVmlElements && vmlDrawingPart != null) + if (!hasAnyVmlElements && vmlDrawingPart is not null) + { + worksheet.LegacyDrawingId = null; worksheetPart.DeletePart(vmlDrawingPart); + } - GenerateWorksheetPartContent(worksheetPart, worksheet, options, context); + var xlTables = worksheet.Tables; - if (worksheet.PivotTables.Any()) + // The way forward is to have 2-phase save, this is a start of that + // concept for tables: + // + // Phase 1 - synchronize part existence with tables xlWorksheet, so each + // table has a corresponding part and part that don't are deleted. + // This phase doesn't modify the content, it only ensures that RelIds are set + // corresponding parts exist and the parts that don't exist are removed + TablePartWriter.SynchronizeTableParts(xlTables, worksheetPart, context); + + // Phase 2 - At this point, all pieces must have corresponding parts + // The only way to link between parts is through RelIds that were already + // set in phase 1. The phase 2 is all about content of individual parts. + // Each part should have individual writer. + TablePartWriter.GenerateTableParts(xlTables, worksheetPart, context); + + WorksheetPartWriter.GenerateWorksheetPartContent(partIsEmpty, worksheetPart, worksheet, options, context); + + if (worksheet.PivotTables.Any()) { GeneratePivotTables(workbookPart, worksheetPart, worksheet, context); } - - // Remove any orphaned references - maybe more types? - foreach (var orphan in worksheetPart.Worksheet.OfType().Where(lg => worksheetPart.Parts.All(p => p.RelationshipId != lg.Id))) - worksheetPart.Worksheet.RemoveChild(orphan); - - foreach (var orphan in worksheetPart.Worksheet.OfType().Where(d => worksheetPart.Parts.All(p => p.RelationshipId != d.Id))) - worksheetPart.Worksheet.RemoveChild(orphan); } - // Remove empty pivot cache part - if (workbookPart.Workbook.PivotCaches != null && !workbookPart.Workbook.PivotCaches.Any()) - workbookPart.Workbook.RemoveChild(workbookPart.Workbook.PivotCaches); - if (options.GenerateCalculationChain) - GenerateCalculationChainPartContent(workbookPart, context); + { + CalculationChainPartWriter.GenerateContent(workbookPart, this, context); + } else - DeleteCalculationChainPartContent(workbookPart, context); + { + if (workbookPart.CalculationChainPart is not null) + workbookPart.DeletePart(workbookPart.CalculationChainPart); + } if (workbookPart.ThemePart == null) { var themePart = workbookPart.AddNewPart(context.RelIdGenerator.GetNext(RelType.Workbook)); - GenerateThemePartContent(themePart); + ThemePartWriter.GenerateContent(themePart, (XLTheme)Theme); } // Custom properties @@ -371,7 +330,7 @@ private void CreateParts(SpreadsheetDocument document, SaveOptions options) var customFilePropertiesPart = document.CustomFilePropertiesPart ?? document.AddNewPart(context.RelIdGenerator.GetNext(RelType.Workbook)); - GenerateCustomFilePropertiesPartContent(customFilePropertiesPart); + CustomFilePropertiesPartWriter.GenerateContent(customFilePropertiesPart, this); } else { @@ -382,18 +341,10 @@ private void CreateParts(SpreadsheetDocument document, SaveOptions options) // Clear list of deleted worksheets to prevent errors on multiple saves worksheets.Deleted.Clear(); - - this.ResumeEvents(); } - private bool DeleteExistingComments(WorksheetPart worksheetPart, XLWorksheet worksheet, WorksheetCommentsPart commentsPart, VmlDrawingPart vmlDrawingPart) + private bool DeleteExistingCommentsShapes(VmlDrawingPart vmlDrawingPart) { - // Nuke existing comments - if (commentsPart != null) - { - commentsPart.Comments = new Comments(); - } - if (vmlDrawingPart == null) return false; @@ -428,5974 +379,174 @@ private bool DeleteExistingComments(WorksheetPart worksheetPart, XLWorksheet wor } } - private static void GenerateTables(XLWorksheet worksheet, WorksheetPart worksheetPart, SaveContext context, XLWorksheetContentManager cm) + private void SetPackageProperties(OpenXmlPackage document) { - var tables = worksheet.Tables as XLTables; - - var emptyTable = tables.FirstOrDefault(t => t.DataRange == null); - if (emptyTable != null) - throw new EmptyTableException($"Table '{emptyTable.Name}' should have at least 1 row."); - - TableParts tableParts; - if (worksheetPart.Worksheet.Elements().Any()) - { - tableParts = worksheetPart.Worksheet.Elements().First(); - } - else - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.TableParts); - tableParts = new TableParts(); - worksheetPart.Worksheet.InsertAfter(tableParts, previousElement); - } - cm.SetElement(XLWorksheetContents.TableParts, tableParts); - - foreach (var deletedTableRelId in tables.Deleted) - { - if (worksheetPart.TableDefinitionParts != null) - { - var tableDefinitionPart = worksheetPart.GetPartById(deletedTableRelId); - worksheetPart.DeletePart(tableDefinitionPart); - - var tablePartsToRemove = tableParts.OfType().Where(tp => tp.Id?.Value == deletedTableRelId).ToList(); - tablePartsToRemove.ForEach(tp => tableParts.RemoveChild(tp)); - } - } - - tables.Deleted.Clear(); - - foreach (var xlTable in tables.Cast()) - { - if (String.IsNullOrEmpty(xlTable.RelId)) - xlTable.RelId = context.RelIdGenerator.GetNext(RelType.Workbook); + var created = Properties.Created == DateTime.MinValue ? DateTime.Now : Properties.Created; + var modified = Properties.Modified == DateTime.MinValue ? DateTime.Now : Properties.Modified; + document.PackageProperties.Created = created; + document.PackageProperties.Modified = modified; - var relId = xlTable.RelId; +#if true // Workaround: https://github.com/OfficeDev/Open-XML-SDK/issues/235 - TableDefinitionPart tableDefinitionPart; - if (worksheetPart.HasPartWithId(relId)) - tableDefinitionPart = worksheetPart.GetPartById(relId) as TableDefinitionPart; - else - tableDefinitionPart = worksheetPart.AddNewPart(relId); + if (Properties.LastModifiedBy == null) document.PackageProperties.LastModifiedBy = ""; + if (Properties.Author == null) document.PackageProperties.Creator = ""; + if (Properties.Title == null) document.PackageProperties.Title = ""; + if (Properties.Subject == null) document.PackageProperties.Subject = ""; + if (Properties.Category == null) document.PackageProperties.Category = ""; + if (Properties.Keywords == null) document.PackageProperties.Keywords = ""; + if (Properties.Comments == null) document.PackageProperties.Description = ""; + if (Properties.Status == null) document.PackageProperties.ContentStatus = ""; - GenerateTableDefinitionPartContent(tableDefinitionPart, xlTable, context); +#endif - if (!tableParts.OfType().Any(tp => tp.Id == xlTable.RelId)) - { - var tablePart = new TablePart { Id = xlTable.RelId }; - tableParts.AppendChild(tablePart); - } - } + document.PackageProperties.LastModifiedBy = Properties.LastModifiedBy; - tableParts.Count = (UInt32)tables.Count(); + document.PackageProperties.Creator = Properties.Author; + document.PackageProperties.Title = Properties.Title; + document.PackageProperties.Subject = Properties.Subject; + document.PackageProperties.Category = Properties.Category; + document.PackageProperties.Keywords = Properties.Keywords; + document.PackageProperties.Description = Properties.Comments; + document.PackageProperties.ContentStatus = Properties.Status; } - private void GenerateExtendedFilePropertiesPartContent(ExtendedFilePropertiesPart extendedFilePropertiesPart) + private static void SynchronizePivotTableParts(WorkbookPart workbookPart, IReadOnlyList allPivotTables, SaveContext context) { - if (extendedFilePropertiesPart.Properties == null) - extendedFilePropertiesPart.Properties = new Properties(); - - var properties = extendedFilePropertiesPart.Properties; - if ( - !properties.NamespaceDeclarations.Contains(new KeyValuePair("vt", - "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"))) - { - properties.AddNamespaceDeclaration("vt", - "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"); - } - - if (properties.Application == null) - properties.AppendChild(new Application { Text = "Microsoft Excel" }); - - if (properties.DocumentSecurity == null) - properties.AppendChild(new DocumentSecurity { Text = "0" }); - - if (properties.ScaleCrop == null) - properties.AppendChild(new ScaleCrop { Text = "false" }); - - if (properties.HeadingPairs == null) - properties.HeadingPairs = new HeadingPairs(); + RemoveUnusedPivotCacheDefinitionParts(workbookPart, allPivotTables); + AddUsedPivotCacheDefinitionParts(workbookPart, allPivotTables, context); - if (properties.TitlesOfParts == null) - properties.TitlesOfParts = new TitlesOfParts(); + // Ensure this in workbook.xml: + // + // + // - properties.HeadingPairs.VTVector = new VTVector { BaseType = VectorBaseValues.Variant }; - - properties.TitlesOfParts.VTVector = new VTVector { BaseType = VectorBaseValues.Lpstr }; - - var vTVectorOne = properties.HeadingPairs.VTVector; - - var vTVectorTwo = properties.TitlesOfParts.VTVector; - - var modifiedWorksheets = - ((IEnumerable)WorksheetsInternal).Select(w => new { w.Name, Order = w.Position }).ToList(); - var modifiedNamedRanges = GetModifiedNamedRanges(); - var modifiedWorksheetsCount = modifiedWorksheets.Count; - var modifiedNamedRangesCount = modifiedNamedRanges.Count; - - InsertOnVtVector(vTVectorOne, "Worksheets", 0, modifiedWorksheetsCount.ToInvariantString()); - InsertOnVtVector(vTVectorOne, "Named Ranges", 2, modifiedNamedRangesCount.ToInvariantString()); - - vTVectorTwo.Size = (UInt32)(modifiedNamedRangesCount + modifiedWorksheetsCount); - - foreach ( - var vTlpstr3 in modifiedWorksheets.OrderBy(w => w.Order).Select(w => new VTLPSTR { Text = w.Name })) - vTVectorTwo.AppendChild(vTlpstr3); - - foreach (var vTlpstr7 in modifiedNamedRanges.Select(nr => new VTLPSTR { Text = nr })) - vTVectorTwo.AppendChild(vTlpstr7); - - if (Properties.Manager != null) + context.PivotSourceCacheId = 0; + var xlUsedCaches = allPivotTables.Select(pt => pt.PivotCache).Distinct().Cast().ToList(); + if (xlUsedCaches.Any()) { - if (!String.IsNullOrWhiteSpace(Properties.Manager)) - { - if (properties.Manager == null) - properties.Manager = new Manager(); + // Recreate the workbook pivot cache references to remove previous gunk + var pivotCaches = new PivotCaches(); + workbookPart.Workbook.PivotCaches = pivotCaches; - properties.Manager.Text = Properties.Manager; + foreach (var source in xlUsedCaches) + { + var cacheId = context.PivotSourceCacheId++; + source.CacheId = cacheId; + var pivotCache = new PivotCache { CacheId = cacheId, Id = source.WorkbookCacheRelId }; + pivotCaches.AppendChild(pivotCache); } - else - properties.Manager = null; - } - - if (Properties.Company == null) return; - - if (!String.IsNullOrWhiteSpace(Properties.Company)) - { - if (properties.Company == null) - properties.Company = new Company(); - - properties.Company.Text = Properties.Company; } else - properties.Company = null; - } - - private static void InsertOnVtVector(VTVector vTVector, String property, Int32 index, String text) - { - var m = from e1 in vTVector.Elements() - where e1.Elements().Any(e2 => e2.Text == property) - select e1; - if (!m.Any()) - { - if (vTVector.Size == null) - vTVector.Size = new UInt32Value(0U); - - vTVector.Size += 2U; - var variant1 = new Variant(); - var vTlpstr1 = new VTLPSTR { Text = property }; - variant1.AppendChild(vTlpstr1); - vTVector.InsertAt(variant1, index); - - var variant2 = new Variant(); - var vTInt321 = new VTInt32(); - variant2.AppendChild(vTInt321); - vTVector.InsertAt(variant2, index + 1); - } - - var targetIndex = 0; - foreach (var e in vTVector.Elements()) { - if (e.Elements().Any(e2 => e2.Text == property)) + // Remove empty pivot cache part + if (workbookPart.Workbook.PivotCaches is not null) { - vTVector.ElementAt(targetIndex + 1).GetFirstChild().Text = text; - break; + workbookPart.Workbook.RemoveChild(workbookPart.Workbook.PivotCaches); } - targetIndex++; - } - } - - private List GetModifiedNamedRanges() - { - var namedRanges = new List(); - foreach (var w in WorksheetsInternal) - { - var wName = w.Name; - namedRanges.AddRange(w.NamedRanges.Select(n => wName + "!" + n.Name)); - namedRanges.Add(w.Name + "!Print_Area"); - namedRanges.Add(w.Name + "!Print_Titles"); - } - namedRanges.AddRange(NamedRanges.Select(n => n.Name)); - return namedRanges; - } - - private void GenerateWorkbookPartContent(WorkbookPart workbookPart, SaveOptions options, SaveContext context) - { - if (workbookPart.Workbook == null) - workbookPart.Workbook = new Workbook(); - - var workbook = workbookPart.Workbook; - if ( - !workbook.NamespaceDeclarations.Contains(new KeyValuePair("r", - "http://schemas.openxmlformats.org/officeDocument/2006/relationships"))) - { - workbook.AddNamespaceDeclaration("r", - "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); } - #region WorkbookProperties - - if (workbook.WorkbookProperties == null) - workbook.WorkbookProperties = new WorkbookProperties(); - - if (workbook.WorkbookProperties.CodeName == null) - workbook.WorkbookProperties.CodeName = "ThisWorkbook"; - - workbook.WorkbookProperties.Date1904 = OpenXmlHelper.GetBooleanValue(this.Use1904DateSystem, false); - - if (options.FilterPrivacy.HasValue) - workbook.WorkbookProperties.FilterPrivacy = OpenXmlHelper.GetBooleanValue(options.FilterPrivacy.Value, false); - - #endregion WorkbookProperties - - #region FileSharing - - if (workbook.FileSharing == null) - workbook.FileSharing = new FileSharing(); - - workbook.FileSharing.ReadOnlyRecommended = OpenXmlHelper.GetBooleanValue(this.FileSharing.ReadOnlyRecommended, false); - workbook.FileSharing.UserName = String.IsNullOrWhiteSpace(this.FileSharing.UserName) ? null : StringValue.FromString(this.FileSharing.UserName); - - if (!workbook.FileSharing.HasChildren && !workbook.FileSharing.HasAttributes) - workbook.FileSharing = null; - - #endregion FileSharing - - #region WorkbookProtection - - if (this.Protection.IsProtected) + // Remove pivot cache parts that are a part of the loaded document, but aren't used by a pivot table of the xlWorkbook + // part of the first phase of saving + static void RemoveUnusedPivotCacheDefinitionParts(WorkbookPart workbookPart, IReadOnlyList allPivotTables) { - if (workbook.WorkbookProtection == null) - workbook.WorkbookProtection = new WorkbookProtection(); - - var workbookProtection = workbook.WorkbookProtection; - - var protection = this.Protection; + var workbookCacheRelIds = allPivotTables + .Select(pt => pt.PivotCache.CastTo().WorkbookCacheRelId) + .Distinct() + .ToList(); - workbookProtection.WorkbookPassword = null; - workbookProtection.WorkbookAlgorithmName = null; - workbookProtection.WorkbookHashValue = null; - workbookProtection.WorkbookSpinCount = null; - workbookProtection.WorkbookSaltValue = null; + var orphanedParts = workbookPart + .GetPartsOfType() + .Where(pcdp => !workbookCacheRelIds.Contains(workbookPart.GetIdOfPart(pcdp))) + .ToList(); - if (protection.Algorithm == XLProtectionAlgorithm.Algorithm.SimpleHash) + foreach (var orphanPart in orphanedParts) { - if (!String.IsNullOrWhiteSpace(protection.PasswordHash)) - workbookProtection.WorkbookPassword = protection.PasswordHash; - } - else + orphanPart.DeletePart(orphanPart.PivotTableCacheRecordsPart); + workbookPart.DeletePart(orphanPart); + }; + + // Remove deleted pivot cache parts + if (workbookPart.Workbook.PivotCaches is not null) { - workbookProtection.WorkbookAlgorithmName = DescribedEnumParser.ToDescription(protection.Algorithm); - workbookProtection.WorkbookHashValue = protection.PasswordHash; - workbookProtection.WorkbookSpinCount = protection.SpinCount; - workbookProtection.WorkbookSaltValue = protection.Base64EncodedSalt; + workbookPart.Workbook.PivotCaches.Elements() + .Where(pc => !workbookPart.HasPartWithId(pc.Id)) + .ToList() + .ForEach(pc => pc.Remove()); } - - workbookProtection.LockStructure = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLWorkbookProtectionElements.Structure), false); - workbookProtection.LockWindows = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLWorkbookProtectionElements.Windows), false); - } - else - { - workbook.WorkbookProtection = null; - } - - #endregion WorkbookProtection - - if (workbook.BookViews == null) - workbook.BookViews = new BookViews(); - - if (workbook.Sheets == null) - workbook.Sheets = new Sheets(); - - var worksheets = WorksheetsInternal; - workbook.Sheets.Elements().Where(s => worksheets.Deleted.Contains(s.Id)).ToList().ForEach( - s => s.Remove()); - - foreach (var sheet in workbook.Sheets.Elements()) - { - var sheetId = (Int32)sheet.SheetId.Value; - - if (WorksheetsInternal.All(w => w.SheetId != sheetId)) continue; - - var wks = WorksheetsInternal.Single(w => w.SheetId == sheetId); - wks.RelId = sheet.Id; - sheet.Name = wks.Name; } - foreach (var xlSheet in WorksheetsInternal.Cast().OrderBy(w => w.Position)) + static void AddUsedPivotCacheDefinitionParts(WorkbookPart workbookPart, IReadOnlyList allPivotTables, SaveContext context) { - string rId; - if (xlSheet.SheetId == 0 && String.IsNullOrWhiteSpace(xlSheet.RelId)) - { - rId = context.RelIdGenerator.GetNext(RelType.Workbook); - - while (WorksheetsInternal.Cast().Any(w => w.SheetId == Int32.Parse(rId.Substring(3)))) - rId = context.RelIdGenerator.GetNext(RelType.Workbook); - - xlSheet.SheetId = Int32.Parse(rId.Substring(3)); - xlSheet.RelId = rId; - } - else - { - if (String.IsNullOrWhiteSpace(xlSheet.RelId)) - { - rId = String.Concat("rId", xlSheet.SheetId); - context.RelIdGenerator.AddValues(new List { rId }, RelType.Workbook); - } - else - rId = xlSheet.RelId; - } + // Add ids and part for the caches to workbooks + // We might get a XLPivotSource with an id of apart that isn't in the file (e.g. loaded from a file and saved to a different one). + var newPivotSources = allPivotTables + .Select(pt => pt.PivotCache.CastTo()) + .Where(ps => string.IsNullOrEmpty(ps.WorkbookCacheRelId) || !workbookPart.HasPartWithId(ps.WorkbookCacheRelId)) + .Distinct() + .ToList(); - if (workbook.Sheets.Cast().All(s => s.Id != rId)) + foreach (var pivotSource in newPivotSources) { - var newSheet = new Sheet - { - Name = xlSheet.Name, - Id = rId, - SheetId = (UInt32)xlSheet.SheetId - }; + var cacheRelId = context.RelIdGenerator.GetNext(RelType.Workbook); + pivotSource.WorkbookCacheRelId = cacheRelId; - workbook.Sheets.AppendChild(newSheet); + workbookPart.AddNewPart(pivotSource.WorkbookCacheRelId); } } + } - var sheetElements = from sheet in workbook.Sheets.Elements() - join worksheet in ((IEnumerable)WorksheetsInternal) on sheet.Id.Value - equals worksheet.RelId - orderby worksheet.Position - select sheet; - - UInt32 firstSheetVisible = 0; - var activeTab = - (from us in UnsupportedSheets where us.IsActive select (UInt32)us.Position - 1).FirstOrDefault(); - var foundVisible = false; + private void GeneratePivotCaches(WorkbookPart workbookPart, SaveContext context) + { + var pivotTables = WorksheetsInternal.SelectMany(ws => ws.PivotTables); - var totalSheets = sheetElements.Count() + UnsupportedSheets.Count; - for (var p = 1; p <= totalSheets; p++) + var xlPivotCaches = pivotTables.Select(pt => pt.PivotCache).Distinct(); + foreach (var xlPivotCache in xlPivotCaches) { - if (UnsupportedSheets.All(us => us.Position != p)) - { - var sheet = sheetElements.ElementAt(p - UnsupportedSheets.Count(us => us.Position <= p) - 1); - workbook.Sheets.RemoveChild(sheet); - workbook.Sheets.AppendChild(sheet); - var xlSheet = Worksheet(sheet.Name); - if (xlSheet.Visibility != XLWorksheetVisibility.Visible) - sheet.State = xlSheet.Visibility.ToOpenXml(); - else - sheet.State = null; - - if (foundVisible) continue; - - if (sheet.State == null || sheet.State == SheetStateValues.Visible) - foundVisible = true; - else - firstSheetVisible++; - } - else - { - var sheetId = UnsupportedSheets.First(us => us.Position == p).SheetId; - var sheet = workbook.Sheets.Elements().First(s => s.SheetId == sheetId); - workbook.Sheets.RemoveChild(sheet); - workbook.Sheets.AppendChild(sheet); - } - } - - var workbookView = workbook.BookViews.Elements().FirstOrDefault(); + Debug.Assert(workbookPart.Workbook.PivotCaches is not null); + Debug.Assert(!string.IsNullOrEmpty(xlPivotCache.WorkbookCacheRelId)); - if (activeTab == 0) - { - UInt32? firstActiveTab = null; - UInt32? firstSelectedTab = null; - foreach (var ws in worksheets) - { - if (ws.TabActive) - { - firstActiveTab = (UInt32)(ws.Position - 1); - break; - } + var pivotTableCacheDefinitionPart = (PivotTableCacheDefinitionPart)workbookPart.GetPartById(xlPivotCache.WorkbookCacheRelId); - if (ws.TabSelected) - { - firstSelectedTab = (UInt32)(ws.Position - 1); - } - } + PivotTableCacheDefinitionPartWriter.GenerateContent(pivotTableCacheDefinitionPart, xlPivotCache, context); - activeTab = firstActiveTab - ?? firstSelectedTab - ?? firstSheetVisible; - } + var pivotTableCacheRecordsPart = pivotTableCacheDefinitionPart.GetPartsOfType().Any() + ? pivotTableCacheDefinitionPart.GetPartsOfType().Single() + : pivotTableCacheDefinitionPart.AddNewPart("rId1"); - if (workbookView == null) - { - workbookView = new WorkbookView { ActiveTab = activeTab, FirstSheet = firstSheetVisible }; - workbook.BookViews.AppendChild(workbookView); - } - else - { - workbookView.ActiveTab = activeTab; - workbookView.FirstSheet = firstSheetVisible; + PivotCacheRecordsWriter.WriteContent(pivotTableCacheRecordsPart, xlPivotCache); } + } - var definedNames = new DefinedNames(); - foreach (var worksheet in WorksheetsInternal) + private static void GeneratePivotTables( + WorkbookPart workbookPart, + WorksheetPart worksheetPart, + XLWorksheet xlWorksheet, + SaveContext context) + { + foreach (var pt in xlWorksheet.PivotTables) { - var wsSheetId = (UInt32)worksheet.SheetId; - UInt32 sheetId = 0; - foreach (var s in workbook.Sheets.Elements().TakeWhile(s => s.SheetId != wsSheetId)) - { - sheetId++; - } - - if (worksheet.PageSetup.PrintAreas.Any()) - { - var definedName = new DefinedName { Name = "_xlnm.Print_Area", LocalSheetId = sheetId }; - var worksheetName = worksheet.Name; - var definedNameText = worksheet.PageSetup.PrintAreas.Aggregate(String.Empty, - (current, printArea) => - current + - (worksheetName.EscapeSheetName() + "!" + - printArea.RangeAddress. - FirstAddress.ToStringFixed( - XLReferenceStyle.A1) + - ":" + - printArea.RangeAddress. - LastAddress.ToStringFixed( - XLReferenceStyle.A1) + - ",")); - definedName.Text = definedNameText.Substring(0, definedNameText.Length - 1); - definedNames.AppendChild(definedName); - } - - if (worksheet.AutoFilter.IsEnabled) - { - var definedName = new DefinedName - { - Name = "_xlnm._FilterDatabase", - LocalSheetId = sheetId, - Text = worksheet.Name.EscapeSheetName() + "!" + - worksheet.AutoFilter.Range.RangeAddress.FirstAddress.ToStringFixed( - XLReferenceStyle.A1) + - ":" + - worksheet.AutoFilter.Range.RangeAddress.LastAddress.ToStringFixed( - XLReferenceStyle.A1), - Hidden = BooleanValue.FromBoolean(true) - }; - definedNames.AppendChild(definedName); - } - - foreach (var nr in worksheet.NamedRanges.Where(n => n.Name != "_xlnm._FilterDatabase")) - { - var definedName = new DefinedName - { - Name = nr.Name, - LocalSheetId = sheetId, - Text = nr.ToString() - }; - - if (!nr.Visible) - definedName.Hidden = BooleanValue.FromBoolean(true); - - if (!String.IsNullOrWhiteSpace(nr.Comment)) - definedName.Comment = nr.Comment; - definedNames.AppendChild(definedName); - } - - var definedNameTextRow = String.Empty; - var definedNameTextColumn = String.Empty; - if (worksheet.PageSetup.FirstRowToRepeatAtTop > 0) - { - definedNameTextRow = worksheet.Name.EscapeSheetName() + "!" + worksheet.PageSetup.FirstRowToRepeatAtTop - + ":" + worksheet.PageSetup.LastRowToRepeatAtTop; - } - if (worksheet.PageSetup.FirstColumnToRepeatAtLeft > 0) - { - var minColumn = worksheet.PageSetup.FirstColumnToRepeatAtLeft; - var maxColumn = worksheet.PageSetup.LastColumnToRepeatAtLeft; - definedNameTextColumn = worksheet.Name.EscapeSheetName() + "!" + - XLHelper.GetColumnLetterFromNumber(minColumn) - + ":" + XLHelper.GetColumnLetterFromNumber(maxColumn); - } - - string titles; - if (definedNameTextColumn.Length > 0) + PivotTablePart pivotTablePart; + var createNewPivotTablePart = String.IsNullOrWhiteSpace(pt.RelId); + if (createNewPivotTablePart) { - titles = definedNameTextColumn; - if (definedNameTextRow.Length > 0) - titles += "," + definedNameTextRow; + var relId = context.RelIdGenerator.GetNext(RelType.Workbook); + pt.RelId = relId; + pivotTablePart = worksheetPart.AddNewPart(relId); } else - titles = definedNameTextRow; - - if (titles.Length <= 0) continue; - - var definedName2 = new DefinedName - { - Name = "_xlnm.Print_Titles", - LocalSheetId = sheetId, - Text = titles - }; - - definedNames.AppendChild(definedName2); - } - - foreach (var nr in NamedRanges.OfType()) - { - var refersTo = string.Join(",", nr.RangeList - .Select(r => r.StartsWith("#REF!") ? "#REF!" : r)); + pivotTablePart = (PivotTablePart)worksheetPart.GetPartById(pt.RelId); - var definedName = new DefinedName + var pivotSource = pt.PivotCache; + var pivotTableCacheDefinitionPart = pivotTablePart.PivotTableCacheDefinitionPart; + if (!workbookPart.GetPartById(pivotSource.WorkbookCacheRelId).Equals(pivotTableCacheDefinitionPart)) { - Name = nr.Name, - Text = refersTo - }; - - if (!nr.Visible) - definedName.Hidden = BooleanValue.FromBoolean(true); + pivotTablePart.DeletePart(pivotTableCacheDefinitionPart); + pivotTablePart.CreateRelationshipToPart(workbookPart.GetPartById(pivotSource.WorkbookCacheRelId), context.RelIdGenerator.GetNext(XLWorkbook.RelType.Workbook)); + } - if (!String.IsNullOrWhiteSpace(nr.Comment)) - definedName.Comment = nr.Comment; - definedNames.AppendChild(definedName); + PivotTableDefinitionPartWriter2.WriteContent(pivotTablePart, pt, context); } - - workbook.DefinedNames = definedNames; - - if (workbook.CalculationProperties == null) - workbook.CalculationProperties = new CalculationProperties { CalculationId = 125725U }; - - if (CalculateMode == XLCalculateMode.Default) - workbook.CalculationProperties.CalculationMode = null; - else - workbook.CalculationProperties.CalculationMode = CalculateMode.ToOpenXml(); - - if (ReferenceStyle == XLReferenceStyle.Default) - workbook.CalculationProperties.ReferenceMode = null; - else - workbook.CalculationProperties.ReferenceMode = ReferenceStyle.ToOpenXml(); - - if (CalculationOnSave) workbook.CalculationProperties.CalculationOnSave = CalculationOnSave; - if (ForceFullCalculation) workbook.CalculationProperties.ForceFullCalculation = ForceFullCalculation; - if (FullCalculationOnLoad) workbook.CalculationProperties.FullCalculationOnLoad = FullCalculationOnLoad; - if (FullPrecision) workbook.CalculationProperties.FullPrecision = FullPrecision; - } - - private void GenerateSharedStringTablePartContent(SharedStringTablePart sharedStringTablePart, - SaveContext context) - { - // Call all table headers to make sure their names are filled - var x = 0; - Worksheets.ForEach(w => w.Tables.ForEach(t => x = (t as XLTable).FieldNames.Count)); - - sharedStringTablePart.SharedStringTable = new SharedStringTable { Count = 0, UniqueCount = 0 }; - - var stringId = 0; - - var newStrings = new Dictionary(); - var newRichStrings = new Dictionary(); - - bool hasSharedString(IXLCell c) - { - if (c.DataType == XLDataType.Text && c.ShareString) - return (c as XLCell).StyleValue.IncludeQuotePrefix || String.IsNullOrWhiteSpace(c.FormulaA1) && (c as XLCell).InnerText.Length > 0; - else - return false; - } - - foreach (var c in Worksheets.Cast().SelectMany(w => w.Internals.CellsCollection.GetCells(hasSharedString))) - { - c.DataType = XLDataType.Text; - if (c.HasRichText) - { - if (newRichStrings.TryGetValue(c.GetRichText(), out int id)) - c.SharedStringId = id; - else - { - var sharedStringItem = new SharedStringItem(); - PopulatedRichTextElements(sharedStringItem, c, context); - - sharedStringTablePart.SharedStringTable.Append(sharedStringItem); - sharedStringTablePart.SharedStringTable.Count += 1; - sharedStringTablePart.SharedStringTable.UniqueCount += 1; - - newRichStrings.Add(c.GetRichText(), stringId); - c.SharedStringId = stringId; - - stringId++; - } - } - else - { - var value = c.Value.ObjectToInvariantString(); - if (newStrings.TryGetValue(value, out int id)) - c.SharedStringId = id; - else - { - var s = value; - var sharedStringItem = new SharedStringItem(); - var text = new Text { Text = XmlEncoder.EncodeString(s) }; - if (!s.Trim().Equals(s)) - text.Space = SpaceProcessingModeValues.Preserve; - sharedStringItem.Append(text); - sharedStringTablePart.SharedStringTable.Append(sharedStringItem); - sharedStringTablePart.SharedStringTable.Count += 1; - sharedStringTablePart.SharedStringTable.UniqueCount += 1; - - newStrings.Add(value, stringId); - c.SharedStringId = stringId; - - stringId++; - } - } - } - } - - private static void PopulatedRichTextElements(RstType rstType, IXLCell cell, SaveContext context) - { - var richText = cell.GetRichText(); - foreach (var rt in richText.Where(r => !String.IsNullOrEmpty(r.Text))) - { - rstType.Append(GetRun(rt)); - } - - if (richText.HasPhonetics) - { - foreach (var p in richText.Phonetics) - { - var phoneticRun = new PhoneticRun - { - BaseTextStartIndex = (UInt32)p.Start, - EndingBaseIndex = (UInt32)p.End - }; - - var text = new Text { Text = p.Text }; - if (p.Text.PreserveSpaces()) - text.Space = SpaceProcessingModeValues.Preserve; - - phoneticRun.Append(text); - rstType.Append(phoneticRun); - } - - var fontKey = XLFont.GenerateKey(richText.Phonetics); - var f = XLFontValue.FromKey(ref fontKey); - - if (!context.SharedFonts.TryGetValue(f, out FontInfo fi)) - { - fi = new FontInfo { Font = f }; - context.SharedFonts.Add(f, fi); - } - - var phoneticProperties = new PhoneticProperties - { - FontId = fi.FontId - }; - - if (richText.Phonetics.Alignment != XLPhoneticAlignment.Left) - phoneticProperties.Alignment = richText.Phonetics.Alignment.ToOpenXml(); - - if (richText.Phonetics.Type != XLPhoneticType.FullWidthKatakana) - phoneticProperties.Type = richText.Phonetics.Type.ToOpenXml(); - - rstType.Append(phoneticProperties); - } - } - - private static Run GetRun(IXLRichString rt) - { - var run = new Run(); - - var runProperties = new RunProperties(); - - var bold = rt.Bold ? new Bold() : null; - var italic = rt.Italic ? new Italic() : null; - var underline = rt.Underline != XLFontUnderlineValues.None - ? new Underline { Val = rt.Underline.ToOpenXml() } - : null; - var strike = rt.Strikethrough ? new Strike() : null; - var verticalAlignment = new VerticalTextAlignment - { Val = rt.VerticalAlignment.ToOpenXml() }; - var shadow = rt.Shadow ? new Shadow() : null; - var fontSize = new FontSize { Val = rt.FontSize }; - var color = new Color().FromClosedXMLColor(rt.FontColor); - var fontName = new RunFont { Val = rt.FontName }; - var fontFamilyNumbering = new FontFamily { Val = (Int32)rt.FontFamilyNumbering }; - - if (bold != null) runProperties.Append(bold); - if (italic != null) runProperties.Append(italic); - - if (strike != null) runProperties.Append(strike); - if (shadow != null) runProperties.Append(shadow); - if (underline != null) runProperties.Append(underline); - runProperties.Append(verticalAlignment); - - runProperties.Append(fontSize); - runProperties.Append(color); - runProperties.Append(fontName); - runProperties.Append(fontFamilyNumbering); - - var text = new Text { Text = rt.Text }; - if (rt.Text.PreserveSpaces()) - text.Space = SpaceProcessingModeValues.Preserve; - - run.Append(runProperties); - run.Append(text); - return run; - } - - private void DeleteCalculationChainPartContent(WorkbookPart workbookPart, SaveContext context) - { - if (workbookPart.CalculationChainPart != null) - workbookPart.DeletePart(workbookPart.CalculationChainPart); - } - - private void GenerateCalculationChainPartContent(WorkbookPart workbookPart, SaveContext context) - { - if (workbookPart.CalculationChainPart == null) - workbookPart.AddNewPart(context.RelIdGenerator.GetNext(RelType.Workbook)); - - if (workbookPart.CalculationChainPart.CalculationChain == null) - workbookPart.CalculationChainPart.CalculationChain = new CalculationChain(); - - var calculationChain = workbookPart.CalculationChainPart.CalculationChain; - calculationChain.RemoveAllChildren(); - - foreach (var worksheet in WorksheetsInternal) - { - foreach (var c in worksheet.Internals.CellsCollection.GetCells().Where(c => c.HasFormula)) - { - if (c.HasArrayFormula) - { - if (c.FormulaReference == null) - c.FormulaReference = c.AsRange().RangeAddress; - - if (c.FormulaReference.FirstAddress.Equals(c.Address)) - { - var cc = new CalculationCell - { - CellReference = c.Address.ToString(), - SheetId = worksheet.SheetId - }; - - cc.Array = true; - calculationChain.AppendChild(cc); - - foreach (var childCell in worksheet.Range(c.FormulaReference.ToString()).Cells()) - { - calculationChain.AppendChild( - new CalculationCell - { - CellReference = childCell.Address.ToString(), - SheetId = worksheet.SheetId, - InChildChain = true - } - ); - } - } - } - else - { - calculationChain.AppendChild(new CalculationCell - { - CellReference = c.Address.ToString(), - SheetId = worksheet.SheetId - }); - } - } - } - - if (!calculationChain.Any()) - workbookPart.DeletePart(workbookPart.CalculationChainPart); - } - - private void GenerateThemePartContent(ThemePart themePart) - { - var theme1 = new Theme { Name = "Office Theme" }; - theme1.AddNamespaceDeclaration("a", "http://schemas.openxmlformats.org/drawingml/2006/main"); - - var themeElements1 = new ThemeElements(); - - var colorScheme1 = new ColorScheme { Name = "Office" }; - - var dark1Color1 = new Dark1Color(); - var systemColor1 = new SystemColor - { - Val = SystemColorValues.WindowText, - LastColor = Theme.Text1.Color.ToHex().Substring(2) - }; - - dark1Color1.AppendChild(systemColor1); - - var light1Color1 = new Light1Color(); - var systemColor2 = new SystemColor - { - Val = SystemColorValues.Window, - LastColor = Theme.Background1.Color.ToHex().Substring(2) - }; - - light1Color1.AppendChild(systemColor2); - - var dark2Color1 = new Dark2Color(); - var rgbColorModelHex1 = new RgbColorModelHex { Val = Theme.Text2.Color.ToHex().Substring(2) }; - - dark2Color1.AppendChild(rgbColorModelHex1); - - var light2Color1 = new Light2Color(); - var rgbColorModelHex2 = new RgbColorModelHex { Val = Theme.Background2.Color.ToHex().Substring(2) }; - - light2Color1.AppendChild(rgbColorModelHex2); - - var accent1Color1 = new Accent1Color(); - var rgbColorModelHex3 = new RgbColorModelHex { Val = Theme.Accent1.Color.ToHex().Substring(2) }; - - accent1Color1.AppendChild(rgbColorModelHex3); - - var accent2Color1 = new Accent2Color(); - var rgbColorModelHex4 = new RgbColorModelHex { Val = Theme.Accent2.Color.ToHex().Substring(2) }; - - accent2Color1.AppendChild(rgbColorModelHex4); - - var accent3Color1 = new Accent3Color(); - var rgbColorModelHex5 = new RgbColorModelHex { Val = Theme.Accent3.Color.ToHex().Substring(2) }; - - accent3Color1.AppendChild(rgbColorModelHex5); - - var accent4Color1 = new Accent4Color(); - var rgbColorModelHex6 = new RgbColorModelHex { Val = Theme.Accent4.Color.ToHex().Substring(2) }; - - accent4Color1.AppendChild(rgbColorModelHex6); - - var accent5Color1 = new Accent5Color(); - var rgbColorModelHex7 = new RgbColorModelHex { Val = Theme.Accent5.Color.ToHex().Substring(2) }; - - accent5Color1.AppendChild(rgbColorModelHex7); - - var accent6Color1 = new Accent6Color(); - var rgbColorModelHex8 = new RgbColorModelHex { Val = Theme.Accent6.Color.ToHex().Substring(2) }; - - accent6Color1.AppendChild(rgbColorModelHex8); - - var hyperlink1 = new DocumentFormat.OpenXml.Drawing.Hyperlink(); - var rgbColorModelHex9 = new RgbColorModelHex { Val = Theme.Hyperlink.Color.ToHex().Substring(2) }; - - hyperlink1.AppendChild(rgbColorModelHex9); - - var followedHyperlinkColor1 = new FollowedHyperlinkColor(); - var rgbColorModelHex10 = new RgbColorModelHex { Val = Theme.FollowedHyperlink.Color.ToHex().Substring(2) }; - - followedHyperlinkColor1.AppendChild(rgbColorModelHex10); - - colorScheme1.AppendChild(dark1Color1); - colorScheme1.AppendChild(light1Color1); - colorScheme1.AppendChild(dark2Color1); - colorScheme1.AppendChild(light2Color1); - colorScheme1.AppendChild(accent1Color1); - colorScheme1.AppendChild(accent2Color1); - colorScheme1.AppendChild(accent3Color1); - colorScheme1.AppendChild(accent4Color1); - colorScheme1.AppendChild(accent5Color1); - colorScheme1.AppendChild(accent6Color1); - colorScheme1.AppendChild(hyperlink1); - colorScheme1.AppendChild(followedHyperlinkColor1); - - var fontScheme2 = new FontScheme { Name = "Office" }; - - var majorFont1 = new MajorFont(); - var latinFont1 = new LatinFont { Typeface = "Cambria" }; - var eastAsianFont1 = new EastAsianFont { Typeface = "" }; - var complexScriptFont1 = new ComplexScriptFont { Typeface = "" }; - var supplementalFont1 = new SupplementalFont { Script = "Jpan", Typeface = "MS Pゴシック" }; - var supplementalFont2 = new SupplementalFont { Script = "Hang", Typeface = "맑은 고딕" }; - var supplementalFont3 = new SupplementalFont { Script = "Hans", Typeface = "宋体" }; - var supplementalFont4 = new SupplementalFont { Script = "Hant", Typeface = "新細明體" }; - var supplementalFont5 = new SupplementalFont { Script = "Arab", Typeface = "Times New Roman" }; - var supplementalFont6 = new SupplementalFont { Script = "Hebr", Typeface = "Times New Roman" }; - var supplementalFont7 = new SupplementalFont { Script = "Thai", Typeface = "Tahoma" }; - var supplementalFont8 = new SupplementalFont { Script = "Ethi", Typeface = "Nyala" }; - var supplementalFont9 = new SupplementalFont { Script = "Beng", Typeface = "Vrinda" }; - var supplementalFont10 = new SupplementalFont { Script = "Gujr", Typeface = "Shruti" }; - var supplementalFont11 = new SupplementalFont { Script = "Khmr", Typeface = "MoolBoran" }; - var supplementalFont12 = new SupplementalFont { Script = "Knda", Typeface = "Tunga" }; - var supplementalFont13 = new SupplementalFont { Script = "Guru", Typeface = "Raavi" }; - var supplementalFont14 = new SupplementalFont { Script = "Cans", Typeface = "Euphemia" }; - var supplementalFont15 = new SupplementalFont { Script = "Cher", Typeface = "Plantagenet Cherokee" }; - var supplementalFont16 = new SupplementalFont { Script = "Yiii", Typeface = "Microsoft Yi Baiti" }; - var supplementalFont17 = new SupplementalFont { Script = "Tibt", Typeface = "Microsoft Himalaya" }; - var supplementalFont18 = new SupplementalFont { Script = "Thaa", Typeface = "MV Boli" }; - var supplementalFont19 = new SupplementalFont { Script = "Deva", Typeface = "Mangal" }; - var supplementalFont20 = new SupplementalFont { Script = "Telu", Typeface = "Gautami" }; - var supplementalFont21 = new SupplementalFont { Script = "Taml", Typeface = "Latha" }; - var supplementalFont22 = new SupplementalFont { Script = "Syrc", Typeface = "Estrangelo Edessa" }; - var supplementalFont23 = new SupplementalFont { Script = "Orya", Typeface = "Kalinga" }; - var supplementalFont24 = new SupplementalFont { Script = "Mlym", Typeface = "Kartika" }; - var supplementalFont25 = new SupplementalFont { Script = "Laoo", Typeface = "DokChampa" }; - var supplementalFont26 = new SupplementalFont { Script = "Sinh", Typeface = "Iskoola Pota" }; - var supplementalFont27 = new SupplementalFont { Script = "Mong", Typeface = "Mongolian Baiti" }; - var supplementalFont28 = new SupplementalFont { Script = "Viet", Typeface = "Times New Roman" }; - var supplementalFont29 = new SupplementalFont { Script = "Uigh", Typeface = "Microsoft Uighur" }; - - majorFont1.AppendChild(latinFont1); - majorFont1.AppendChild(eastAsianFont1); - majorFont1.AppendChild(complexScriptFont1); - majorFont1.AppendChild(supplementalFont1); - majorFont1.AppendChild(supplementalFont2); - majorFont1.AppendChild(supplementalFont3); - majorFont1.AppendChild(supplementalFont4); - majorFont1.AppendChild(supplementalFont5); - majorFont1.AppendChild(supplementalFont6); - majorFont1.AppendChild(supplementalFont7); - majorFont1.AppendChild(supplementalFont8); - majorFont1.AppendChild(supplementalFont9); - majorFont1.AppendChild(supplementalFont10); - majorFont1.AppendChild(supplementalFont11); - majorFont1.AppendChild(supplementalFont12); - majorFont1.AppendChild(supplementalFont13); - majorFont1.AppendChild(supplementalFont14); - majorFont1.AppendChild(supplementalFont15); - majorFont1.AppendChild(supplementalFont16); - majorFont1.AppendChild(supplementalFont17); - majorFont1.AppendChild(supplementalFont18); - majorFont1.AppendChild(supplementalFont19); - majorFont1.AppendChild(supplementalFont20); - majorFont1.AppendChild(supplementalFont21); - majorFont1.AppendChild(supplementalFont22); - majorFont1.AppendChild(supplementalFont23); - majorFont1.AppendChild(supplementalFont24); - majorFont1.AppendChild(supplementalFont25); - majorFont1.AppendChild(supplementalFont26); - majorFont1.AppendChild(supplementalFont27); - majorFont1.AppendChild(supplementalFont28); - majorFont1.AppendChild(supplementalFont29); - - var minorFont1 = new MinorFont(); - var latinFont2 = new LatinFont { Typeface = "Calibri" }; - var eastAsianFont2 = new EastAsianFont { Typeface = "" }; - var complexScriptFont2 = new ComplexScriptFont { Typeface = "" }; - var supplementalFont30 = new SupplementalFont { Script = "Jpan", Typeface = "MS Pゴシック" }; - var supplementalFont31 = new SupplementalFont { Script = "Hang", Typeface = "맑은 고딕" }; - var supplementalFont32 = new SupplementalFont { Script = "Hans", Typeface = "宋体" }; - var supplementalFont33 = new SupplementalFont { Script = "Hant", Typeface = "新細明體" }; - var supplementalFont34 = new SupplementalFont { Script = "Arab", Typeface = "Arial" }; - var supplementalFont35 = new SupplementalFont { Script = "Hebr", Typeface = "Arial" }; - var supplementalFont36 = new SupplementalFont { Script = "Thai", Typeface = "Tahoma" }; - var supplementalFont37 = new SupplementalFont { Script = "Ethi", Typeface = "Nyala" }; - var supplementalFont38 = new SupplementalFont { Script = "Beng", Typeface = "Vrinda" }; - var supplementalFont39 = new SupplementalFont { Script = "Gujr", Typeface = "Shruti" }; - var supplementalFont40 = new SupplementalFont { Script = "Khmr", Typeface = "DaunPenh" }; - var supplementalFont41 = new SupplementalFont { Script = "Knda", Typeface = "Tunga" }; - var supplementalFont42 = new SupplementalFont { Script = "Guru", Typeface = "Raavi" }; - var supplementalFont43 = new SupplementalFont { Script = "Cans", Typeface = "Euphemia" }; - var supplementalFont44 = new SupplementalFont { Script = "Cher", Typeface = "Plantagenet Cherokee" }; - var supplementalFont45 = new SupplementalFont { Script = "Yiii", Typeface = "Microsoft Yi Baiti" }; - var supplementalFont46 = new SupplementalFont { Script = "Tibt", Typeface = "Microsoft Himalaya" }; - var supplementalFont47 = new SupplementalFont { Script = "Thaa", Typeface = "MV Boli" }; - var supplementalFont48 = new SupplementalFont { Script = "Deva", Typeface = "Mangal" }; - var supplementalFont49 = new SupplementalFont { Script = "Telu", Typeface = "Gautami" }; - var supplementalFont50 = new SupplementalFont { Script = "Taml", Typeface = "Latha" }; - var supplementalFont51 = new SupplementalFont { Script = "Syrc", Typeface = "Estrangelo Edessa" }; - var supplementalFont52 = new SupplementalFont { Script = "Orya", Typeface = "Kalinga" }; - var supplementalFont53 = new SupplementalFont { Script = "Mlym", Typeface = "Kartika" }; - var supplementalFont54 = new SupplementalFont { Script = "Laoo", Typeface = "DokChampa" }; - var supplementalFont55 = new SupplementalFont { Script = "Sinh", Typeface = "Iskoola Pota" }; - var supplementalFont56 = new SupplementalFont { Script = "Mong", Typeface = "Mongolian Baiti" }; - var supplementalFont57 = new SupplementalFont { Script = "Viet", Typeface = "Arial" }; - var supplementalFont58 = new SupplementalFont { Script = "Uigh", Typeface = "Microsoft Uighur" }; - - minorFont1.AppendChild(latinFont2); - minorFont1.AppendChild(eastAsianFont2); - minorFont1.AppendChild(complexScriptFont2); - minorFont1.AppendChild(supplementalFont30); - minorFont1.AppendChild(supplementalFont31); - minorFont1.AppendChild(supplementalFont32); - minorFont1.AppendChild(supplementalFont33); - minorFont1.AppendChild(supplementalFont34); - minorFont1.AppendChild(supplementalFont35); - minorFont1.AppendChild(supplementalFont36); - minorFont1.AppendChild(supplementalFont37); - minorFont1.AppendChild(supplementalFont38); - minorFont1.AppendChild(supplementalFont39); - minorFont1.AppendChild(supplementalFont40); - minorFont1.AppendChild(supplementalFont41); - minorFont1.AppendChild(supplementalFont42); - minorFont1.AppendChild(supplementalFont43); - minorFont1.AppendChild(supplementalFont44); - minorFont1.AppendChild(supplementalFont45); - minorFont1.AppendChild(supplementalFont46); - minorFont1.AppendChild(supplementalFont47); - minorFont1.AppendChild(supplementalFont48); - minorFont1.AppendChild(supplementalFont49); - minorFont1.AppendChild(supplementalFont50); - minorFont1.AppendChild(supplementalFont51); - minorFont1.AppendChild(supplementalFont52); - minorFont1.AppendChild(supplementalFont53); - minorFont1.AppendChild(supplementalFont54); - minorFont1.AppendChild(supplementalFont55); - minorFont1.AppendChild(supplementalFont56); - minorFont1.AppendChild(supplementalFont57); - minorFont1.AppendChild(supplementalFont58); - - fontScheme2.AppendChild(majorFont1); - fontScheme2.AppendChild(minorFont1); - - var formatScheme1 = new FormatScheme { Name = "Office" }; - - var fillStyleList1 = new FillStyleList(); - - var solidFill1 = new SolidFill(); - var schemeColor1 = new SchemeColor { Val = SchemeColorValues.PhColor }; - - solidFill1.AppendChild(schemeColor1); - - var gradientFill1 = new GradientFill { RotateWithShape = true }; - - var gradientStopList1 = new GradientStopList(); - - var gradientStop1 = new GradientStop { Position = 0 }; - - var schemeColor2 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var tint1 = new Tint { Val = 50000 }; - var saturationModulation1 = new SaturationModulation { Val = 300000 }; - - schemeColor2.AppendChild(tint1); - schemeColor2.AppendChild(saturationModulation1); - - gradientStop1.AppendChild(schemeColor2); - - var gradientStop2 = new GradientStop { Position = 35000 }; - - var schemeColor3 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var tint2 = new Tint { Val = 37000 }; - var saturationModulation2 = new SaturationModulation { Val = 300000 }; - - schemeColor3.AppendChild(tint2); - schemeColor3.AppendChild(saturationModulation2); - - gradientStop2.AppendChild(schemeColor3); - - var gradientStop3 = new GradientStop { Position = 100000 }; - - var schemeColor4 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var tint3 = new Tint { Val = 15000 }; - var saturationModulation3 = new SaturationModulation { Val = 350000 }; - - schemeColor4.AppendChild(tint3); - schemeColor4.AppendChild(saturationModulation3); - - gradientStop3.AppendChild(schemeColor4); - - gradientStopList1.AppendChild(gradientStop1); - gradientStopList1.AppendChild(gradientStop2); - gradientStopList1.AppendChild(gradientStop3); - var linearGradientFill1 = new LinearGradientFill { Angle = 16200000, Scaled = true }; - - gradientFill1.AppendChild(gradientStopList1); - gradientFill1.AppendChild(linearGradientFill1); - - var gradientFill2 = new GradientFill { RotateWithShape = true }; - - var gradientStopList2 = new GradientStopList(); - - var gradientStop4 = new GradientStop { Position = 0 }; - - var schemeColor5 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var shade1 = new Shade { Val = 51000 }; - var saturationModulation4 = new SaturationModulation { Val = 130000 }; - - schemeColor5.AppendChild(shade1); - schemeColor5.AppendChild(saturationModulation4); - - gradientStop4.AppendChild(schemeColor5); - - var gradientStop5 = new GradientStop { Position = 80000 }; - - var schemeColor6 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var shade2 = new Shade { Val = 93000 }; - var saturationModulation5 = new SaturationModulation { Val = 130000 }; - - schemeColor6.AppendChild(shade2); - schemeColor6.AppendChild(saturationModulation5); - - gradientStop5.AppendChild(schemeColor6); - - var gradientStop6 = new GradientStop { Position = 100000 }; - - var schemeColor7 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var shade3 = new Shade { Val = 94000 }; - var saturationModulation6 = new SaturationModulation { Val = 135000 }; - - schemeColor7.AppendChild(shade3); - schemeColor7.AppendChild(saturationModulation6); - - gradientStop6.AppendChild(schemeColor7); - - gradientStopList2.AppendChild(gradientStop4); - gradientStopList2.AppendChild(gradientStop5); - gradientStopList2.AppendChild(gradientStop6); - var linearGradientFill2 = new LinearGradientFill { Angle = 16200000, Scaled = false }; - - gradientFill2.AppendChild(gradientStopList2); - gradientFill2.AppendChild(linearGradientFill2); - - fillStyleList1.AppendChild(solidFill1); - fillStyleList1.AppendChild(gradientFill1); - fillStyleList1.AppendChild(gradientFill2); - - var lineStyleList1 = new LineStyleList(); - - var outline1 = new Outline - { - Width = 9525, - CapType = LineCapValues.Flat, - CompoundLineType = CompoundLineValues.Single, - Alignment = PenAlignmentValues.Center - }; - - var solidFill2 = new SolidFill(); - - var schemeColor8 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var shade4 = new Shade { Val = 95000 }; - var saturationModulation7 = new SaturationModulation { Val = 105000 }; - - schemeColor8.AppendChild(shade4); - schemeColor8.AppendChild(saturationModulation7); - - solidFill2.AppendChild(schemeColor8); - var presetDash1 = new PresetDash { Val = PresetLineDashValues.Solid }; - - outline1.AppendChild(solidFill2); - outline1.AppendChild(presetDash1); - - var outline2 = new Outline - { - Width = 25400, - CapType = LineCapValues.Flat, - CompoundLineType = CompoundLineValues.Single, - Alignment = PenAlignmentValues.Center - }; - - var solidFill3 = new SolidFill(); - var schemeColor9 = new SchemeColor { Val = SchemeColorValues.PhColor }; - - solidFill3.AppendChild(schemeColor9); - var presetDash2 = new PresetDash { Val = PresetLineDashValues.Solid }; - - outline2.AppendChild(solidFill3); - outline2.AppendChild(presetDash2); - - var outline3 = new Outline - { - Width = 38100, - CapType = LineCapValues.Flat, - CompoundLineType = CompoundLineValues.Single, - Alignment = PenAlignmentValues.Center - }; - - var solidFill4 = new SolidFill(); - var schemeColor10 = new SchemeColor { Val = SchemeColorValues.PhColor }; - - solidFill4.AppendChild(schemeColor10); - var presetDash3 = new PresetDash { Val = PresetLineDashValues.Solid }; - - outline3.AppendChild(solidFill4); - outline3.AppendChild(presetDash3); - - lineStyleList1.AppendChild(outline1); - lineStyleList1.AppendChild(outline2); - lineStyleList1.AppendChild(outline3); - - var effectStyleList1 = new EffectStyleList(); - - var effectStyle1 = new EffectStyle(); - - var effectList1 = new EffectList(); - - var outerShadow1 = new OuterShadow - { - BlurRadius = 40000L, - Distance = 20000L, - Direction = 5400000, - RotateWithShape = false - }; - - var rgbColorModelHex11 = new RgbColorModelHex { Val = "000000" }; - var alpha1 = new Alpha { Val = 38000 }; - - rgbColorModelHex11.AppendChild(alpha1); - - outerShadow1.AppendChild(rgbColorModelHex11); - - effectList1.AppendChild(outerShadow1); - - effectStyle1.AppendChild(effectList1); - - var effectStyle2 = new EffectStyle(); - - var effectList2 = new EffectList(); - - var outerShadow2 = new OuterShadow - { - BlurRadius = 40000L, - Distance = 23000L, - Direction = 5400000, - RotateWithShape = false - }; - - var rgbColorModelHex12 = new RgbColorModelHex { Val = "000000" }; - var alpha2 = new Alpha { Val = 35000 }; - - rgbColorModelHex12.AppendChild(alpha2); - - outerShadow2.AppendChild(rgbColorModelHex12); - - effectList2.AppendChild(outerShadow2); - - effectStyle2.AppendChild(effectList2); - - var effectStyle3 = new EffectStyle(); - - var effectList3 = new EffectList(); - - var outerShadow3 = new OuterShadow - { - BlurRadius = 40000L, - Distance = 23000L, - Direction = 5400000, - RotateWithShape = false - }; - - var rgbColorModelHex13 = new RgbColorModelHex { Val = "000000" }; - var alpha3 = new Alpha { Val = 35000 }; - - rgbColorModelHex13.AppendChild(alpha3); - - outerShadow3.AppendChild(rgbColorModelHex13); - - effectList3.AppendChild(outerShadow3); - - var scene3DType1 = new Scene3DType(); - - var camera1 = new Camera { Preset = PresetCameraValues.OrthographicFront }; - var rotation1 = new Rotation { Latitude = 0, Longitude = 0, Revolution = 0 }; - - camera1.AppendChild(rotation1); - - var lightRig1 = new LightRig { Rig = LightRigValues.ThreePoints, Direction = LightRigDirectionValues.Top }; - var rotation2 = new Rotation { Latitude = 0, Longitude = 0, Revolution = 1200000 }; - - lightRig1.AppendChild(rotation2); - - scene3DType1.AppendChild(camera1); - scene3DType1.AppendChild(lightRig1); - - var shape3DType1 = new Shape3DType(); - var bevelTop1 = new BevelTop { Width = 63500L, Height = 25400L }; - - shape3DType1.AppendChild(bevelTop1); - - effectStyle3.AppendChild(effectList3); - effectStyle3.AppendChild(scene3DType1); - effectStyle3.AppendChild(shape3DType1); - - effectStyleList1.AppendChild(effectStyle1); - effectStyleList1.AppendChild(effectStyle2); - effectStyleList1.AppendChild(effectStyle3); - - var backgroundFillStyleList1 = new BackgroundFillStyleList(); - - var solidFill5 = new SolidFill(); - var schemeColor11 = new SchemeColor { Val = SchemeColorValues.PhColor }; - - solidFill5.AppendChild(schemeColor11); - - var gradientFill3 = new GradientFill { RotateWithShape = true }; - - var gradientStopList3 = new GradientStopList(); - - var gradientStop7 = new GradientStop { Position = 0 }; - - var schemeColor12 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var tint4 = new Tint { Val = 40000 }; - var saturationModulation8 = new SaturationModulation { Val = 350000 }; - - schemeColor12.AppendChild(tint4); - schemeColor12.AppendChild(saturationModulation8); - - gradientStop7.AppendChild(schemeColor12); - - var gradientStop8 = new GradientStop { Position = 40000 }; - - var schemeColor13 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var tint5 = new Tint { Val = 45000 }; - var shade5 = new Shade { Val = 99000 }; - var saturationModulation9 = new SaturationModulation { Val = 350000 }; - - schemeColor13.AppendChild(tint5); - schemeColor13.AppendChild(shade5); - schemeColor13.AppendChild(saturationModulation9); - - gradientStop8.AppendChild(schemeColor13); - - var gradientStop9 = new GradientStop { Position = 100000 }; - - var schemeColor14 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var shade6 = new Shade { Val = 20000 }; - var saturationModulation10 = new SaturationModulation { Val = 255000 }; - - schemeColor14.AppendChild(shade6); - schemeColor14.AppendChild(saturationModulation10); - - gradientStop9.AppendChild(schemeColor14); - - gradientStopList3.AppendChild(gradientStop7); - gradientStopList3.AppendChild(gradientStop8); - gradientStopList3.AppendChild(gradientStop9); - - var pathGradientFill1 = new PathGradientFill { Path = PathShadeValues.Circle }; - var fillToRectangle1 = new FillToRectangle { Left = 50000, Top = -80000, Right = 50000, Bottom = 180000 }; - - pathGradientFill1.AppendChild(fillToRectangle1); - - gradientFill3.AppendChild(gradientStopList3); - gradientFill3.AppendChild(pathGradientFill1); - - var gradientFill4 = new GradientFill { RotateWithShape = true }; - - var gradientStopList4 = new GradientStopList(); - - var gradientStop10 = new GradientStop { Position = 0 }; - - var schemeColor15 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var tint6 = new Tint { Val = 80000 }; - var saturationModulation11 = new SaturationModulation { Val = 300000 }; - - schemeColor15.AppendChild(tint6); - schemeColor15.AppendChild(saturationModulation11); - - gradientStop10.AppendChild(schemeColor15); - - var gradientStop11 = new GradientStop { Position = 100000 }; - - var schemeColor16 = new SchemeColor { Val = SchemeColorValues.PhColor }; - var shade7 = new Shade { Val = 30000 }; - var saturationModulation12 = new SaturationModulation { Val = 200000 }; - - schemeColor16.AppendChild(shade7); - schemeColor16.AppendChild(saturationModulation12); - - gradientStop11.AppendChild(schemeColor16); - - gradientStopList4.AppendChild(gradientStop10); - gradientStopList4.AppendChild(gradientStop11); - - var pathGradientFill2 = new PathGradientFill { Path = PathShadeValues.Circle }; - var fillToRectangle2 = new FillToRectangle { Left = 50000, Top = 50000, Right = 50000, Bottom = 50000 }; - - pathGradientFill2.AppendChild(fillToRectangle2); - - gradientFill4.AppendChild(gradientStopList4); - gradientFill4.AppendChild(pathGradientFill2); - - backgroundFillStyleList1.AppendChild(solidFill5); - backgroundFillStyleList1.AppendChild(gradientFill3); - backgroundFillStyleList1.AppendChild(gradientFill4); - - formatScheme1.AppendChild(fillStyleList1); - formatScheme1.AppendChild(lineStyleList1); - formatScheme1.AppendChild(effectStyleList1); - formatScheme1.AppendChild(backgroundFillStyleList1); - - themeElements1.AppendChild(colorScheme1); - themeElements1.AppendChild(fontScheme2); - themeElements1.AppendChild(formatScheme1); - var objectDefaults1 = new ObjectDefaults(); - var extraColorSchemeList1 = new ExtraColorSchemeList(); - - theme1.AppendChild(themeElements1); - theme1.AppendChild(objectDefaults1); - theme1.AppendChild(extraColorSchemeList1); - - themePart.Theme = theme1; - } - - private void GenerateCustomFilePropertiesPartContent(CustomFilePropertiesPart customFilePropertiesPart) - { - var properties = new DocumentFormat.OpenXml.CustomProperties.Properties(); - properties.AddNamespaceDeclaration("vt", - "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"); - var propertyId = 1; - foreach (var p in CustomProperties) - { - propertyId++; - var customDocumentProperty = new CustomDocumentProperty - { - FormatId = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}", - PropertyId = propertyId, - Name = p.Name - }; - if (p.Type == XLCustomPropertyType.Text) - { - var vTlpwstr1 = new VTLPWSTR { Text = p.GetValue() }; - customDocumentProperty.AppendChild(vTlpwstr1); - } - else if (p.Type == XLCustomPropertyType.Date) - { - var vTFileTime1 = new VTFileTime - { - Text = - p.GetValue().ToUniversalTime().ToString( - "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'") - }; - customDocumentProperty.AppendChild(vTFileTime1); - } - else if (p.Type == XLCustomPropertyType.Number) - { - var vTDouble1 = new VTDouble - { - Text = p.GetValue().ToInvariantString() - }; - customDocumentProperty.AppendChild(vTDouble1); - } - else - { - var vTBool1 = new VTBool { Text = p.GetValue().ToString().ToLower() }; - customDocumentProperty.AppendChild(vTBool1); - } - properties.AppendChild(customDocumentProperty); - } - - customFilePropertiesPart.Properties = properties; - } - - private void SetPackageProperties(OpenXmlPackage document) - { - var created = Properties.Created == DateTime.MinValue ? DateTime.Now : Properties.Created; - var modified = Properties.Modified == DateTime.MinValue ? DateTime.Now : Properties.Modified; - document.PackageProperties.Created = created; - document.PackageProperties.Modified = modified; - -#if true // Workaround: https://github.com/OfficeDev/Open-XML-SDK/issues/235 - - if (Properties.LastModifiedBy == null) document.PackageProperties.LastModifiedBy = ""; - if (Properties.Author == null) document.PackageProperties.Creator = ""; - if (Properties.Title == null) document.PackageProperties.Title = ""; - if (Properties.Subject == null) document.PackageProperties.Subject = ""; - if (Properties.Category == null) document.PackageProperties.Category = ""; - if (Properties.Keywords == null) document.PackageProperties.Keywords = ""; - if (Properties.Comments == null) document.PackageProperties.Description = ""; - if (Properties.Status == null) document.PackageProperties.ContentStatus = ""; - -#endif - - document.PackageProperties.LastModifiedBy = Properties.LastModifiedBy; - - document.PackageProperties.Creator = Properties.Author; - document.PackageProperties.Title = Properties.Title; - document.PackageProperties.Subject = Properties.Subject; - document.PackageProperties.Category = Properties.Category; - document.PackageProperties.Keywords = Properties.Keywords; - document.PackageProperties.Description = Properties.Comments; - document.PackageProperties.ContentStatus = Properties.Status; - } - - private static string GetTableName(String originalTableName, SaveContext context) - { - var tableName = originalTableName.RemoveSpecialCharacters(); - var name = tableName; - if (context.TableNames.Contains(name)) - { - var i = 1; - name = tableName + i.ToInvariantString(); - while (context.TableNames.Contains(name)) - { - i++; - name = tableName + i.ToInvariantString(); - } - } - - context.TableNames.Add(name); - return name; - } - - private static void GenerateTableDefinitionPartContent(TableDefinitionPart tableDefinitionPart, XLTable xlTable, SaveContext context) - { - context.TableId++; - var reference = xlTable.RangeAddress.FirstAddress + ":" + xlTable.RangeAddress.LastAddress; - var tableName = GetTableName(xlTable.Name, context); - var table = new Table - { - Id = context.TableId, - Name = tableName, - DisplayName = tableName, - Reference = reference - }; - - if (!xlTable.ShowHeaderRow) - table.HeaderRowCount = 0; - - if (xlTable.ShowTotalsRow) - table.TotalsRowCount = 1; - else - table.TotalsRowShown = false; - - var tableColumns = new TableColumns { Count = (UInt32)xlTable.ColumnCount() }; - - UInt32 columnId = 0; - foreach (var xlField in xlTable.Fields) - { - columnId++; - var fieldName = xlField.Name; - var tableColumn = new TableColumn - { - Id = columnId, - Name = fieldName.Replace("_x000a_", "_x005f_x000a_").Replace(Environment.NewLine, "_x000a_") - }; - - // https://github.com/ClosedXML/ClosedXML/issues/513 - if (xlField.IsConsistentStyle()) - { - var style = (xlField.Column.Cells() - .Skip(xlTable.ShowHeaderRow ? 1 : 0) - .First() - .Style as XLStyle).Value; - - if (!DefaultStyleValue.Equals(style) && context.DifferentialFormats.TryGetValue(style, out Int32 id)) - tableColumn.DataFormatId = UInt32Value.FromUInt32(Convert.ToUInt32(id)); - } - else - tableColumn.DataFormatId = null; - - if (xlField.IsConsistentFormula()) - { - string formula = xlField.Column.Cells() - .Skip(xlTable.ShowHeaderRow ? 1 : 0) - .First() - .FormulaA1; - - while (formula.StartsWith("=") && formula.Length > 1) - formula = formula.Substring(1); - - if (!String.IsNullOrWhiteSpace(formula)) - { - tableColumn.CalculatedColumnFormula = new CalculatedColumnFormula - { - Text = formula - }; - } - } - else - tableColumn.CalculatedColumnFormula = null; - - if (xlTable.ShowTotalsRow) - { - if (xlField.TotalsRowFunction != XLTotalsRowFunction.None) - { - tableColumn.TotalsRowFunction = xlField.TotalsRowFunction.ToOpenXml(); - - if (xlField.TotalsRowFunction == XLTotalsRowFunction.Custom) - tableColumn.TotalsRowFormula = new TotalsRowFormula(xlField.TotalsRowFormulaA1); - } - - if (!String.IsNullOrWhiteSpace(xlField.TotalsRowLabel)) - tableColumn.TotalsRowLabel = xlField.TotalsRowLabel; - } - tableColumns.AppendChild(tableColumn); - } - - var tableStyleInfo1 = new TableStyleInfo - { - ShowFirstColumn = xlTable.EmphasizeFirstColumn, - ShowLastColumn = xlTable.EmphasizeLastColumn, - ShowRowStripes = xlTable.ShowRowStripes, - ShowColumnStripes = xlTable.ShowColumnStripes - }; - - if (xlTable.Theme != XLTableTheme.None) - tableStyleInfo1.Name = xlTable.Theme.Name; - - if (xlTable.ShowAutoFilter) - { - var autoFilter1 = new AutoFilter(); - if (xlTable.ShowTotalsRow) - { - xlTable.AutoFilter.Range = xlTable.Worksheet.Range( - xlTable.RangeAddress.FirstAddress.RowNumber, xlTable.RangeAddress.FirstAddress.ColumnNumber, - xlTable.RangeAddress.LastAddress.RowNumber - 1, xlTable.RangeAddress.LastAddress.ColumnNumber); - } - else - xlTable.AutoFilter.Range = xlTable.Worksheet.Range(xlTable.RangeAddress); - - PopulateAutoFilter(xlTable.AutoFilter, autoFilter1); - - table.AppendChild(autoFilter1); - } - - table.AppendChild(tableColumns); - table.AppendChild(tableStyleInfo1); - - tableDefinitionPart.Table = table; - } - - private static void GeneratePivotTables(WorkbookPart workbookPart, WorksheetPart worksheetPart, - XLWorksheet xlWorksheet, - SaveContext context) - { - PivotCaches pivotCaches; - uint cacheId = 0; - if (workbookPart.Workbook.PivotCaches == null) - pivotCaches = workbookPart.Workbook.InsertAfter(new PivotCaches(), workbookPart.Workbook.CalculationProperties); - else - { - pivotCaches = workbookPart.Workbook.PivotCaches; - if (pivotCaches.Any()) - cacheId = pivotCaches.Cast().Max(pc => pc.CacheId.Value) + 1; - } - - foreach (var pt in xlWorksheet.PivotTables.Cast()) - { - context.PivotTables.Clear(); - - // TODO: Avoid duplicate pivot caches of same source range - - PivotCache pivotCache; - PivotTableCacheDefinitionPart pivotTableCacheDefinitionPart; - if (!String.IsNullOrWhiteSpace(pt.WorkbookCacheRelId)) - { - pivotCache = pivotCaches.Cast().Single(pc => pc.Id.Value == pt.WorkbookCacheRelId); - pivotTableCacheDefinitionPart = workbookPart.GetPartById(pt.WorkbookCacheRelId) as PivotTableCacheDefinitionPart; - } - else - { - var workbookCacheRelId = context.RelIdGenerator.GetNext(RelType.Workbook); - pt.WorkbookCacheRelId = workbookCacheRelId; - pivotCache = new PivotCache { CacheId = cacheId++, Id = workbookCacheRelId }; - pivotCaches.AppendChild(pivotCache); - pivotTableCacheDefinitionPart = workbookPart.AddNewPart(workbookCacheRelId); - } - - GeneratePivotTableCacheDefinitionPartContent(pivotTableCacheDefinitionPart, pt, context); - - PivotTablePart pivotTablePart; - var createNewPivotTablePart = String.IsNullOrWhiteSpace(pt.RelId); - if (createNewPivotTablePart) - { - var relId = context.RelIdGenerator.GetNext(RelType.Workbook); - pt.RelId = relId; - pivotTablePart = worksheetPart.AddNewPart(relId); - } - else - pivotTablePart = worksheetPart.GetPartById(pt.RelId) as PivotTablePart; - - GeneratePivotTablePartContent(pivotTablePart, pt, pivotCache.CacheId, context); - - if (createNewPivotTablePart) - pivotTablePart.AddPart(pivotTableCacheDefinitionPart, context.RelIdGenerator.GetNext(RelType.Workbook)); - } - } - - // Generates content of pivotTableCacheDefinitionPart - private static void GeneratePivotTableCacheDefinitionPartContent( - PivotTableCacheDefinitionPart pivotTableCacheDefinitionPart, XLPivotTable pt, - SaveContext context) - { - var pivotCacheDefinition = pivotTableCacheDefinitionPart.PivotCacheDefinition; - if (pivotCacheDefinition == null) - { - pivotCacheDefinition = new PivotCacheDefinition { Id = "rId1" }; - - pivotCacheDefinition.AddNamespaceDeclaration("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); - pivotTableCacheDefinitionPart.PivotCacheDefinition = pivotCacheDefinition; - } - - #region CreatedVersion - - byte createdVersion = XLConstants.PivotTable.CreatedVersion; - - if (pivotCacheDefinition.CreatedVersion?.HasValue ?? false) - pivotCacheDefinition.CreatedVersion = Math.Max(createdVersion, pivotCacheDefinition.CreatedVersion.Value); - else - pivotCacheDefinition.CreatedVersion = createdVersion; - - #endregion CreatedVersion - - #region RefreshedVersion - - byte refreshedVersion = XLConstants.PivotTable.RefreshedVersion; - if (pivotCacheDefinition.RefreshedVersion?.HasValue ?? false) - pivotCacheDefinition.RefreshedVersion = Math.Max(refreshedVersion, pivotCacheDefinition.RefreshedVersion.Value); - else - pivotCacheDefinition.RefreshedVersion = refreshedVersion; - - #endregion RefreshedVersion - - #region MinRefreshableVersion - - byte minRefreshableVersion = 3; - if (pivotCacheDefinition.MinRefreshableVersion?.HasValue ?? false) - pivotCacheDefinition.MinRefreshableVersion = Math.Max(minRefreshableVersion, pivotCacheDefinition.MinRefreshableVersion.Value); - else - pivotCacheDefinition.MinRefreshableVersion = minRefreshableVersion; - - #endregion MinRefreshableVersion - - pivotCacheDefinition.SaveData = pt.SaveSourceData; - pivotCacheDefinition.RefreshOnLoad = true; //pt.RefreshDataOnOpen - - var pti = new PivotTableInfo - { - Guid = pt.Guid, - Fields = new Dictionary() - }; - - var source = pt.SourceRange; - if (pt.ItemsToRetainPerField == XLItemsToRetain.None) - pivotCacheDefinition.MissingItemsLimit = 0U; - else if (pt.ItemsToRetainPerField == XLItemsToRetain.Max) - pivotCacheDefinition.MissingItemsLimit = XLHelper.MaxRowNumber; - - // Begin CacheSource - var cacheSource = new CacheSource { Type = SourceValues.Worksheet }; - var worksheetSource = new WorksheetSource(); - - switch (pt.SourceType) - { - case XLPivotTableSourceType.Range: - worksheetSource.Name = null; - worksheetSource.Reference = source.RangeAddress.ToStringRelative(includeSheet: false); - - // Do not quote worksheet name with whitespace here - issue #955 - worksheetSource.Sheet = source.RangeAddress.Worksheet.Name; - break; - - case XLPivotTableSourceType.Table: - worksheetSource.Name = pt.SourceTable.Name; - worksheetSource.Reference = null; - worksheetSource.Sheet = null; - break; - - default: - throw new NotSupportedException($"Pivot table source type {pt.SourceType} is not supported."); - } - - cacheSource.AppendChild(worksheetSource); - pivotCacheDefinition.CacheSource = cacheSource; - - // End CacheSource - - // Begin CacheFields - var cacheFields = pivotCacheDefinition.CacheFields; - if (cacheFields == null) - { - cacheFields = new CacheFields(); - pivotCacheDefinition.CacheFields = cacheFields; - } - - foreach (var c in source.Columns()) - { - var columnNumber = c.ColumnNumber(); - var columnName = c.FirstCell().Value.ObjectToInvariantString(); - - CacheField cacheField = null; - - // .CacheFields is cleared when workbook is begin saved - // So if there are any entries, it would be from previous pivot tables - // with an identical source range. - // When pivot sources get its refactoring, this will not be necessary - if (cacheFields != null) - { - cacheField = pivotCacheDefinition - .CacheFields - .Elements() - .FirstOrDefault(f => f.Name == columnName); - } - - if (cacheField == null) - { - cacheField = new CacheField - { - Name = columnName, - SharedItems = new SharedItems() - }; - cacheFields.AppendChild(cacheField); - } - var sharedItems = cacheField.SharedItems; - - XLPivotField xlpf; - if (pt.Fields.Contains(columnName)) - xlpf = pt.Fields.Get(columnName) as XLPivotField; - else - xlpf = pt.Fields.Add(columnName) as XLPivotField; - - var field = pt.RowLabels - .Union(pt.ColumnLabels) - .Union(pt.ReportFilters) - .FirstOrDefault(f => f.SourceName == columnName); - - if (field == null) - { - xlpf.ShowBlankItems = true; - } - else - { - xlpf.CustomName = field.CustomName; - xlpf.SortType = field.SortType; - xlpf.SubtotalCaption = field.SubtotalCaption; - xlpf.IncludeNewItemsInFilter = field.IncludeNewItemsInFilter; - xlpf.Outline = field.Outline; - xlpf.Compact = field.Compact; - xlpf.SubtotalsAtTop = field.SubtotalsAtTop; - xlpf.RepeatItemLabels = field.RepeatItemLabels; - xlpf.InsertBlankLines = field.InsertBlankLines; - xlpf.ShowBlankItems = field.ShowBlankItems; - xlpf.InsertPageBreaks = field.InsertPageBreaks; - xlpf.Collapsed = field.Collapsed; - xlpf.Subtotals.AddRange(field.Subtotals); - } - - var ptfi = new PivotTableFieldInfo - { - IsTotallyBlankField = false - }; - - var sourceHeaderRow = source.FirstRow().RowNumber(); - var cellsUsed = source.CellsUsed(XLCellsUsedOptions.All, - cell => cell.Address.ColumnNumber == columnNumber - && cell.Address.RowNumber > sourceHeaderRow) - .ToArray(); - var fieldValueCells = cellsUsed.Where(cell => !cell.IsEmpty()).ToArray(); - var types = fieldValueCells.Select(cell => cell.DataType).Distinct().ToArray(); - var containsBlank = cellsUsed.Any(cell => cell.IsEmpty()); - - // For a totally blank column, we need to check that all cells in column are unused - if (fieldValueCells.Length == 0) - { - ptfi.IsTotallyBlankField = true; - containsBlank = true; - } - - if (types.Length > 0) - { - if (types.Length == 1 && types.Single() == XLDataType.Number) - { - ptfi.DataType = XLDataType.Number; - ptfi.MixedDataType = false; - ptfi.DistinctValues = fieldValueCells - .Where(cell => cell.TryGetValue(out Double _)) - .Select(cell => cell.CachedValue.CastTo()) - .Distinct() - .Cast() - .ToArray(); - - pti.Fields.Add(xlpf.SourceName, ptfi); - } - else if (types.Length == 1 && types.Single() == XLDataType.DateTime) - { - ptfi.DataType = XLDataType.DateTime; - ptfi.MixedDataType = false; - ptfi.DistinctValues = fieldValueCells - .Where(cell => cell.TryGetValue(out DateTime _)) - .Select(cell => cell.CachedValue.CastTo()) - .Distinct() - .Cast() - .ToArray(); - - pti.Fields.Add(xlpf.SourceName, ptfi); - } - else - { - ptfi.DataType = types.First(); - ptfi.MixedDataType = types.Length > 1; - - if (!ptfi.MixedDataType && ptfi.DataType == XLDataType.Text) - ptfi.DistinctValues = fieldValueCells - .Where(cell => cell.TryGetValue(out String _)) - .Select(cell => cell.CachedValue.CastTo()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - else - ptfi.DistinctValues = fieldValueCells - .Where(cell => cell.TryGetValue(out String _)) - .Select(cell => cell.GetString()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - pti.Fields.Add(xlpf.SourceName, ptfi); - } - - // If this cache field exists and contains shared items, - // then we can assume that this as been populated by a previous pivot table - if (sharedItems.Any()) - continue; - - // Else we have to populate the items - if (types.Length == 1 && types.Single() == XLDataType.Number) - { - sharedItems.ContainsSemiMixedTypes = containsBlank; - sharedItems.ContainsString = false; - sharedItems.ContainsNumber = true; - - var allInteger = ptfi.DistinctValues.All(v => int.TryParse(v.ToString(), out int val)); - if (allInteger) sharedItems.ContainsInteger = true; - - // Output items only for row / column / filter fields - if (pt.RowLabels.Any(p => p.SourceName == xlpf.SourceName) - || pt.ColumnLabels.Any(p => p.SourceName == xlpf.SourceName) - || pt.ReportFilters.Any(p => p.SourceName == xlpf.SourceName)) - { - foreach (var value in ptfi.DistinctValues) - sharedItems.AppendChild(new NumberItem { Val = (double)value }); - - if (containsBlank) sharedItems.AppendChild(new MissingItem()); - } - - sharedItems.MinValue = (double)ptfi.DistinctValues.Min(); - sharedItems.MaxValue = (double)ptfi.DistinctValues.Max(); - } - else if (types.Length == 1 && types.Single() == XLDataType.DateTime) - { - sharedItems.ContainsSemiMixedTypes = containsBlank; - sharedItems.ContainsNonDate = false; - sharedItems.ContainsString = false; - sharedItems.ContainsDate = true; - - // Output items only for row / column / filter fields - if (pt.RowLabels.Any(p => p.SourceName == xlpf.SourceName) - || pt.ColumnLabels.Any(p => p.SourceName == xlpf.SourceName) - || pt.ReportFilters.Any(p => p.SourceName == xlpf.SourceName)) - { - foreach (var value in ptfi.DistinctValues) - sharedItems.AppendChild(new DateTimeItem { Val = (DateTime)value }); - - if (containsBlank) sharedItems.AppendChild(new MissingItem()); - } - - sharedItems.MinDate = (DateTime)ptfi.DistinctValues.Min(); - sharedItems.MaxDate = (DateTime)ptfi.DistinctValues.Max(); - } - else - { - if (ptfi.DistinctValues.Any(v => ((string)v).Length > 255)) - sharedItems.LongText = true; - - foreach (var value in ptfi.DistinctValues) - sharedItems.AppendChild(new StringItem { Val = (string)value }); - - if (containsBlank) sharedItems.AppendChild(new MissingItem()); - } - - sharedItems.Count = Convert.ToUInt32(sharedItems.Elements().Count()); - } - - if (containsBlank) sharedItems.ContainsBlank = true; - - if (ptfi.IsTotallyBlankField) - pti.Fields.Add(xlpf.SourceName, ptfi); - else if (ptfi.DistinctValues?.Any() ?? false) - sharedItems.Count = Convert.ToUInt32(ptfi.DistinctValues.Count()); - } - - // End CacheFields - - var pivotTableCacheRecordsPart = pivotTableCacheDefinitionPart.GetPartsOfType().Any() ? - pivotTableCacheDefinitionPart.GetPartsOfType().First() : - pivotTableCacheDefinitionPart.AddNewPart("rId1"); - - var pivotCacheRecords = new PivotCacheRecords(); - pivotCacheRecords.AddNamespaceDeclaration("r", - "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); - pivotTableCacheRecordsPart.PivotCacheRecords = pivotCacheRecords; - - context.PivotTables.Add(pti.Guid, pti); - } - - // Generates content of pivotTablePart - private static void GeneratePivotTablePartContent( - PivotTablePart pivotTablePart, XLPivotTable pt, - uint cacheId, SaveContext context) - { - var pti = context.PivotTables[(pt as XLPivotTable).Guid]; - - var pivotTableDefinition = new PivotTableDefinition - { - Name = pt.Name, - CacheId = cacheId, - DataCaption = "Values", - MergeItem = OpenXmlHelper.GetBooleanValue(pt.MergeAndCenterWithLabels, false), - Indent = Convert.ToUInt32(pt.RowLabelIndent), - PageOverThenDown = (pt.FilterAreaOrder == XLFilterAreaOrder.OverThenDown), - PageWrap = Convert.ToUInt32(pt.FilterFieldsPageWrap), - ShowError = String.IsNullOrEmpty(pt.ErrorValueReplacement), - UseAutoFormatting = OpenXmlHelper.GetBooleanValue(pt.AutofitColumns, false), - PreserveFormatting = OpenXmlHelper.GetBooleanValue(pt.PreserveCellFormatting, true), - RowGrandTotals = OpenXmlHelper.GetBooleanValue(pt.ShowGrandTotalsRows, true), - ColumnGrandTotals = OpenXmlHelper.GetBooleanValue(pt.ShowGrandTotalsColumns, true), - SubtotalHiddenItems = OpenXmlHelper.GetBooleanValue(pt.FilteredItemsInSubtotals, false), - MultipleFieldFilters = OpenXmlHelper.GetBooleanValue(pt.AllowMultipleFilters, true), - CustomListSort = OpenXmlHelper.GetBooleanValue(pt.UseCustomListsForSorting, true), - ShowDrill = OpenXmlHelper.GetBooleanValue(pt.ShowExpandCollapseButtons, true), - ShowDataTips = OpenXmlHelper.GetBooleanValue(pt.ShowContextualTooltips, true), - ShowMemberPropertyTips = OpenXmlHelper.GetBooleanValue(pt.ShowPropertiesInTooltips, true), - ShowHeaders = OpenXmlHelper.GetBooleanValue(pt.DisplayCaptionsAndDropdowns, true), - GridDropZones = OpenXmlHelper.GetBooleanValue(pt.ClassicPivotTableLayout, false), - ShowEmptyRow = OpenXmlHelper.GetBooleanValue(pt.ShowEmptyItemsOnRows, false), - ShowEmptyColumn = OpenXmlHelper.GetBooleanValue(pt.ShowEmptyItemsOnColumns, false), - ShowItems = OpenXmlHelper.GetBooleanValue(pt.DisplayItemLabels, true), - FieldListSortAscending = OpenXmlHelper.GetBooleanValue(pt.SortFieldsAtoZ, false), - PrintDrill = OpenXmlHelper.GetBooleanValue(pt.PrintExpandCollapsedButtons, false), - ItemPrintTitles = OpenXmlHelper.GetBooleanValue(pt.RepeatRowLabels, false), - FieldPrintTitles = OpenXmlHelper.GetBooleanValue(pt.PrintTitles, false), - EnableDrill = OpenXmlHelper.GetBooleanValue(pt.EnableShowDetails, true) - }; - - if (!String.IsNullOrEmpty(pt.ColumnHeaderCaption)) - pivotTableDefinition.ColumnHeaderCaption = StringValue.FromString(pt.ColumnHeaderCaption); - - if (!String.IsNullOrEmpty(pt.RowHeaderCaption)) - pivotTableDefinition.RowHeaderCaption = StringValue.FromString(pt.RowHeaderCaption); - - if (pt.ClassicPivotTableLayout) - { - pivotTableDefinition.Compact = false; - pivotTableDefinition.CompactData = false; - } - - if (pt.EmptyCellReplacement != null) - { - pivotTableDefinition.ShowMissing = true; - pivotTableDefinition.MissingCaption = pt.EmptyCellReplacement; - } - else - { - pivotTableDefinition.ShowMissing = false; - } - - if (pt.ErrorValueReplacement != null) - { - pivotTableDefinition.ShowError = true; - pivotTableDefinition.ErrorCaption = pt.ErrorValueReplacement; - } - else - { - pivotTableDefinition.ShowError = false; - } - - var location = new Location - { - FirstHeaderRow = 1U, - FirstDataRow = 1U, - FirstDataColumn = 1U - }; - - if (pt.ReportFilters.Any()) - { - // Reference cell is the part BELOW the report filters - location.Reference = pt.TargetCell.CellBelow(pt.ReportFilters.Count() + 1).Address.ToString(); - } - else - location.Reference = pt.TargetCell.Address.ToString(); - - var rowFields = new RowFields(); - var columnFields = new ColumnFields(); - var rowItems = new RowItems(); - var columnItems = new ColumnItems(); - var pageFields = new PageFields { Count = (uint)pt.ReportFilters.Count() }; - var pivotFields = new PivotFields { Count = Convert.ToUInt32(pt.SourceRange.ColumnCount()) }; - - var orderedPageFields = new SortedDictionary(); - var orderedColumnLabels = new SortedDictionary(); - var orderedRowLabels = new SortedDictionary(); - - // Add value fields first - if (pt.Values.Any()) - { - if (pt.RowLabels.Contains(XLConstants.PivotTable.ValuesSentinalLabel)) - { - var f = pt.RowLabels.First(f1 => f1.SourceName == XLConstants.PivotTable.ValuesSentinalLabel); - orderedRowLabels.Add(pt.RowLabels.IndexOf(f), new Field { Index = -2 }); - pivotTableDefinition.DataOnRows = true; - } - else if (pt.ColumnLabels.Contains(XLConstants.PivotTable.ValuesSentinalLabel)) - { - var f = pt.ColumnLabels.First(f1 => f1.SourceName == XLConstants.PivotTable.ValuesSentinalLabel); - orderedColumnLabels.Add(pt.ColumnLabels.IndexOf(f), new Field { Index = -2 }); - } - } - - // TODO: improve performance as per https://github.com/ClosedXML/ClosedXML/pull/984#discussion_r217266491 - foreach (var xlpf in pt.Fields) - { - var ptfi = pti.Fields[xlpf.SourceName]; - - if (pt.RowLabels.Contains(xlpf.SourceName)) - { - var rowLabelIndex = pt.RowLabels.IndexOf(xlpf); - var f = new Field { Index = pt.Fields.IndexOf(xlpf) }; - orderedRowLabels.Add(rowLabelIndex, f); - - if (ptfi.IsTotallyBlankField) - rowItems.AppendChild(new RowItem()); - else - { - for (var i = 0; i < ptfi.DistinctValues.Count(); i++) - { - var rowItem = new RowItem(); - rowItem.AppendChild(new MemberPropertyIndex { Val = i }); - rowItems.AppendChild(rowItem); - } - } - - var rowItemTotal = new RowItem { ItemType = ItemValues.Grand }; - rowItemTotal.AppendChild(new MemberPropertyIndex()); - rowItems.AppendChild(rowItemTotal); - } - else if (pt.ColumnLabels.Contains(xlpf.SourceName)) - { - var columnlabelIndex = pt.ColumnLabels.IndexOf(xlpf); - var f = new Field { Index = pt.Fields.IndexOf(xlpf) }; - orderedColumnLabels.Add(columnlabelIndex, f); - - if (ptfi.IsTotallyBlankField) - columnItems.AppendChild(new RowItem()); - else - { - for (var i = 0; i < ptfi.DistinctValues.Count(); i++) - { - var rowItem = new RowItem(); - rowItem.AppendChild(new MemberPropertyIndex { Val = i }); - columnItems.AppendChild(rowItem); - } - } - - var rowItemTotal = new RowItem { ItemType = ItemValues.Grand }; - rowItemTotal.AppendChild(new MemberPropertyIndex()); - columnItems.AppendChild(rowItemTotal); - } - } - - foreach (var xlpf in pt.Fields) - { - var ptfi = pti.Fields[xlpf.SourceName]; - IXLPivotField labelOrFilterField = null; - var pf = new PivotField - { - Name = xlpf.CustomName, - IncludeNewItemsInFilter = OpenXmlHelper.GetBooleanValue(xlpf.IncludeNewItemsInFilter, false), - InsertBlankRow = OpenXmlHelper.GetBooleanValue(xlpf.InsertBlankLines, false), - ShowAll = OpenXmlHelper.GetBooleanValue(xlpf.ShowBlankItems, true), - InsertPageBreak = OpenXmlHelper.GetBooleanValue(xlpf.InsertPageBreaks, false), - AllDrilled = OpenXmlHelper.GetBooleanValue(xlpf.Collapsed, false), - }; - if (!string.IsNullOrWhiteSpace(xlpf.SubtotalCaption)) - { - pf.SubtotalCaption = xlpf.SubtotalCaption; - } - - if (pt.ClassicPivotTableLayout) - { - pf.Outline = false; - pf.Compact = false; - } - else - { - pf.Outline = OpenXmlHelper.GetBooleanValue(xlpf.Outline, true); - pf.Compact = OpenXmlHelper.GetBooleanValue(xlpf.Compact, true); - } - - if (xlpf.SortType != XLPivotSortType.Default) - { - pf.SortType = new EnumValue((FieldSortValues)xlpf.SortType); - } - - switch (pt.Subtotals) - { - case XLPivotSubtotals.DoNotShow: - pf.DefaultSubtotal = false; - break; - - case XLPivotSubtotals.AtBottom: - pf.SubtotalTop = false; - break; - - case XLPivotSubtotals.AtTop: - // at top is by default - break; - } - - if (xlpf.SubtotalsAtTop.HasValue) - { - pf.SubtotalTop = OpenXmlHelper.GetBooleanValue(xlpf.SubtotalsAtTop.Value, true); - } - - if (pt.RowLabels.Contains(xlpf.SourceName)) - { - labelOrFilterField = pt.RowLabels.Get(xlpf.SourceName); - pf.Axis = PivotTableAxisValues.AxisRow; - } - else if (pt.ColumnLabels.Contains(xlpf.SourceName)) - { - labelOrFilterField = pt.ColumnLabels.Get(xlpf.SourceName); - pf.Axis = PivotTableAxisValues.AxisColumn; - } - else if (pt.ReportFilters.Contains(xlpf.SourceName)) - { - labelOrFilterField = pt.ReportFilters.Get(xlpf.SourceName); - var sortOrderIndex = pt.ReportFilters.IndexOf(labelOrFilterField); - - location.ColumnsPerPage = 1; - location.RowPageCount = 1; - pf.Axis = PivotTableAxisValues.AxisPage; - - var pageField = new PageField - { - Hierarchy = -1, - Field = pt.Fields.IndexOf(xlpf) - }; - - if (labelOrFilterField.SelectedValues.Count == 1) - { - if (ptfi.MixedDataType || ptfi.DataType == XLDataType.Text) - { - var values = ptfi.DistinctValues - .Select(v => v.ObjectToInvariantString().ToLower()) - .ToList(); - var selectedValue = labelOrFilterField.SelectedValues.Single().ObjectToInvariantString().ToLower(); - if (values.Contains(selectedValue)) - pageField.Item = Convert.ToUInt32(values.IndexOf(selectedValue)); - } - else if (ptfi.DataType == XLDataType.DateTime) - { - var values = ptfi.DistinctValues - .Select(v => Convert.ToDateTime(v)) - .ToList(); - var selectedValue = Convert.ToDateTime(labelOrFilterField.SelectedValues.Single()); - if (values.Contains(selectedValue)) - pageField.Item = Convert.ToUInt32(values.IndexOf(selectedValue)); - } - else if (ptfi.DataType == XLDataType.Number) - { - var values = ptfi.DistinctValues - .Select(v => Convert.ToDouble(v)) - .ToList(); - var selectedValue = Convert.ToDouble(labelOrFilterField.SelectedValues.Single()); - if (values.Contains(selectedValue)) - pageField.Item = Convert.ToUInt32(values.IndexOf(selectedValue)); - } - else if (ptfi.DataType == XLDataType.Boolean) - { - var values = ptfi.DistinctValues - .Select(v => Convert.ToBoolean(v)) - .ToList(); - var selectedValue = Convert.ToBoolean(labelOrFilterField.SelectedValues.Single()); - if (values.Contains(selectedValue)) - pageField.Item = Convert.ToUInt32(values.IndexOf(selectedValue)); - } - else if (ptfi.DataType == XLDataType.TimeSpan) - { - var values = ptfi.DistinctValues - .Cast() - .ToList(); - var selectedValue = (TimeSpan)labelOrFilterField.SelectedValues.Single(); - if (values.Contains(selectedValue)) - pageField.Item = Convert.ToUInt32(values.IndexOf(selectedValue)); - } - else - throw new NotImplementedException(); - } - - orderedPageFields.Add(sortOrderIndex, pageField); - } - - if ((labelOrFilterField?.SelectedValues?.Count ?? 0) > 1) - pf.MultipleItemSelectionAllowed = true; - - if (pt.Values.Any(p => p.SourceName == xlpf.SourceName)) - pf.DataField = true; - - var fieldItems = new Items(); - - // Output items only for row / column / filter fields - if (!ptfi.IsTotallyBlankField && - ptfi.DistinctValues.Any() - && (pt.RowLabels.Contains(xlpf.SourceName) - || pt.ColumnLabels.Contains(xlpf.SourceName) - || pt.ReportFilters.Contains(xlpf.SourceName))) - { - uint i = 0; - foreach (var value in ptfi.DistinctValues) - { - var item = new Item { Index = i }; - - if (labelOrFilterField != null && labelOrFilterField.Collapsed) - item.HideDetails = BooleanValue.FromBoolean(false); - - if (labelOrFilterField != null && - labelOrFilterField.SelectedValues.Count > 1 && - !labelOrFilterField.SelectedValues.Contains(value)) - item.Hidden = BooleanValue.FromBoolean(true); - - fieldItems.AppendChild(item); - - i++; - } - } - - if (xlpf.Subtotals.Any()) - { - foreach (var subtotal in xlpf.Subtotals) - { - var itemSubtotal = new Item(); - switch (subtotal) - { - case XLSubtotalFunction.Average: - pf.AverageSubTotal = true; - itemSubtotal.ItemType = ItemValues.Average; - break; - - case XLSubtotalFunction.Count: - pf.CountASubtotal = true; - itemSubtotal.ItemType = ItemValues.CountA; - break; - - case XLSubtotalFunction.CountNumbers: - pf.CountSubtotal = true; - itemSubtotal.ItemType = ItemValues.Count; - break; - - case XLSubtotalFunction.Maximum: - pf.MaxSubtotal = true; - itemSubtotal.ItemType = ItemValues.Maximum; - break; - - case XLSubtotalFunction.Minimum: - pf.MinSubtotal = true; - itemSubtotal.ItemType = ItemValues.Minimum; - break; - - case XLSubtotalFunction.PopulationStandardDeviation: - pf.ApplyStandardDeviationPInSubtotal = true; - itemSubtotal.ItemType = ItemValues.StandardDeviationP; - break; - - case XLSubtotalFunction.PopulationVariance: - pf.ApplyVariancePInSubtotal = true; - itemSubtotal.ItemType = ItemValues.VarianceP; - break; - - case XLSubtotalFunction.Product: - pf.ApplyProductInSubtotal = true; - itemSubtotal.ItemType = ItemValues.Product; - break; - - case XLSubtotalFunction.StandardDeviation: - pf.ApplyStandardDeviationInSubtotal = true; - itemSubtotal.ItemType = ItemValues.StandardDeviation; - break; - - case XLSubtotalFunction.Sum: - pf.SumSubtotal = true; - itemSubtotal.ItemType = ItemValues.Sum; - break; - - case XLSubtotalFunction.Variance: - pf.ApplyVarianceInSubtotal = true; - itemSubtotal.ItemType = ItemValues.Variance; - break; - } - fieldItems.AppendChild(itemSubtotal); - } - } - // If the field itself doesn't have subtotals, but the pivot table is set to show pivot tables, add the default item - else if (pt.Subtotals != XLPivotSubtotals.DoNotShow) - { - fieldItems.AppendChild(new Item { ItemType = ItemValues.Default }); - } - - if (fieldItems.Any()) - { - fieldItems.Count = Convert.ToUInt32(fieldItems.Count()); - pf.AppendChild(fieldItems); - } - - #region Excel 2010 Features - - if (xlpf.RepeatItemLabels) - { - var pivotFieldExtensionList = new PivotFieldExtensionList(); - pivotFieldExtensionList.RemoveNamespaceDeclaration("x"); - var pivotFieldExtension = new PivotFieldExtension { Uri = "{2946ED86-A175-432a-8AC1-64E0C546D7DE}" }; - pivotFieldExtension.AddNamespaceDeclaration("x14", "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); - - var pivotField2 = new DocumentFormat.OpenXml.Office2010.Excel.PivotField { FillDownLabels = true }; - - pivotFieldExtension.AppendChild(pivotField2); - - pivotFieldExtensionList.AppendChild(pivotFieldExtension); - pf.AppendChild(pivotFieldExtensionList); - } - - #endregion Excel 2010 Features - - pivotFields.AppendChild(pf); - } - - pivotTableDefinition.AppendChild(location); - pivotTableDefinition.AppendChild(pivotFields); - - if (pt.RowLabels.Any()) - { - rowFields.Append(orderedRowLabels.Values); - rowFields.Count = Convert.ToUInt32(rowFields.Count()); - pivotTableDefinition.AppendChild(rowFields); - } - else - { - rowItems.AppendChild(new RowItem()); - } - - if (rowItems.Any()) - { - rowItems.Count = Convert.ToUInt32(rowItems.Count()); - pivotTableDefinition.AppendChild(rowItems); - } - - if (pt.ColumnLabels.All(cl => cl.CustomName == XLConstants.PivotTable.ValuesSentinalLabel)) - { - for (int i = 0; i < pt.Values.Count(); i++) - { - var rowItem = new RowItem(); - rowItem.Index = Convert.ToUInt32(i); - rowItem.AppendChild(new MemberPropertyIndex() { Val = i }); - columnItems.AppendChild(rowItem); - } - } - - if (pt.ColumnLabels.Any()) - { - columnFields.Append(orderedColumnLabels.Values); - columnFields.Count = Convert.ToUInt32(columnFields.Count()); - pivotTableDefinition.AppendChild(columnFields); - } - - if (columnItems.Any()) - { - columnItems.Count = Convert.ToUInt32(columnItems.Count()); - pivotTableDefinition.AppendChild(columnItems); - } - - if (pt.ReportFilters.Any()) - { - pageFields.Append(orderedPageFields.Values); - pageFields.Count = Convert.ToUInt32(pageFields.Count()); - pivotTableDefinition.AppendChild(pageFields); - } - - var dataFields = new DataFields(); - foreach (var value in pt.Values) - { - var sourceColumn = - pt.SourceRange.Columns().FirstOrDefault(c => c.Cell(1).Value.ObjectToInvariantString() == value.SourceName); - if (sourceColumn == null) continue; - - UInt32 numberFormatId = 0; - if (value.NumberFormat.NumberFormatId != -1 || context.SharedNumberFormats.ContainsKey(value.NumberFormat.NumberFormatId)) - numberFormatId = (UInt32)value.NumberFormat.NumberFormatId; - else if (context.SharedNumberFormats.Any(snf => snf.Value.NumberFormat.Format == value.NumberFormat.Format)) - numberFormatId = (UInt32)context.SharedNumberFormats.First(snf => snf.Value.NumberFormat.Format == value.NumberFormat.Format).Key; - - var df = new DataField - { - Name = value.CustomName, - Field = (UInt32)(sourceColumn.ColumnNumber() - pt.SourceRange.RangeAddress.FirstAddress.ColumnNumber), - Subtotal = value.SummaryFormula.ToOpenXml(), - ShowDataAs = value.Calculation.ToOpenXml(), - NumberFormatId = numberFormatId - }; - - if (!String.IsNullOrEmpty(value.BaseField)) - { - var baseField = pt.SourceRange.Columns().FirstOrDefault(c => c.Cell(1).Value.ObjectToInvariantString() == value.BaseField); - if (baseField != null) - { - df.BaseField = baseField.ColumnNumber() - pt.SourceRange.RangeAddress.FirstAddress.ColumnNumber; - - var items = baseField.CellsUsed() - .Select(c => c.Value) - .Skip(1) // Skip header column - .Distinct().ToList(); - - if (items.Any(i => i.Equals(value.BaseItem))) - df.BaseItem = Convert.ToUInt32(items.IndexOf(value.BaseItem)); - } - } - else - { - df.BaseField = 0; - } - - if (value.CalculationItem == XLPivotCalculationItem.Previous) - df.BaseItem = 1048828U; - else if (value.CalculationItem == XLPivotCalculationItem.Next) - df.BaseItem = 1048829U; - else if (df.BaseItem == null || !df.BaseItem.HasValue) - df.BaseItem = 0U; - - dataFields.AppendChild(df); - } - - if (dataFields.Any()) - { - dataFields.Count = Convert.ToUInt32(dataFields.Count()); - pivotTableDefinition.AppendChild(dataFields); - } - - var pts = new PivotTableStyle - { - ShowRowHeaders = pt.ShowRowHeaders, - ShowColumnHeaders = pt.ShowColumnHeaders, - ShowRowStripes = pt.ShowRowStripes, - ShowColumnStripes = pt.ShowColumnStripes - }; - - if (pt.Theme != XLPivotTableTheme.None) - pts.Name = Enum.GetName(typeof(XLPivotTableTheme), pt.Theme); - - pivotTableDefinition.AppendChild(pts); - - // Pivot formats - if (pivotTableDefinition.Formats == null) - pivotTableDefinition.Formats = new Formats(); - else - pivotTableDefinition.Formats.RemoveAllChildren(); - - foreach (var styleFormat in pt.StyleFormats.RowGrandTotalFormats) - GeneratePivotTableFormat(isRow: true, (XLPivotStyleFormat)styleFormat, pivotTableDefinition, context); - - foreach (var styleFormat in pt.StyleFormats.ColumnGrandTotalFormats) - GeneratePivotTableFormat(isRow: false, (XLPivotStyleFormat)styleFormat, pivotTableDefinition, context); - - foreach (var pivotField in pt.ImplementedFields) - { - GeneratePivotFieldFormat(XLPivotStyleFormatTarget.Header, pt, (XLPivotField)pivotField, (XLPivotStyleFormat)pivotField.StyleFormats.Header, pivotTableDefinition, context); - GeneratePivotFieldFormat(XLPivotStyleFormatTarget.Subtotal, pt, (XLPivotField)pivotField, (XLPivotStyleFormat)pivotField.StyleFormats.Subtotal, pivotTableDefinition, context); - GeneratePivotFieldFormat(XLPivotStyleFormatTarget.Label, pt, (XLPivotField)pivotField, (XLPivotStyleFormat)pivotField.StyleFormats.Label, pivotTableDefinition, context); - GeneratePivotFieldFormat(XLPivotStyleFormatTarget.Data, pt, (XLPivotField)pivotField, (XLPivotStyleFormat)pivotField.StyleFormats.DataValuesFormat, pivotTableDefinition, context); - } - - if (pivotTableDefinition.Formats.Any()) - { - pivotTableDefinition.Formats.Count = new UInt32Value((uint)pivotTableDefinition.Formats.Count()); - } - else - pivotTableDefinition.Formats = null; - - #region Excel 2010 Features - - var pivotTableDefinitionExtensionList = new PivotTableDefinitionExtensionList(); - - var pivotTableDefinitionExtension = new PivotTableDefinitionExtension { Uri = "{962EF5D1-5CA2-4c93-8EF4-DBF5C05439D2}" }; - pivotTableDefinitionExtension.AddNamespaceDeclaration("x14", "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); - - var pivotTableDefinition2 = new DocumentFormat.OpenXml.Office2010.Excel.PivotTableDefinition - { - EnableEdit = pt.EnableCellEditing, - HideValuesRow = !pt.ShowValuesRow - }; - pivotTableDefinition2.AddNamespaceDeclaration("xm", "http://schemas.microsoft.com/office/excel/2006/main"); - - pivotTableDefinitionExtension.AppendChild(pivotTableDefinition2); - - pivotTableDefinitionExtensionList.AppendChild(pivotTableDefinitionExtension); - pivotTableDefinition.AppendChild(pivotTableDefinitionExtensionList); - - #endregion Excel 2010 Features - - pivotTablePart.PivotTableDefinition = pivotTableDefinition; - } - - private static void GeneratePivotTableFormat(Boolean isRow, XLPivotStyleFormat styleFormat, PivotTableDefinition pivotTableDefinition, SaveContext context) - { - if (DefaultStyle.Equals(styleFormat.Style) || !context.DifferentialFormats.ContainsKey(((XLStyle)styleFormat.Style).Value)) - return; - - var format = new Format(); - - format.FormatId = UInt32Value.FromUInt32(Convert.ToUInt32(context.DifferentialFormats[((XLStyle)styleFormat.Style).Value])); - - var pivotArea = GenerateDefaultPivotArea(XLPivotStyleFormatTarget.GrandTotal); - - pivotArea.LabelOnly = OpenXmlHelper.GetBooleanValue(styleFormat.AppliesTo == XLPivotStyleFormatElement.Label, false); - pivotArea.DataOnly = OpenXmlHelper.GetBooleanValue(styleFormat.AppliesTo == XLPivotStyleFormatElement.Data, true); - - pivotArea.GrandColumn = OpenXmlHelper.GetBooleanValue(!isRow, false); - pivotArea.GrandRow = OpenXmlHelper.GetBooleanValue(isRow, false); - pivotArea.Axis = isRow ? PivotTableAxisValues.AxisRow : PivotTableAxisValues.AxisColumn; - - format.PivotArea = pivotArea; - - pivotTableDefinition.Formats.AppendChild(format); - } - - private static void GeneratePivotFieldFormat(XLPivotStyleFormatTarget target, XLPivotTable pt, XLPivotField pivotField, XLPivotStyleFormat styleFormat, PivotTableDefinition pivotTableDefinition, SaveContext context) - { - if (target == XLPivotStyleFormatTarget.GrandTotal) - throw new ArgumentException($"Use {nameof(GeneratePivotTableFormat)} to populate grand total formats."); - - if (DefaultStyle.Equals(styleFormat.Style) || !context.DifferentialFormats.ContainsKey(((XLStyle)styleFormat.Style).Value)) - return; - - var format = new Format(); - - format.FormatId = UInt32Value.FromUInt32(Convert.ToUInt32(context.DifferentialFormats[((XLStyle)styleFormat.Style).Value])); - - var pivotArea = GenerateDefaultPivotArea(target); - - pivotArea.LabelOnly = OpenXmlHelper.GetBooleanValue(styleFormat.AppliesTo == XLPivotStyleFormatElement.Label, false); - pivotArea.DataOnly = OpenXmlHelper.GetBooleanValue(styleFormat.AppliesTo == XLPivotStyleFormatElement.Data, true); - - pivotArea.CollapsedLevelsAreSubtotals = OpenXmlHelper.GetBooleanValue(styleFormat.CollapsedLevelsAreSubtotals, false); - - if (target == XLPivotStyleFormatTarget.Header) - { - pivotArea.Field = pivotField.Offset; - - if (pivotField.IsOnRowAxis) - pivotArea.Axis = PivotTableAxisValues.AxisRow; - else if (pivotField.IsOnColumnAxis) - pivotArea.Axis = PivotTableAxisValues.AxisColumn; - else if (pivotField.IsInFilterList) - pivotArea.Axis = PivotTableAxisValues.AxisPage; - else - throw new NotImplementedException(); - } - - //Ensure referenced pivot field is added to field references - if (new[] - { - XLPivotStyleFormatTarget.Data, XLPivotStyleFormatTarget.Label, XLPivotStyleFormatTarget.Subtotal - }.Contains(target) - && !styleFormat.FieldReferences.OfType().Select(fr => fr.PivotField).Contains(pivotField)) - { - var fr = new PivotLabelFieldReference(pivotField); - fr.DefaultSubtotal = target == XLPivotStyleFormatTarget.Subtotal; - styleFormat.FieldReferences.Insert(0, fr); - } - - if (pivotArea.PivotAreaReferences == null) - pivotArea.PivotAreaReferences = new PivotAreaReferences(); - else - pivotArea.PivotAreaReferences.RemoveAllChildren(); - - foreach (var fr in styleFormat.FieldReferences) - { - GeneratePivotAreaReference(pt, pivotArea.PivotAreaReferences, fr, context); - } - - if (pivotArea.PivotAreaReferences.Any()) - { - pivotArea.PivotAreaReferences.Count = new UInt32Value((uint)pivotArea.PivotAreaReferences.Count()); - } - else - pivotArea.PivotAreaReferences = null; - - format.PivotArea = pivotArea; - pivotTableDefinition.Formats.AppendChild(format); - } - - private static PivotArea GenerateDefaultPivotArea(XLPivotStyleFormatTarget target) - { - switch (target) - { - case XLPivotStyleFormatTarget.Header: - return new PivotArea - { - Type = PivotAreaValues.Button, - FieldPosition = 0, - DataOnly = OpenXmlHelper.GetBooleanValue(false, true), - LabelOnly = OpenXmlHelper.GetBooleanValue(true, false), - Outline = OpenXmlHelper.GetBooleanValue(false, true), - }; - - case XLPivotStyleFormatTarget.Subtotal: - return new PivotArea - { - Type = PivotAreaValues.Normal, - FieldPosition = 0, - }; - - case XLPivotStyleFormatTarget.GrandTotal: - return new PivotArea - { - Type = PivotAreaValues.Normal, - FieldPosition = 0, - DataOnly = OpenXmlHelper.GetBooleanValue(false, true), - LabelOnly = OpenXmlHelper.GetBooleanValue(false, false), - }; - - case XLPivotStyleFormatTarget.Label: - return new PivotArea - { - Type = PivotAreaValues.Normal, - FieldPosition = 0, - DataOnly = OpenXmlHelper.GetBooleanValue(false, true), - LabelOnly = OpenXmlHelper.GetBooleanValue(true, false), - }; - - case XLPivotStyleFormatTarget.Data: - return new PivotArea - { - Type = PivotAreaValues.Normal, - FieldPosition = 0, - }; - - default: - throw new NotImplementedException(); - } - } - - private static void GeneratePivotAreaReference(XLPivotTable pt, PivotAreaReferences pivotAreaReferences, AbstractPivotFieldReference fieldReference, SaveContext context) - { - var pivotAreaReference = new PivotAreaReference(); - - pivotAreaReference.DefaultSubtotal = OpenXmlHelper.GetBooleanValue(fieldReference.DefaultSubtotal, false); - pivotAreaReference.Field = fieldReference.GetFieldOffset(); - - var matchedOffsets = fieldReference.Match(context.PivotTables[pt.Guid], pt); - foreach (var o in matchedOffsets) - { - pivotAreaReference.AppendChild(new FieldItem { Val = UInt32Value.FromUInt32((uint)o) }); - } - - pivotAreaReferences.AppendChild(pivotAreaReference); - } - - private static void GenerateWorksheetCommentsPartContent(WorksheetCommentsPart worksheetCommentsPart, - XLWorksheet xlWorksheet) - { - var commentList = new CommentList(); - var authorsDict = new Dictionary(); - foreach (var c in xlWorksheet.Internals.CellsCollection.GetCells(c => c.HasComment)) - { - var comment = new Comment { Reference = c.Address.ToStringRelative() }; - var authorName = c.GetComment().Author; - - if (!authorsDict.TryGetValue(authorName, out int authorId)) - { - authorId = authorsDict.Count; - authorsDict.Add(authorName, authorId); - } - comment.AuthorId = (UInt32)authorId; - - var commentText = new CommentText(); - foreach (var rt in c.GetComment()) - { - commentText.Append(GetRun(rt)); - } - - comment.Append(commentText); - commentList.Append(comment); - } - - var authors = new Authors(); - foreach (var author in authorsDict.Select(a => new Author { Text = a.Key })) - { - authors.Append(author); - } - - worksheetCommentsPart.Comments.Append(authors); - worksheetCommentsPart.Comments.Append(commentList); - } - - // Generates content of vmlDrawingPart1. - private static bool GenerateVmlDrawingPartContent(VmlDrawingPart vmlDrawingPart, XLWorksheet xlWorksheet) - { - using (var ms = new MemoryStream()) - using (var stream = vmlDrawingPart.GetStream(FileMode.OpenOrCreate)) - { - CopyStream(stream, ms); - stream.Position = 0; - var writer = new XmlTextWriter(stream, Encoding.UTF8); - - writer.WriteStartElement("xml"); - - // https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.vml.shapetype?view=openxml-2.8.1#remarks - // This element defines a shape template that can be used to create other shapes. - // Shapetype is identical to the shape element(§14.1.2.19) except it cannot reference another shapetype element. - // The type attribute shall not be used with shapetype. - // Attributes defined in the shape override any that appear in the shapetype positioning attributes - // (such as top, width, z-index, rotation, flip) are not passed to a shape from a shapetype. - // To use this element, create a shapetype with a specific id attribute. - // Then create a shape and reference the shapetype's id using the type attribute. - new Vml.Shapetype( - new Vml.Stroke { JoinStyle = Vml.StrokeJoinStyleValues.Miter }, - new Vml.Path { AllowGradientShape = true, ConnectionPointType = ConnectValues.Rectangle } - ) - { - Id = XLConstants.Comment.ShapeTypeId, - CoordinateSize = "21600,21600", - OptionalNumber = 202, - EdgePath = "m,l,21600r21600,l21600,xe", - } - .WriteTo(writer); - - var cellWithComments = xlWorksheet.Internals.CellsCollection.GetCells(c => c.HasComment); - - var hasAnyVmlElements = false; - - foreach (var c in cellWithComments) - { - GenerateCommentShape(c).WriteTo(writer); - hasAnyVmlElements |= true; - } - - if (ms.Length > 0) - { - ms.Position = 0; - var xdoc = XDocumentExtensions.Load(ms); - xdoc.Root.Elements().ForEach(e => writer.WriteRaw(e.ToString())); - hasAnyVmlElements |= xdoc.Root.HasElements; - } - - writer.WriteEndElement(); - writer.Flush(); - writer.Close(); - - return hasAnyVmlElements; - } - } - - // VML Shape for Comment - private static Vml.Shape GenerateCommentShape(XLCell c) - { - var rowNumber = c.Address.RowNumber; - var columnNumber = c.Address.ColumnNumber; - - var comment = c.GetComment(); - var shapeId = String.Concat("_x0000_s", comment.ShapeId); - // Unique per cell (workbook?), e.g.: "_x0000_s1026" - var anchor = GetAnchor(c); - var textBox = GetTextBox(comment.Style); - var fill = new Vml.Fill { Color2 = "#" + comment.Style.ColorsAndLines.FillColor.Color.ToHex().Substring(2) }; - if (comment.Style.ColorsAndLines.FillTransparency < 1) - fill.Opacity = - Math.Round(Convert.ToDouble(comment.Style.ColorsAndLines.FillTransparency), 2).ToInvariantString(); - var stroke = GetStroke(c); - var shape = new Vml.Shape( - fill, - stroke, - new Vml.Shadow { Color = "black", Obscured = true }, - new Vml.Path { ConnectionPointType = ConnectValues.None }, - textBox, - new ClientData( - new MoveWithCells(comment.Style.Properties.Positioning == XLDrawingAnchor.Absolute - ? "True" - : "False"), // Counterintuitive - new ResizeWithCells(comment.Style.Properties.Positioning == XLDrawingAnchor.MoveAndSizeWithCells - ? "False" - : "True"), // Counterintuitive - anchor, - new HorizontalTextAlignment(comment.Style.Alignment.Horizontal.ToString().ToCamel()), - new Vml.Spreadsheet.VerticalTextAlignment(comment.Style.Alignment.Vertical.ToString().ToCamel()), - new AutoFill("False"), - new CommentRowTarget { Text = (rowNumber - 1).ToInvariantString() }, - new CommentColumnTarget { Text = (columnNumber - 1).ToInvariantString() }, - new Locked(comment.Style.Protection.Locked ? "True" : "False"), - new LockText(comment.Style.Protection.LockText ? "True" : "False"), - new Visible(comment.Visible ? "True" : "False") - ) - { ObjectType = ObjectValues.Note } - ) - { - Id = shapeId, - Type = "#" + XLConstants.Comment.ShapeTypeId, - Style = GetCommentStyle(c), - FillColor = "#" + comment.Style.ColorsAndLines.FillColor.Color.ToHex().Substring(2), - StrokeColor = "#" + comment.Style.ColorsAndLines.LineColor.Color.ToHex().Substring(2), - StrokeWeight = String.Concat(comment.Style.ColorsAndLines.LineWeight.ToInvariantString(), "pt"), - InsetMode = comment.Style.Margins.Automatic ? InsetMarginValues.Auto : InsetMarginValues.Custom - }; - if (!String.IsNullOrWhiteSpace(comment.Style.Web.AlternateText)) - shape.Alternate = comment.Style.Web.AlternateText; - - return shape; - } - - private static Vml.Stroke GetStroke(XLCell c) - { - var lineDash = c.GetComment().Style.ColorsAndLines.LineDash; - var stroke = new Vml.Stroke - { - LineStyle = c.GetComment().Style.ColorsAndLines.LineStyle.ToOpenXml(), - DashStyle = - lineDash == XLDashStyle.RoundDot || lineDash == XLDashStyle.SquareDot - ? "shortDot" - : lineDash.ToString().ToCamel() - }; - if (lineDash == XLDashStyle.RoundDot) - stroke.EndCap = Vml.StrokeEndCapValues.Round; - if (c.GetComment().Style.ColorsAndLines.LineTransparency < 1) - stroke.Opacity = - Math.Round(Convert.ToDouble(c.GetComment().Style.ColorsAndLines.LineTransparency), 2).ToInvariantString(); - return stroke; - } - - // http://polymathprogrammer.com/2009/10/22/english-metric-units-and-open-xml/ - // http://archive.oreilly.com/pub/post/what_is_an_emu.html - // https://en.wikipedia.org/wiki/Office_Open_XML_file_formats#DrawingML - private static Int64 ConvertToEnglishMetricUnits(Int32 pixels, Double resolution) - { - return Convert.ToInt64(914400L * pixels / resolution); - } - - private static void AddPictureAnchor(WorksheetPart worksheetPart, Drawings.IXLPicture picture, SaveContext context) - { - var pic = picture as Drawings.XLPicture; - var drawingsPart = worksheetPart.DrawingsPart ?? - worksheetPart.AddNewPart(context.RelIdGenerator.GetNext(RelType.Workbook)); - - if (drawingsPart.WorksheetDrawing == null) - drawingsPart.WorksheetDrawing = new Xdr.WorksheetDrawing(); - - var worksheetDrawing = drawingsPart.WorksheetDrawing; - - // Add namespaces - if (!worksheetDrawing.NamespaceDeclarations.Any(nd => nd.Value.Equals("http://schemas.openxmlformats.org/drawingml/2006/main"))) - worksheetDrawing.AddNamespaceDeclaration("a", "http://schemas.openxmlformats.org/drawingml/2006/main"); - - if (!worksheetDrawing.NamespaceDeclarations.Any(nd => nd.Value.Equals("http://schemas.openxmlformats.org/officeDocument/2006/relationships"))) - worksheetDrawing.AddNamespaceDeclaration("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); - ///////// - - // Overwrite actual image binary data - ImagePart imagePart; - if (drawingsPart.HasPartWithId(pic.RelId)) - imagePart = drawingsPart.GetPartById(pic.RelId) as ImagePart; - else - { - pic.RelId = context.RelIdGenerator.GetNext(RelType.Workbook); - imagePart = drawingsPart.AddImagePart(pic.Format.ToOpenXml(), pic.RelId); - } - - using (var stream = new MemoryStream()) - { - pic.ImageStream.Position = 0; - pic.ImageStream.CopyTo(stream); - stream.Seek(0, SeekOrigin.Begin); - imagePart.FeedData(stream); - } - ///////// - - // Clear current anchors - var existingAnchor = GetAnchorFromImageId(worksheetPart, pic.RelId); - if (existingAnchor != null) - worksheetDrawing.RemoveChild(existingAnchor); - - var wb = pic.Worksheet.Workbook; - var extentsCx = ConvertToEnglishMetricUnits(pic.Width, wb.DpiX); - var extentsCy = ConvertToEnglishMetricUnits(pic.Height, wb.DpiY); - - var nvps = worksheetDrawing.Descendants(); - var nvpId = nvps.Any() ? - (UInt32Value)worksheetDrawing.Descendants().Max(p => p.Id.Value) + 1 : - 1U; - - Xdr.FromMarker fMark; - Xdr.ToMarker tMark; - switch (pic.Placement) - { - case Drawings.XLPicturePlacement.FreeFloating: - var absoluteAnchor = new Xdr.AbsoluteAnchor( - new Xdr.Position - { - X = ConvertToEnglishMetricUnits(pic.Left, wb.DpiX), - Y = ConvertToEnglishMetricUnits(pic.Top, wb.DpiY) - }, - new Xdr.Extent - { - Cx = extentsCx, - Cy = extentsCy - }, - new Xdr.Picture( - new Xdr.NonVisualPictureProperties( - new Xdr.NonVisualDrawingProperties { Id = nvpId, Name = pic.Name }, - new Xdr.NonVisualPictureDrawingProperties(new PictureLocks { NoChangeAspect = true }) - ), - new Xdr.BlipFill( - new Blip { Embed = drawingsPart.GetIdOfPart(imagePart), CompressionState = BlipCompressionValues.Print }, - new Stretch(new FillRectangle()) - ), - new Xdr.ShapeProperties( - new Transform2D( - new Offset { X = 0, Y = 0 }, - new Extents { Cx = extentsCx, Cy = extentsCy } - ), - new PresetGeometry { Preset = ShapeTypeValues.Rectangle } - ) - ), - new Xdr.ClientData() - ); - - worksheetDrawing.Append(absoluteAnchor); - break; - - case Drawings.XLPicturePlacement.MoveAndSize: - var moveAndSizeFromMarker = pic.Markers[Drawings.XLMarkerPosition.TopLeft]; - if (moveAndSizeFromMarker == null) moveAndSizeFromMarker = new Drawings.XLMarker(picture.Worksheet.Cell("A1")); - fMark = new Xdr.FromMarker - { - ColumnId = new Xdr.ColumnId((moveAndSizeFromMarker.ColumnNumber - 1).ToInvariantString()), - RowId = new Xdr.RowId((moveAndSizeFromMarker.RowNumber - 1).ToInvariantString()), - ColumnOffset = new Xdr.ColumnOffset(ConvertToEnglishMetricUnits(moveAndSizeFromMarker.Offset.X, wb.DpiX).ToInvariantString()), - RowOffset = new Xdr.RowOffset(ConvertToEnglishMetricUnits(moveAndSizeFromMarker.Offset.Y, wb.DpiY).ToInvariantString()) - }; - - var moveAndSizeToMarker = pic.Markers[Drawings.XLMarkerPosition.BottomRight]; - if (moveAndSizeToMarker == null) moveAndSizeToMarker = new Drawings.XLMarker(picture.Worksheet.Cell("A1"), new System.Drawing.Point(picture.Width, picture.Height)); - tMark = new Xdr.ToMarker - { - ColumnId = new Xdr.ColumnId((moveAndSizeToMarker.ColumnNumber - 1).ToInvariantString()), - RowId = new Xdr.RowId((moveAndSizeToMarker.RowNumber - 1).ToInvariantString()), - ColumnOffset = new Xdr.ColumnOffset(ConvertToEnglishMetricUnits(moveAndSizeToMarker.Offset.X, wb.DpiX).ToInvariantString()), - RowOffset = new Xdr.RowOffset(ConvertToEnglishMetricUnits(moveAndSizeToMarker.Offset.Y, wb.DpiY).ToInvariantString()) - }; - - var twoCellAnchor = new Xdr.TwoCellAnchor( - fMark, - tMark, - new Xdr.Picture( - new Xdr.NonVisualPictureProperties( - new Xdr.NonVisualDrawingProperties { Id = nvpId, Name = pic.Name }, - new Xdr.NonVisualPictureDrawingProperties(new PictureLocks { NoChangeAspect = true }) - ), - new Xdr.BlipFill( - new Blip { Embed = drawingsPart.GetIdOfPart(imagePart), CompressionState = BlipCompressionValues.Print }, - new Stretch(new FillRectangle()) - ), - new Xdr.ShapeProperties( - new Transform2D( - new Offset { X = 0, Y = 0 }, - new Extents { Cx = extentsCx, Cy = extentsCy } - ), - new PresetGeometry { Preset = ShapeTypeValues.Rectangle } - ) - ), - new Xdr.ClientData() - ); - - worksheetDrawing.Append(twoCellAnchor); - break; - - case Drawings.XLPicturePlacement.Move: - var moveFromMarker = pic.Markers[Drawings.XLMarkerPosition.TopLeft]; - if (moveFromMarker == null) moveFromMarker = new Drawings.XLMarker(picture.Worksheet.Cell("A1")); - fMark = new Xdr.FromMarker - { - ColumnId = new Xdr.ColumnId((moveFromMarker.ColumnNumber - 1).ToInvariantString()), - RowId = new Xdr.RowId((moveFromMarker.RowNumber - 1).ToInvariantString()), - ColumnOffset = new Xdr.ColumnOffset(ConvertToEnglishMetricUnits(moveFromMarker.Offset.X, wb.DpiX).ToInvariantString()), - RowOffset = new Xdr.RowOffset(ConvertToEnglishMetricUnits(moveFromMarker.Offset.Y, wb.DpiY).ToInvariantString()) - }; - - var oneCellAnchor = new Xdr.OneCellAnchor( - fMark, - new Xdr.Extent - { - Cx = extentsCx, - Cy = extentsCy - }, - new Xdr.Picture( - new Xdr.NonVisualPictureProperties( - new Xdr.NonVisualDrawingProperties { Id = nvpId, Name = pic.Name }, - new Xdr.NonVisualPictureDrawingProperties(new PictureLocks { NoChangeAspect = true }) - ), - new Xdr.BlipFill( - new Blip { Embed = drawingsPart.GetIdOfPart(imagePart), CompressionState = BlipCompressionValues.Print }, - new Stretch(new FillRectangle()) - ), - new Xdr.ShapeProperties( - new Transform2D( - new Offset { X = 0, Y = 0 }, - new Extents { Cx = extentsCx, Cy = extentsCy } - ), - new PresetGeometry { Preset = ShapeTypeValues.Rectangle } - ) - ), - new Xdr.ClientData() - ); - - worksheetDrawing.Append(oneCellAnchor); - break; - } - } - - private static void RebaseNonVisualDrawingPropertiesIds(WorksheetPart worksheetPart) - { - var worksheetDrawing = worksheetPart.DrawingsPart.WorksheetDrawing; - - var toRebase = worksheetDrawing.Descendants() - .ToList(); - - toRebase.ForEach(nvdpr => nvdpr.Id = Convert.ToUInt32(toRebase.IndexOf(nvdpr) + 1)); - } - - private static Vml.TextBox GetTextBox(IXLDrawingStyle ds) - { - var sb = new StringBuilder(); - var a = ds.Alignment; - - if (a.Direction == XLDrawingTextDirection.Context) - sb.Append("mso-direction-alt:auto;"); - else if (a.Direction == XLDrawingTextDirection.RightToLeft) - sb.Append("direction:RTL;"); - - if (a.Orientation != XLDrawingTextOrientation.LeftToRight) - { - sb.Append("layout-flow:vertical;"); - if (a.Orientation == XLDrawingTextOrientation.BottomToTop) - sb.Append("mso-layout-flow-alt:bottom-to-top;"); - else if (a.Orientation == XLDrawingTextOrientation.Vertical) - sb.Append("mso-layout-flow-alt:top-to-bottom;"); - } - if (a.AutomaticSize) - sb.Append("mso-fit-shape-to-text:t;"); - - var tb = new Vml.TextBox(); - - if (sb.Length > 0) - tb.Style = sb.ToString(); - - var dm = ds.Margins; - if (!dm.Automatic) - tb.Inset = String.Concat( - dm.Left.ToInvariantString(), "in,", - dm.Top.ToInvariantString(), "in,", - dm.Right.ToInvariantString(), "in,", - dm.Bottom.ToInvariantString(), "in"); - - return tb; - } - - private static Anchor GetAnchor(XLCell cell) - { - var c = cell.GetComment(); - var cWidth = c.Style.Size.Width; - var fcNumber = c.Position.Column - 1; - var fcOffset = Convert.ToInt32(c.Position.ColumnOffset * 7.5); - var widthFromColumns = cell.Worksheet.Column(c.Position.Column).Width - c.Position.ColumnOffset; - var lastCell = cell.CellRight(c.Position.Column - cell.Address.ColumnNumber); - while (widthFromColumns <= cWidth) - { - lastCell = lastCell.CellRight(); - widthFromColumns += lastCell.WorksheetColumn().Width; - } - - var lcNumber = lastCell.WorksheetColumn().ColumnNumber() - 1; - var lcOffset = Convert.ToInt32((lastCell.WorksheetColumn().Width - (widthFromColumns - cWidth)) * 7.5); - - var cHeight = c.Style.Size.Height; //c.Style.Size.Height * 72.0; - var frNumber = c.Position.Row - 1; - var frOffset = Convert.ToInt32(c.Position.RowOffset); - var heightFromRows = cell.Worksheet.Row(c.Position.Row).Height - c.Position.RowOffset; - lastCell = cell.CellBelow(c.Position.Row - cell.Address.RowNumber); - while (heightFromRows <= cHeight) - { - lastCell = lastCell.CellBelow(); - heightFromRows += lastCell.WorksheetRow().Height; - } - - var lrNumber = lastCell.WorksheetRow().RowNumber() - 1; - var lrOffset = Convert.ToInt32(lastCell.WorksheetRow().Height - (heightFromRows - cHeight)); - return new Anchor - { - Text = string.Concat( - fcNumber, ", ", fcOffset, ", ", - frNumber, ", ", frOffset, ", ", - lcNumber, ", ", lcOffset, ", ", - lrNumber, ", ", lrOffset - ) - }; - } - - private static StringValue GetCommentStyle(XLCell cell) - { - var c = cell.GetComment(); - var sb = new StringBuilder("position:absolute; "); - - sb.Append("visibility:"); - sb.Append(c.Visible ? "visible" : "hidden"); - sb.Append(";"); - - sb.Append("width:"); - sb.Append(Math.Round(c.Style.Size.Width * 7.5, 2).ToInvariantString()); - sb.Append("pt;"); - sb.Append("height:"); - sb.Append(Math.Round(c.Style.Size.Height, 2).ToInvariantString()); - sb.Append("pt;"); - - sb.Append("z-index:"); - sb.Append(c.ZOrder.ToInvariantString()); - - return sb.ToString(); - } - - #region GenerateWorkbookStylesPartContent - - private void GenerateWorkbookStylesPartContent(WorkbookStylesPart workbookStylesPart, SaveContext context) - { - var defaultStyle = DefaultStyleValue; - - if (!context.SharedFonts.ContainsKey(defaultStyle.Font)) - context.SharedFonts.Add(defaultStyle.Font, new FontInfo { FontId = 0, Font = defaultStyle.Font }); - - if (workbookStylesPart.Stylesheet == null) - workbookStylesPart.Stylesheet = new Stylesheet(); - - // Cell styles = Named styles - if (workbookStylesPart.Stylesheet.CellStyles == null) - workbookStylesPart.Stylesheet.CellStyles = new CellStyles(); - - // To determine the default workbook style, we look for the style with builtInId = 0 (I hope that is the correct approach) - UInt32 defaultFormatId; - if (workbookStylesPart.Stylesheet.CellStyles.Elements().Any(c => c.BuiltinId != null && c.BuiltinId.HasValue && c.BuiltinId.Value == 0)) - { - // Possible to have duplicate default cell styles - occurs when file gets saved under different cultures. - // We prefer the style that is named Normal - var normalCellStyles = workbookStylesPart.Stylesheet.CellStyles.Elements() - .Where(c => c.BuiltinId != null && c.BuiltinId.HasValue && c.BuiltinId.Value == 0) - .OrderBy(c => c.Name != null && c.Name.HasValue && c.Name.Value == "Normal"); - - defaultFormatId = normalCellStyles.Last().FormatId.Value; - } - else if (workbookStylesPart.Stylesheet.CellStyles.Elements().Any()) - defaultFormatId = workbookStylesPart.Stylesheet.CellStyles.Elements().Max(c => c.FormatId.Value) + 1; - else - defaultFormatId = 0; - - context.SharedStyles.Add(defaultStyle, - new StyleInfo - { - StyleId = defaultFormatId, - Style = defaultStyle, - FontId = 0, - FillId = 0, - BorderId = 0, - IncludeQuotePrefix = false, - NumberFormatId = 0 - //AlignmentId = 0 - }); - - UInt32 styleCount = 1; - UInt32 fontCount = 1; - UInt32 fillCount = 3; - UInt32 borderCount = 1; - var numberFormatCount = 0; // 0-based - var pivotTableNumberFormats = new HashSet(); - var xlStyles = new HashSet(); - - foreach (var worksheet in WorksheetsInternal) - { - xlStyles.Add(worksheet.StyleValue); - foreach (var s in worksheet.Internals.ColumnsCollection.Select(c => c.Value.StyleValue)) - { - xlStyles.Add(s); - } - foreach (var s in worksheet.Internals.RowsCollection.Select(r => r.Value.StyleValue)) - { - xlStyles.Add(s); - } - - foreach (var s in worksheet.Internals.CellsCollection.GetCells().Select(c => c.StyleValue)) - { - xlStyles.Add(s); - } - - foreach (var ptnf in worksheet.PivotTables.SelectMany(pt => pt.Values.Select(ptv => ptv.NumberFormat)).Distinct().Where(nf => !pivotTableNumberFormats.Contains(nf))) - pivotTableNumberFormats.Add(ptnf); - } - - var alignments = xlStyles.Select(s => s.Alignment).Distinct().ToList(); - var borders = xlStyles.Select(s => s.Border).Distinct().ToList(); - var fonts = xlStyles.Select(s => s.Font).Distinct().ToList(); - var fills = xlStyles.Select(s => s.Fill).Distinct().ToList(); - var numberFormats = xlStyles.Select(s => s.NumberFormat).Distinct().ToList(); - var protections = xlStyles.Select(s => s.Protection).Distinct().ToList(); - - for (int i = 0; i < fonts.Count; i++) - { - if (!context.SharedFonts.ContainsKey(fonts[i])) - { - context.SharedFonts.Add(fonts[i], new FontInfo { FontId = (uint)fontCount++, Font = fonts[i] }); - } - } - - var sharedFills = fills.ToDictionary( - f => f, f => new FillInfo { FillId = fillCount++, Fill = f }); - - var sharedBorders = borders.ToDictionary( - b => b, b => new BorderInfo { BorderId = borderCount++, Border = b }); - - var sharedNumberFormats = numberFormats - .Where(nf => nf.NumberFormatId == -1) - .ToDictionary(nf => nf, nf => new NumberFormatInfo - { - NumberFormatId = XLConstants.NumberOfBuiltInStyles + numberFormatCount++, - NumberFormat = nf - }); - - foreach (var pivotNumberFormat in pivotTableNumberFormats.Where(nf => nf.NumberFormatId == -1)) - { - var numberFormatKey = new XLNumberFormatKey - { - NumberFormatId = -1, - Format = pivotNumberFormat.Format - }; - var numberFormat = XLNumberFormatValue.FromKey(ref numberFormatKey); - - if (sharedNumberFormats.ContainsKey(numberFormat)) - continue; - - sharedNumberFormats.Add(numberFormat, - new NumberFormatInfo - { - NumberFormatId = XLConstants.NumberOfBuiltInStyles + numberFormatCount++, - NumberFormat = numberFormat - }); - } - - var allSharedNumberFormats = ResolveNumberFormats(workbookStylesPart, sharedNumberFormats, defaultFormatId); - foreach (var nf in allSharedNumberFormats) - { - context.SharedNumberFormats.Add(nf.Value.NumberFormatId, nf.Value); - } - - ResolveFonts(workbookStylesPart, context); - var allSharedFills = ResolveFills(workbookStylesPart, sharedFills); - var allSharedBorders = ResolveBorders(workbookStylesPart, sharedBorders); - - foreach (var xlStyle in xlStyles) - { - var numberFormatId = xlStyle.NumberFormat.NumberFormatId >= 0 - ? xlStyle.NumberFormat.NumberFormatId - : allSharedNumberFormats[xlStyle.NumberFormat].NumberFormatId; - - if (!context.SharedStyles.ContainsKey(xlStyle)) - context.SharedStyles.Add(xlStyle, - new StyleInfo - { - StyleId = styleCount++, - Style = xlStyle, - FontId = context.SharedFonts[xlStyle.Font].FontId, - FillId = allSharedFills[xlStyle.Fill].FillId, - BorderId = allSharedBorders[xlStyle.Border].BorderId, - NumberFormatId = numberFormatId, - IncludeQuotePrefix = xlStyle.IncludeQuotePrefix - }); - } - - ResolveCellStyleFormats(workbookStylesPart, context); - ResolveRest(workbookStylesPart, context); - - if (!workbookStylesPart.Stylesheet.CellStyles.Elements().Any(c => c.BuiltinId != null && c.BuiltinId.HasValue && c.BuiltinId.Value == 0U)) - workbookStylesPart.Stylesheet.CellStyles.AppendChild(new CellStyle { Name = "Normal", FormatId = defaultFormatId, BuiltinId = 0U }); - - workbookStylesPart.Stylesheet.CellStyles.Count = (UInt32)workbookStylesPart.Stylesheet.CellStyles.Count(); - - var newSharedStyles = new Dictionary(); - foreach (var ss in context.SharedStyles) - { - var styleId = -1; - foreach (CellFormat f in workbookStylesPart.Stylesheet.CellFormats) - { - styleId++; - if (CellFormatsAreEqual(f, ss.Value, compareAlignment: true)) - break; - } - if (styleId == -1) - styleId = 0; - var si = ss.Value; - si.StyleId = (UInt32)styleId; - newSharedStyles.Add(ss.Key, si); - } - context.SharedStyles.Clear(); - newSharedStyles.ForEach(kp => context.SharedStyles.Add(kp.Key, kp.Value)); - - AddDifferentialFormats(workbookStylesPart, context); - } - - /// - /// Populates the differential formats that are currently in the file to the SaveContext - /// - /// The workbook styles part. - /// The context. - private void AddDifferentialFormats(WorkbookStylesPart workbookStylesPart, SaveContext context) - { - if (workbookStylesPart.Stylesheet.DifferentialFormats == null) - workbookStylesPart.Stylesheet.DifferentialFormats = new DifferentialFormats(); - - var differentialFormats = workbookStylesPart.Stylesheet.DifferentialFormats; - differentialFormats.RemoveAllChildren(); - FillDifferentialFormatsCollection(differentialFormats, context.DifferentialFormats); - - foreach (var ws in Worksheets) - { - foreach (var cf in ws.ConditionalFormats) - { - var styleValue = (cf.Style as XLStyle).Value; - if (!styleValue.Equals(DefaultStyleValue) && !context.DifferentialFormats.ContainsKey(styleValue)) - AddConditionalDifferentialFormat(workbookStylesPart.Stylesheet.DifferentialFormats, cf, context); - } - - foreach (var tf in ws.Tables.SelectMany(t => t.Fields)) - { - if (tf.IsConsistentStyle()) - { - var style = (tf.Column.Cells() - .Skip(tf.Table.ShowHeaderRow ? 1 : 0) - .First() - .Style as XLStyle).Value; - - if (!style.Equals(DefaultStyleValue) && !context.DifferentialFormats.ContainsKey(style)) - AddStyleAsDifferentialFormat(workbookStylesPart.Stylesheet.DifferentialFormats, style, context); - } - } - - foreach (var pt in ws.PivotTables.Cast()) - { - foreach (var styleFormat in pt.AllStyleFormats) - { - var xlStyle = (XLStyle)styleFormat.Style; - if (!xlStyle.Value.Equals(DefaultStyleValue) && !context.DifferentialFormats.ContainsKey(xlStyle.Value)) - AddStyleAsDifferentialFormat(workbookStylesPart.Stylesheet.DifferentialFormats, xlStyle.Value, context); - } - } - } - - differentialFormats.Count = (UInt32)differentialFormats.Count(); - if (differentialFormats.Count == 0) - workbookStylesPart.Stylesheet.DifferentialFormats = null; - } - - private void FillDifferentialFormatsCollection(DifferentialFormats differentialFormats, - Dictionary dictionary) - { - dictionary.Clear(); - var id = 0; - - foreach (var df in differentialFormats.Elements()) - { - var emptyContainer = new XLStylizedEmpty(DefaultStyle); - - var style = new XLStyle(emptyContainer, DefaultStyle); - LoadFont(df.Font, emptyContainer.Style.Font); - LoadBorder(df.Border, emptyContainer.Style.Border); - LoadNumberFormat(df.NumberingFormat, emptyContainer.Style.NumberFormat); - LoadFill(df.Fill, emptyContainer.Style.Fill, differentialFillFormat: true); - - if (!dictionary.ContainsKey(emptyContainer.StyleValue)) - dictionary.Add(emptyContainer.StyleValue, id++); - } - } - - private static void AddConditionalDifferentialFormat(DifferentialFormats differentialFormats, IXLConditionalFormat cf, - SaveContext context) - { - var differentialFormat = new DifferentialFormat(); - var styleValue = (cf.Style as XLStyle).Value; - - var diffFont = GetNewFont(new FontInfo { Font = styleValue.Font }, false); - if (diffFont?.HasChildren ?? false) - differentialFormat.Append(diffFont); - - if (!String.IsNullOrWhiteSpace(cf.Style.NumberFormat.Format)) - { - var numberFormat = new NumberingFormat - { - NumberFormatId = (UInt32)(XLConstants.NumberOfBuiltInStyles + differentialFormats.Count()), - FormatCode = cf.Style.NumberFormat.Format - }; - differentialFormat.Append(numberFormat); - } - - var diffFill = GetNewFill(new FillInfo { Fill = styleValue.Fill }, differentialFillFormat: true, ignoreMod: false); - if (diffFill?.HasChildren ?? false) - differentialFormat.Append(diffFill); - - var diffBorder = GetNewBorder(new BorderInfo { Border = styleValue.Border }, false); - if (diffBorder?.HasChildren ?? false) - differentialFormat.Append(diffBorder); - - differentialFormats.Append(differentialFormat); - - context.DifferentialFormats.Add(styleValue, differentialFormats.Count() - 1); - } - - private static void AddStyleAsDifferentialFormat(DifferentialFormats differentialFormats, XLStyleValue style, - SaveContext context) - { - var differentialFormat = new DifferentialFormat(); - - var diffFont = GetNewFont(new FontInfo { Font = style.Font }, false); - if (diffFont?.HasChildren ?? false) - differentialFormat.Append(diffFont); - - if (!String.IsNullOrWhiteSpace(style.NumberFormat.Format) || style.NumberFormat.NumberFormatId != 0) - { - var numberFormat = new NumberingFormat(); - - if (style.NumberFormat.NumberFormatId == -1) - { - numberFormat.FormatCode = style.NumberFormat.Format; - numberFormat.NumberFormatId = (UInt32)(XLConstants.NumberOfBuiltInStyles + - differentialFormats - .Descendants() - .Count(df => df.NumberingFormat != null && df.NumberingFormat.NumberFormatId != null && df.NumberingFormat.NumberFormatId.Value >= XLConstants.NumberOfBuiltInStyles)); - } - else - { - numberFormat.NumberFormatId = (UInt32)(style.NumberFormat.NumberFormatId); - if (!string.IsNullOrEmpty(style.NumberFormat.Format)) - numberFormat.FormatCode = style.NumberFormat.Format; - else if (XLPredefinedFormat.FormatCodes.TryGetValue(style.NumberFormat.NumberFormatId, out string formatCode)) - numberFormat.FormatCode = formatCode; - } - - differentialFormat.Append(numberFormat); - } - - var diffFill = GetNewFill(new FillInfo { Fill = style.Fill }, differentialFillFormat: true, ignoreMod: false); - if (diffFill?.HasChildren ?? false) - differentialFormat.Append(diffFill); - - var diffBorder = GetNewBorder(new BorderInfo { Border = style.Border }, false); - if (diffBorder?.HasChildren ?? false) - differentialFormat.Append(diffBorder); - - differentialFormats.Append(differentialFormat); - - context.DifferentialFormats.Add(style, differentialFormats.Count() - 1); - } - - private static void ResolveRest(WorkbookStylesPart workbookStylesPart, SaveContext context) - { - if (workbookStylesPart.Stylesheet.CellFormats == null) - workbookStylesPart.Stylesheet.CellFormats = new CellFormats(); - - foreach (var styleInfo in context.SharedStyles.Values) - { - var info = styleInfo; - var foundOne = - workbookStylesPart.Stylesheet.CellFormats.Cast().Any(f => CellFormatsAreEqual(f, info, compareAlignment: true)); - - if (foundOne) continue; - - var cellFormat = GetCellFormat(styleInfo); - cellFormat.FormatId = 0; - var alignment = new Alignment - { - Horizontal = styleInfo.Style.Alignment.Horizontal.ToOpenXml(), - Vertical = styleInfo.Style.Alignment.Vertical.ToOpenXml(), - Indent = (UInt32)styleInfo.Style.Alignment.Indent, - ReadingOrder = (UInt32)styleInfo.Style.Alignment.ReadingOrder, - WrapText = styleInfo.Style.Alignment.WrapText, - TextRotation = (UInt32)styleInfo.Style.Alignment.TextRotation, - ShrinkToFit = styleInfo.Style.Alignment.ShrinkToFit, - RelativeIndent = styleInfo.Style.Alignment.RelativeIndent, - JustifyLastLine = styleInfo.Style.Alignment.JustifyLastLine - }; - cellFormat.AppendChild(alignment); - - if (cellFormat.ApplyProtection.Value) - cellFormat.AppendChild(GetProtection(styleInfo)); - - workbookStylesPart.Stylesheet.CellFormats.AppendChild(cellFormat); - } - workbookStylesPart.Stylesheet.CellFormats.Count = (UInt32)workbookStylesPart.Stylesheet.CellFormats.Count(); - } - - private static void ResolveCellStyleFormats(WorkbookStylesPart workbookStylesPart, - SaveContext context) - { - if (workbookStylesPart.Stylesheet.CellStyleFormats == null) - workbookStylesPart.Stylesheet.CellStyleFormats = new CellStyleFormats(); - - foreach (var styleInfo in context.SharedStyles.Values) - { - var info = styleInfo; - var foundOne = - workbookStylesPart.Stylesheet.CellStyleFormats.Cast().Any( - f => CellFormatsAreEqual(f, info, compareAlignment: false)); - - if (foundOne) continue; - - var cellStyleFormat = GetCellFormat(styleInfo); - - if (cellStyleFormat.ApplyProtection.Value) - cellStyleFormat.AppendChild(GetProtection(styleInfo)); - - workbookStylesPart.Stylesheet.CellStyleFormats.AppendChild(cellStyleFormat); - } - workbookStylesPart.Stylesheet.CellStyleFormats.Count = - (UInt32)workbookStylesPart.Stylesheet.CellStyleFormats.Count(); - } - - private static bool ApplyFill(StyleInfo styleInfo) - { - return styleInfo.Style.Fill.PatternType.ToOpenXml() == PatternValues.None; - } - - private static bool ApplyBorder(StyleInfo styleInfo) - { - var opBorder = styleInfo.Style.Border; - return (opBorder.BottomBorder.ToOpenXml() != BorderStyleValues.None - || opBorder.DiagonalBorder.ToOpenXml() != BorderStyleValues.None - || opBorder.RightBorder.ToOpenXml() != BorderStyleValues.None - || opBorder.LeftBorder.ToOpenXml() != BorderStyleValues.None - || opBorder.TopBorder.ToOpenXml() != BorderStyleValues.None); - } - - private static bool ApplyProtection(StyleInfo styleInfo) - { - return styleInfo.Style.Protection != null; - } - - private static CellFormat GetCellFormat(StyleInfo styleInfo) - { - var cellFormat = new CellFormat - { - NumberFormatId = (UInt32)styleInfo.NumberFormatId, - FontId = styleInfo.FontId, - FillId = styleInfo.FillId, - BorderId = styleInfo.BorderId, - QuotePrefix = OpenXmlHelper.GetBooleanValue(styleInfo.IncludeQuotePrefix, false), - ApplyNumberFormat = true, - ApplyAlignment = true, - ApplyFill = ApplyFill(styleInfo), - ApplyBorder = ApplyBorder(styleInfo), - ApplyProtection = ApplyProtection(styleInfo) - }; - return cellFormat; - } - - private static Protection GetProtection(StyleInfo styleInfo) - { - return new Protection - { - Locked = styleInfo.Style.Protection.Locked, - Hidden = styleInfo.Style.Protection.Hidden - }; - } - - /// - /// Check if two style are equivalent. - /// - /// Style in the OpenXML format. - /// Style in the ClosedXML format. - /// Flag specifying whether or not compare the alignments of two styles. - /// Styles in x:cellStyleXfs section do not include alignment so we don't have to compare it in this case. - /// Styles in x:cellXfs section, on the opposite, do include alignments, and we must compare them. - /// - /// True if two formats are equivalent, false otherwise. - private static bool CellFormatsAreEqual(CellFormat f, StyleInfo styleInfo, bool compareAlignment) - { - return - f.BorderId != null && styleInfo.BorderId == f.BorderId - && f.FillId != null && styleInfo.FillId == f.FillId - && f.FontId != null && styleInfo.FontId == f.FontId - && f.NumberFormatId != null && styleInfo.NumberFormatId == f.NumberFormatId - && QuotePrefixesAreEqual(f.QuotePrefix, styleInfo.IncludeQuotePrefix) - && (f.ApplyFill == null && styleInfo.Style.Fill == XLFillValue.Default || - f.ApplyFill != null && f.ApplyFill == ApplyFill(styleInfo)) - && (f.ApplyBorder == null && styleInfo.Style.Border == XLBorderValue.Default || - f.ApplyBorder != null && f.ApplyBorder == ApplyBorder(styleInfo)) - && (!compareAlignment || AlignmentsAreEqual(f.Alignment, styleInfo.Style.Alignment)) - && ProtectionsAreEqual(f.Protection, styleInfo.Style.Protection) - ; - } - - private static bool ProtectionsAreEqual(Protection protection, XLProtectionValue xlProtection) - { - var p = XLProtectionValue.Default.Key; - if (protection != null) - { - if (protection.Locked != null) - p.Locked = protection.Locked.Value; - if (protection.Hidden != null) - p.Hidden = protection.Hidden.Value; - } - return p.Equals(xlProtection.Key); - } - - private static bool QuotePrefixesAreEqual(BooleanValue quotePrefix, Boolean includeQuotePrefix) - { - return OpenXmlHelper.GetBooleanValueAsBool(quotePrefix, false) == includeQuotePrefix; - } - - private static bool AlignmentsAreEqual(Alignment alignment, XLAlignmentValue xlAlignment) - { - if (alignment != null) - { - var a = XLAlignmentValue.Default.Key; - if (alignment.Indent != null) - a.Indent = (Int32)alignment.Indent.Value; - - if (alignment.Horizontal != null) - a.Horizontal = alignment.Horizontal.Value.ToClosedXml(); - if (alignment.Vertical != null) - a.Vertical = alignment.Vertical.Value.ToClosedXml(); - - if (alignment.ReadingOrder != null) - a.ReadingOrder = alignment.ReadingOrder.Value.ToClosedXml(); - if (alignment.WrapText != null) - a.WrapText = alignment.WrapText.Value; - if (alignment.TextRotation != null) - a.TextRotation = (Int32)alignment.TextRotation.Value; - if (alignment.ShrinkToFit != null) - a.ShrinkToFit = alignment.ShrinkToFit.Value; - if (alignment.RelativeIndent != null) - a.RelativeIndent = alignment.RelativeIndent.Value; - if (alignment.JustifyLastLine != null) - a.JustifyLastLine = alignment.JustifyLastLine.Value; - return a.Equals(xlAlignment.Key); - } - else - { - return XLStyle.Default.Value.Alignment.Equals(xlAlignment); - } - } - - private Dictionary ResolveBorders(WorkbookStylesPart workbookStylesPart, - Dictionary sharedBorders) - { - if (workbookStylesPart.Stylesheet.Borders == null) - workbookStylesPart.Stylesheet.Borders = new Borders(); - - var allSharedBorders = new Dictionary(); - foreach (var borderInfo in sharedBorders.Values) - { - var borderId = 0; - var foundOne = false; - foreach (Border f in workbookStylesPart.Stylesheet.Borders) - { - if (BordersAreEqual(f, borderInfo.Border)) - { - foundOne = true; - break; - } - borderId++; - } - if (!foundOne) - { - var border = GetNewBorder(borderInfo); - workbookStylesPart.Stylesheet.Borders.AppendChild(border); - } - allSharedBorders.Add(borderInfo.Border, - new BorderInfo { Border = borderInfo.Border, BorderId = (UInt32)borderId }); - } - workbookStylesPart.Stylesheet.Borders.Count = (UInt32)workbookStylesPart.Stylesheet.Borders.Count(); - return allSharedBorders; - } - - private static Border GetNewBorder(BorderInfo borderInfo, Boolean ignoreMod = true) - { - var border = new Border(); - if (borderInfo.Border.DiagonalUp != XLBorderValue.Default.DiagonalUp || ignoreMod) - border.DiagonalUp = borderInfo.Border.DiagonalUp; - - if (borderInfo.Border.DiagonalDown != XLBorderValue.Default.DiagonalDown || ignoreMod) - border.DiagonalDown = borderInfo.Border.DiagonalDown; - - if (borderInfo.Border.LeftBorder != XLBorderValue.Default.LeftBorder || ignoreMod) - { - var leftBorder = new LeftBorder { Style = borderInfo.Border.LeftBorder.ToOpenXml() }; - if (borderInfo.Border.LeftBorderColor != XLBorderValue.Default.LeftBorderColor || ignoreMod) - { - var leftBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.LeftBorderColor); - leftBorder.AppendChild(leftBorderColor); - } - border.AppendChild(leftBorder); - } - - if (borderInfo.Border.RightBorder != XLBorderValue.Default.RightBorder || ignoreMod) - { - var rightBorder = new RightBorder { Style = borderInfo.Border.RightBorder.ToOpenXml() }; - if (borderInfo.Border.RightBorderColor != XLBorderValue.Default.RightBorderColor || ignoreMod) - { - var rightBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.RightBorderColor); - rightBorder.AppendChild(rightBorderColor); - } - border.AppendChild(rightBorder); - } - - if (borderInfo.Border.TopBorder != XLBorderValue.Default.TopBorder || ignoreMod) - { - var topBorder = new TopBorder { Style = borderInfo.Border.TopBorder.ToOpenXml() }; - if (borderInfo.Border.TopBorderColor != XLBorderValue.Default.TopBorderColor || ignoreMod) - { - var topBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.TopBorderColor); - topBorder.AppendChild(topBorderColor); - } - border.AppendChild(topBorder); - } - - if (borderInfo.Border.BottomBorder != XLBorderValue.Default.BottomBorder || ignoreMod) - { - var bottomBorder = new BottomBorder { Style = borderInfo.Border.BottomBorder.ToOpenXml() }; - if (borderInfo.Border.BottomBorderColor != XLBorderValue.Default.BottomBorderColor || ignoreMod) - { - var bottomBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.BottomBorderColor); - bottomBorder.AppendChild(bottomBorderColor); - } - border.AppendChild(bottomBorder); - } - - if (borderInfo.Border.DiagonalBorder != XLBorderValue.Default.DiagonalBorder || ignoreMod) - { - var DiagonalBorder = new DiagonalBorder { Style = borderInfo.Border.DiagonalBorder.ToOpenXml() }; - if (borderInfo.Border.DiagonalBorderColor != XLBorderValue.Default.DiagonalBorderColor || ignoreMod) - if (borderInfo.Border.DiagonalBorderColor != null) - { - var DiagonalBorderColor = new Color().FromClosedXMLColor(borderInfo.Border.DiagonalBorderColor); - DiagonalBorder.AppendChild(DiagonalBorderColor); - } - border.AppendChild(DiagonalBorder); - } - - return border; - } - - private bool BordersAreEqual(Border b, XLBorderValue xlBorder) - { - var nb = XLBorderValue.Default.Key; - if (b.DiagonalUp != null) - nb.DiagonalUp = b.DiagonalUp.Value; - - if (b.DiagonalDown != null) - nb.DiagonalDown = b.DiagonalDown.Value; - - if (b.DiagonalBorder != null) - { - if (b.DiagonalBorder.Style != null) - nb.DiagonalBorder = b.DiagonalBorder.Style.Value.ToClosedXml(); - if (b.DiagonalBorder.Color != null) - nb.DiagonalBorderColor = b.DiagonalBorder.Color.ToClosedXMLColor(_colorList).Key; - } - - if (b.LeftBorder != null) - { - if (b.LeftBorder.Style != null) - nb.LeftBorder = b.LeftBorder.Style.Value.ToClosedXml(); - if (b.LeftBorder.Color != null) - nb.LeftBorderColor = b.LeftBorder.Color.ToClosedXMLColor(_colorList).Key; - } - - if (b.RightBorder != null) - { - if (b.RightBorder.Style != null) - nb.RightBorder = b.RightBorder.Style.Value.ToClosedXml(); - if (b.RightBorder.Color != null) - nb.RightBorderColor = b.RightBorder.Color.ToClosedXMLColor(_colorList).Key; - } - - if (b.TopBorder != null) - { - if (b.TopBorder.Style != null) - nb.TopBorder = b.TopBorder.Style.Value.ToClosedXml(); - if (b.TopBorder.Color != null) - nb.TopBorderColor = b.TopBorder.Color.ToClosedXMLColor(_colorList).Key; - } - - if (b.BottomBorder != null) - { - if (b.BottomBorder.Style != null) - nb.BottomBorder = b.BottomBorder.Style.Value.ToClosedXml(); - if (b.BottomBorder.Color != null) - nb.BottomBorderColor = b.BottomBorder.Color.ToClosedXMLColor(_colorList).Key; - } - - return nb.Equals(xlBorder.Key); - } - - private Dictionary ResolveFills(WorkbookStylesPart workbookStylesPart, - Dictionary sharedFills) - { - if (workbookStylesPart.Stylesheet.Fills == null) - workbookStylesPart.Stylesheet.Fills = new Fills(); - - ResolveFillWithPattern(workbookStylesPart.Stylesheet.Fills, PatternValues.None); - ResolveFillWithPattern(workbookStylesPart.Stylesheet.Fills, PatternValues.Gray125); - - var allSharedFills = new Dictionary(); - foreach (var fillInfo in sharedFills.Values) - { - var fillId = 0; - var foundOne = false; - foreach (Fill f in workbookStylesPart.Stylesheet.Fills) - { - if (FillsAreEqual(f, fillInfo.Fill, fromDifferentialFormat: false)) - { - foundOne = true; - break; - } - fillId++; - } - if (!foundOne) - { - var fill = GetNewFill(fillInfo, differentialFillFormat: false); - workbookStylesPart.Stylesheet.Fills.AppendChild(fill); - } - allSharedFills.Add(fillInfo.Fill, new FillInfo { Fill = fillInfo.Fill, FillId = (UInt32)fillId }); - } - - workbookStylesPart.Stylesheet.Fills.Count = (UInt32)workbookStylesPart.Stylesheet.Fills.Count(); - return allSharedFills; - } - - private static void ResolveFillWithPattern(Fills fills, PatternValues patternValues) - { - if (fills.Elements().Any(f => - f.PatternFill == null - || (f.PatternFill.PatternType == patternValues - && f.PatternFill.ForegroundColor == null - && f.PatternFill.BackgroundColor == null - ))) - return; - - var fill1 = new Fill(); - var patternFill1 = new PatternFill { PatternType = patternValues }; - fill1.AppendChild(patternFill1); - fills.AppendChild(fill1); - } - - private static Fill GetNewFill(FillInfo fillInfo, Boolean differentialFillFormat, Boolean ignoreMod = true) - { - var fill = new Fill(); - - var patternFill = new PatternFill(); - - patternFill.PatternType = fillInfo.Fill.PatternType.ToOpenXml(); - - BackgroundColor backgroundColor; - ForegroundColor foregroundColor; - - switch (fillInfo.Fill.PatternType) - { - case XLFillPatternValues.None: - break; - - case XLFillPatternValues.Solid: - - if (differentialFillFormat) - { - patternFill.AppendChild(new ForegroundColor { Auto = true }); - backgroundColor = new BackgroundColor().FromClosedXMLColor(fillInfo.Fill.BackgroundColor, true); - if (backgroundColor.HasAttributes) - patternFill.AppendChild(backgroundColor); - } - else - { - // ClosedXML Background color to be populated into OpenXML fgColor - foregroundColor = new ForegroundColor().FromClosedXMLColor(fillInfo.Fill.BackgroundColor); - if (foregroundColor.HasAttributes) - patternFill.AppendChild(foregroundColor); - } - break; - - default: - - foregroundColor = new ForegroundColor().FromClosedXMLColor(fillInfo.Fill.PatternColor); - if (foregroundColor.HasAttributes) - patternFill.AppendChild(foregroundColor); - - backgroundColor = new BackgroundColor().FromClosedXMLColor(fillInfo.Fill.BackgroundColor); - if (backgroundColor.HasAttributes) - patternFill.AppendChild(backgroundColor); - - break; - } - - if (patternFill.HasChildren) - fill.AppendChild(patternFill); - - return fill; - } - - private bool FillsAreEqual(Fill f, XLFillValue xlFill, Boolean fromDifferentialFormat) - { - var nF = new XLFill(null); - - LoadFill(f, nF, fromDifferentialFormat); - - return nF.Key.Equals(xlFill.Key); - } - - private void ResolveFonts(WorkbookStylesPart workbookStylesPart, SaveContext context) - { - if (workbookStylesPart.Stylesheet.Fonts == null) - workbookStylesPart.Stylesheet.Fonts = new Fonts(); - - var newFonts = new Dictionary(); - foreach (var fontInfo in context.SharedFonts.Values) - { - var fontId = 0; - var foundOne = false; - foreach (Font f in workbookStylesPart.Stylesheet.Fonts) - { - if (FontsAreEqual(f, fontInfo.Font)) - { - foundOne = true; - break; - } - fontId++; - } - if (!foundOne) - { - var font = GetNewFont(fontInfo); - workbookStylesPart.Stylesheet.Fonts.AppendChild(font); - } - newFonts.Add(fontInfo.Font, new FontInfo { Font = fontInfo.Font, FontId = (UInt32)fontId }); - } - context.SharedFonts.Clear(); - foreach (var kp in newFonts) - context.SharedFonts.Add(kp.Key, kp.Value); - - workbookStylesPart.Stylesheet.Fonts.Count = (UInt32)workbookStylesPart.Stylesheet.Fonts.Count(); - } - - private static Font GetNewFont(FontInfo fontInfo, Boolean ignoreMod = true) - { - var font = new Font(); - var bold = (fontInfo.Font.Bold != XLFontValue.Default.Bold || ignoreMod) && fontInfo.Font.Bold ? new Bold() : null; - var italic = (fontInfo.Font.Italic != XLFontValue.Default.Italic || ignoreMod) && fontInfo.Font.Italic ? new Italic() : null; - var underline = (fontInfo.Font.Underline != XLFontValue.Default.Underline || ignoreMod) && - fontInfo.Font.Underline != XLFontUnderlineValues.None - ? new Underline { Val = fontInfo.Font.Underline.ToOpenXml() } - : null; - var strike = (fontInfo.Font.Strikethrough != XLFontValue.Default.Strikethrough || ignoreMod) && fontInfo.Font.Strikethrough - ? new Strike() - : null; - var verticalAlignment = fontInfo.Font.VerticalAlignment != XLFontValue.Default.VerticalAlignment || ignoreMod - ? new VerticalTextAlignment { Val = fontInfo.Font.VerticalAlignment.ToOpenXml() } - : null; - - var shadow = (fontInfo.Font.Shadow != XLFontValue.Default.Shadow || ignoreMod) && fontInfo.Font.Shadow ? new Shadow() : null; - var fontSize = fontInfo.Font.FontSize != XLFontValue.Default.FontSize || ignoreMod - ? new FontSize { Val = fontInfo.Font.FontSize } - : null; - var color = fontInfo.Font.FontColor != XLFontValue.Default.FontColor || ignoreMod ? new Color().FromClosedXMLColor(fontInfo.Font.FontColor) : null; - - var fontName = fontInfo.Font.FontName != XLFontValue.Default.FontName || ignoreMod - ? new FontName { Val = fontInfo.Font.FontName } - : null; - var fontFamilyNumbering = fontInfo.Font.FontFamilyNumbering != XLFontValue.Default.FontFamilyNumbering || ignoreMod - ? new FontFamilyNumbering { Val = (Int32)fontInfo.Font.FontFamilyNumbering } - : null; - - var fontCharSet = (fontInfo.Font.FontCharSet != XLFontValue.Default.FontCharSet || ignoreMod) && fontInfo.Font.FontCharSet != XLFontCharSet.Default - ? new FontCharSet { Val = (Int32)fontInfo.Font.FontCharSet } - : null; - - if (bold != null) - font.AppendChild(bold); - if (italic != null) - font.AppendChild(italic); - if (underline != null) - font.AppendChild(underline); - if (strike != null) - font.AppendChild(strike); - if (verticalAlignment != null) - font.AppendChild(verticalAlignment); - if (shadow != null) - font.AppendChild(shadow); - if (fontSize != null) - font.AppendChild(fontSize); - if (color != null) - font.AppendChild(color); - if (fontName != null) - font.AppendChild(fontName); - if (fontFamilyNumbering != null) - font.AppendChild(fontFamilyNumbering); - if (fontCharSet != null) - font.AppendChild(fontCharSet); - - return font; - } - - private bool FontsAreEqual(Font f, XLFontValue xlFont) - { - var nf = XLFontValue.Default.Key; - nf.Bold = f.Bold != null; - nf.Italic = f.Italic != null; - - if (f.Underline != null) - { - nf.Underline = f.Underline.Val != null - ? f.Underline.Val.Value.ToClosedXml() - : XLFontUnderlineValues.Single; - } - nf.Strikethrough = f.Strike != null; - if (f.VerticalTextAlignment != null) - { - nf.VerticalAlignment = f.VerticalTextAlignment.Val != null - ? f.VerticalTextAlignment.Val.Value.ToClosedXml() - : XLFontVerticalTextAlignmentValues.Baseline; - } - nf.Shadow = f.Shadow != null; - if (f.FontSize != null) - nf.FontSize = f.FontSize.Val; - if (f.Color != null) - nf.FontColor = f.Color.ToClosedXMLColor(_colorList).Key; - if (f.FontName != null) - nf.FontName = f.FontName.Val; - if (f.FontFamilyNumbering != null) - nf.FontFamilyNumbering = (XLFontFamilyNumberingValues)f.FontFamilyNumbering.Val.Value; - - return nf.Equals(xlFont.Key); - } - - private static Dictionary ResolveNumberFormats( - WorkbookStylesPart workbookStylesPart, - Dictionary sharedNumberFormats, - UInt32 defaultFormatId) - { - if (workbookStylesPart.Stylesheet.NumberingFormats == null) - { - workbookStylesPart.Stylesheet.NumberingFormats = new NumberingFormats(); - workbookStylesPart.Stylesheet.NumberingFormats.AppendChild(new NumberingFormat() - { - NumberFormatId = 0, - FormatCode = "" - }); - } - - var allSharedNumberFormats = new Dictionary(); - foreach (var numberFormatInfo in sharedNumberFormats.Values.Where(nf => nf.NumberFormatId != defaultFormatId)) - { - var numberingFormatId = XLConstants.NumberOfBuiltInStyles; // 0-based - var foundOne = false; - foreach (NumberingFormat nf in workbookStylesPart.Stylesheet.NumberingFormats) - { - if (NumberFormatsAreEqual(nf, numberFormatInfo.NumberFormat)) - { - foundOne = true; - numberingFormatId = (Int32)nf.NumberFormatId.Value; - break; - } - numberingFormatId++; - } - if (!foundOne) - { - var numberingFormat = new NumberingFormat - { - NumberFormatId = (UInt32)numberingFormatId, - FormatCode = numberFormatInfo.NumberFormat.Format - }; - workbookStylesPart.Stylesheet.NumberingFormats.AppendChild(numberingFormat); - } - allSharedNumberFormats.Add(numberFormatInfo.NumberFormat, - new NumberFormatInfo - { - NumberFormat = numberFormatInfo.NumberFormat, - NumberFormatId = numberingFormatId - }); - } - workbookStylesPart.Stylesheet.NumberingFormats.Count = - (UInt32)workbookStylesPart.Stylesheet.NumberingFormats.Count(); - return allSharedNumberFormats; - } - - private static bool NumberFormatsAreEqual(NumberingFormat nf, XLNumberFormatValue xlNumberFormat) - { - if (nf.FormatCode != null && !String.IsNullOrWhiteSpace(nf.FormatCode.Value)) - return string.Equals(xlNumberFormat?.Format, nf.FormatCode.Value); - else if (nf.NumberFormatId != null) - return xlNumberFormat?.NumberFormatId == (Int32)nf.NumberFormatId.Value; - return false; - } - - #endregion GenerateWorkbookStylesPartContent - - #region GenerateWorksheetPartContent - - private static void GenerateWorksheetPartContent( - WorksheetPart worksheetPart, XLWorksheet xlWorksheet, SaveOptions options, SaveContext context) - { - if (options.ConsolidateConditionalFormatRanges) - { - ((XLConditionalFormats)xlWorksheet.ConditionalFormats).Consolidate(); - } - - #region Worksheet - - if (worksheetPart.Worksheet == null) - worksheetPart.Worksheet = new Worksheet(); - - if ( - !worksheetPart.Worksheet.NamespaceDeclarations.Contains(new KeyValuePair("r", - "http://schemas.openxmlformats.org/officeDocument/2006/relationships"))) - { - worksheetPart.Worksheet.AddNamespaceDeclaration("r", - "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); - } - - #endregion Worksheet - - var cm = new XLWorksheetContentManager(worksheetPart.Worksheet); - - #region SheetProperties - - if (worksheetPart.Worksheet.SheetProperties == null) - worksheetPart.Worksheet.SheetProperties = new SheetProperties(); - - worksheetPart.Worksheet.SheetProperties.TabColor = xlWorksheet.TabColor.HasValue - ? new TabColor().FromClosedXMLColor(xlWorksheet.TabColor) - : null; - - cm.SetElement(XLWorksheetContents.SheetProperties, worksheetPart.Worksheet.SheetProperties); - - if (worksheetPart.Worksheet.SheetProperties.OutlineProperties == null) - worksheetPart.Worksheet.SheetProperties.OutlineProperties = new OutlineProperties(); - - worksheetPart.Worksheet.SheetProperties.OutlineProperties.SummaryBelow = - (xlWorksheet.Outline.SummaryVLocation == - XLOutlineSummaryVLocation.Bottom); - worksheetPart.Worksheet.SheetProperties.OutlineProperties.SummaryRight = - (xlWorksheet.Outline.SummaryHLocation == - XLOutlineSummaryHLocation.Right); - - if (worksheetPart.Worksheet.SheetProperties.PageSetupProperties == null - && (xlWorksheet.PageSetup.PagesTall > 0 || xlWorksheet.PageSetup.PagesWide > 0)) - worksheetPart.Worksheet.SheetProperties.PageSetupProperties = new PageSetupProperties { FitToPage = true }; - - #endregion SheetProperties - - var maxColumn = 0; - - var sheetDimensionReference = "A1"; - if (xlWorksheet.Internals.CellsCollection.Count > 0) - { - maxColumn = xlWorksheet.Internals.CellsCollection.MaxColumnUsed; - var maxRow = xlWorksheet.Internals.CellsCollection.MaxRowUsed; - sheetDimensionReference = "A1:" + XLHelper.GetColumnLetterFromNumber(maxColumn) + - maxRow.ToInvariantString(); - } - - if (xlWorksheet.Internals.ColumnsCollection.Count > 0) - { - var maxColCollection = xlWorksheet.Internals.ColumnsCollection.Keys.Max(); - if (maxColCollection > maxColumn) - maxColumn = maxColCollection; - } - - #region SheetViews - - if (worksheetPart.Worksheet.SheetDimension == null) - worksheetPart.Worksheet.SheetDimension = new SheetDimension { Reference = sheetDimensionReference }; - - cm.SetElement(XLWorksheetContents.SheetDimension, worksheetPart.Worksheet.SheetDimension); - - if (worksheetPart.Worksheet.SheetViews == null) - worksheetPart.Worksheet.SheetViews = new SheetViews(); - - cm.SetElement(XLWorksheetContents.SheetViews, worksheetPart.Worksheet.SheetViews); - - var sheetView = (SheetView)worksheetPart.Worksheet.SheetViews.FirstOrDefault(); - if (sheetView == null) - { - sheetView = new SheetView { WorkbookViewId = 0U }; - worksheetPart.Worksheet.SheetViews.AppendChild(sheetView); - } - - var svcm = new XLSheetViewContentManager(sheetView); - - if (xlWorksheet.TabSelected) - sheetView.TabSelected = true; - else - sheetView.TabSelected = null; - - if (xlWorksheet.RightToLeft) - sheetView.RightToLeft = true; - else - sheetView.RightToLeft = null; - - if (xlWorksheet.ShowFormulas) - sheetView.ShowFormulas = true; - else - sheetView.ShowFormulas = null; - - if (xlWorksheet.ShowGridLines) - sheetView.ShowGridLines = null; - else - sheetView.ShowGridLines = false; - - if (xlWorksheet.ShowOutlineSymbols) - sheetView.ShowOutlineSymbols = null; - else - sheetView.ShowOutlineSymbols = false; - - if (xlWorksheet.ShowRowColHeaders) - sheetView.ShowRowColHeaders = null; - else - sheetView.ShowRowColHeaders = false; - - if (xlWorksheet.ShowRuler) - sheetView.ShowRuler = null; - else - sheetView.ShowRuler = false; - - if (xlWorksheet.ShowWhiteSpace) - sheetView.ShowWhiteSpace = null; - else - sheetView.ShowWhiteSpace = false; - - if (xlWorksheet.ShowZeros) - sheetView.ShowZeros = null; - else - sheetView.ShowZeros = false; - - if (xlWorksheet.RightToLeft) - sheetView.RightToLeft = true; - else - sheetView.RightToLeft = null; - - if (xlWorksheet.SheetView.View == XLSheetViewOptions.Normal) - sheetView.View = null; - else - sheetView.View = xlWorksheet.SheetView.View.ToOpenXml(); - - var pane = sheetView.Elements().FirstOrDefault(); - if (pane == null) - { - pane = new Pane(); - sheetView.InsertAt(pane, 0); - } - - svcm.SetElement(XLSheetViewContents.Pane, pane); - - pane.State = PaneStateValues.FrozenSplit; - int hSplit = xlWorksheet.SheetView.SplitColumn; - int ySplit = xlWorksheet.SheetView.SplitRow; - - pane.HorizontalSplit = hSplit; - pane.VerticalSplit = ySplit; - - pane.ActivePane = (ySplit == 0 ? PaneValues.TopRight : 0) - | (hSplit == 0 ? PaneValues.BottomLeft : 0); - - pane.TopLeftCell = XLHelper.GetColumnLetterFromNumber(xlWorksheet.SheetView.SplitColumn + 1) - + (xlWorksheet.SheetView.SplitRow + 1); - - if (hSplit == 0 && ySplit == 0) - { - // We don't have a pane. Just a regular sheet. - pane = null; - sheetView.RemoveAllChildren(); - svcm.SetElement(XLSheetViewContents.Pane, null); - } - - // Do sheet view. Whether it's for a regular sheet or for the bottom-right pane - if (!xlWorksheet.SheetView.TopLeftCellAddress.IsValid - || xlWorksheet.SheetView.TopLeftCellAddress == new XLAddress(1, 1, fixedRow: false, fixedColumn: false)) - sheetView.TopLeftCell = null; - else - sheetView.TopLeftCell = xlWorksheet.SheetView.TopLeftCellAddress.ToString(); - - if (xlWorksheet.SelectedRanges.Any() || xlWorksheet.ActiveCell != null) - { - sheetView.RemoveAllChildren(); - svcm.SetElement(XLSheetViewContents.Selection, null); - - var firstSelection = xlWorksheet.SelectedRanges.FirstOrDefault(); - - Action populateSelection = (Selection selection) => - { - if (xlWorksheet.ActiveCell != null) - selection.ActiveCell = xlWorksheet.ActiveCell.Address.ToStringRelative(false); - else if (firstSelection != null) - selection.ActiveCell = firstSelection.RangeAddress.FirstAddress.ToStringRelative(false); - - var seqRef = new List { selection.ActiveCell.Value }; - seqRef.AddRange(xlWorksheet.SelectedRanges - .Select(range => - { - if (range.RangeAddress.FirstAddress.Equals(range.RangeAddress.LastAddress)) - return range.RangeAddress.FirstAddress.ToStringRelative(false); - else - return range.RangeAddress.ToStringRelative(false); - }) - ); - - selection.SequenceOfReferences = new ListValue { InnerText = String.Join(" ", seqRef.Distinct().ToArray()) }; - - sheetView.InsertAfter(selection, svcm.GetPreviousElementFor(XLSheetViewContents.Selection)); - svcm.SetElement(XLSheetViewContents.Selection, selection); - }; - - // If a pane exists, we need to set the active pane too - // Yes, this might lead to 2 Selection elements! - if (pane != null) - { - populateSelection(new Selection() - { - Pane = pane.ActivePane - }); - } - populateSelection(new Selection()); - } - - if (xlWorksheet.SheetView.ZoomScale == 100) - sheetView.ZoomScale = null; - else - sheetView.ZoomScale = (UInt32)Math.Max(10, Math.Min(400, xlWorksheet.SheetView.ZoomScale)); - - if (xlWorksheet.SheetView.ZoomScaleNormal == 100) - sheetView.ZoomScaleNormal = null; - else - sheetView.ZoomScaleNormal = (UInt32)Math.Max(10, Math.Min(400, xlWorksheet.SheetView.ZoomScaleNormal)); - - if (xlWorksheet.SheetView.ZoomScalePageLayoutView == 100) - sheetView.ZoomScalePageLayoutView = null; - else - sheetView.ZoomScalePageLayoutView = (UInt32)Math.Max(10, Math.Min(400, xlWorksheet.SheetView.ZoomScalePageLayoutView)); - - if (xlWorksheet.SheetView.ZoomScaleSheetLayoutView == 100) - sheetView.ZoomScaleSheetLayoutView = null; - else - sheetView.ZoomScaleSheetLayoutView = (UInt32)Math.Max(10, Math.Min(400, xlWorksheet.SheetView.ZoomScaleSheetLayoutView)); - - #endregion SheetViews - - var maxOutlineColumn = 0; - if (xlWorksheet.ColumnCount() > 0) - maxOutlineColumn = xlWorksheet.GetMaxColumnOutline(); - - var maxOutlineRow = 0; - if (xlWorksheet.RowCount() > 0) - maxOutlineRow = xlWorksheet.GetMaxRowOutline(); - - #region SheetFormatProperties - - if (worksheetPart.Worksheet.SheetFormatProperties == null) - worksheetPart.Worksheet.SheetFormatProperties = new SheetFormatProperties(); - - cm.SetElement(XLWorksheetContents.SheetFormatProperties, - worksheetPart.Worksheet.SheetFormatProperties); - - worksheetPart.Worksheet.SheetFormatProperties.DefaultRowHeight = xlWorksheet.RowHeight.SaveRound(); - - if (xlWorksheet.RowHeightChanged) - worksheetPart.Worksheet.SheetFormatProperties.CustomHeight = true; - else - worksheetPart.Worksheet.SheetFormatProperties.CustomHeight = null; - - var worksheetColumnWidth = GetColumnWidth(xlWorksheet.ColumnWidth).SaveRound(); - if (xlWorksheet.ColumnWidthChanged) - worksheetPart.Worksheet.SheetFormatProperties.DefaultColumnWidth = worksheetColumnWidth; - else - worksheetPart.Worksheet.SheetFormatProperties.DefaultColumnWidth = null; - - if (maxOutlineColumn > 0) - worksheetPart.Worksheet.SheetFormatProperties.OutlineLevelColumn = (byte)maxOutlineColumn; - else - worksheetPart.Worksheet.SheetFormatProperties.OutlineLevelColumn = null; - - if (maxOutlineRow > 0) - worksheetPart.Worksheet.SheetFormatProperties.OutlineLevelRow = (byte)maxOutlineRow; - else - worksheetPart.Worksheet.SheetFormatProperties.OutlineLevelRow = null; - - #endregion SheetFormatProperties - - #region Columns - - var worksheetStyleId = context.SharedStyles[xlWorksheet.StyleValue].StyleId; - if (xlWorksheet.Internals.CellsCollection.Count == 0 && - xlWorksheet.Internals.ColumnsCollection.Count == 0 - && worksheetStyleId == 0) - worksheetPart.Worksheet.RemoveAllChildren(); - else - { - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.Columns); - worksheetPart.Worksheet.InsertAfter(new Columns(), previousElement); - } - - var columns = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.Columns, columns); - - var sheetColumnsByMin = columns.Elements().ToDictionary(c => c.Min.Value, c => c); - //Dictionary sheetColumnsByMax = columns.Elements().ToDictionary(c => c.Max.Value, c => c); - - Int32 minInColumnsCollection; - Int32 maxInColumnsCollection; - if (xlWorksheet.Internals.ColumnsCollection.Count > 0) - { - minInColumnsCollection = xlWorksheet.Internals.ColumnsCollection.Keys.Min(); - maxInColumnsCollection = xlWorksheet.Internals.ColumnsCollection.Keys.Max(); - } - else - { - minInColumnsCollection = 1; - maxInColumnsCollection = 0; - } - - if (minInColumnsCollection > 1) - { - UInt32Value min = 1; - UInt32Value max = (UInt32)(minInColumnsCollection - 1); - - for (var co = min; co <= max; co++) - { - var column = new Column - { - Min = co, - Max = co, - Style = worksheetStyleId, - Width = worksheetColumnWidth, - CustomWidth = true - }; - - UpdateColumn(column, columns, sheetColumnsByMin); //, sheetColumnsByMax); - } - } - - for (var co = minInColumnsCollection; co <= maxInColumnsCollection; co++) - { - UInt32 styleId; - Double columnWidth; - var isHidden = false; - var collapsed = false; - var outlineLevel = 0; - if (xlWorksheet.Internals.ColumnsCollection.TryGetValue(co, out XLColumn col)) - { - styleId = context.SharedStyles[col.StyleValue].StyleId; - columnWidth = GetColumnWidth(col.Width).SaveRound(); - isHidden = col.IsHidden; - collapsed = col.Collapsed; - outlineLevel = col.OutlineLevel; - } - else - { - styleId = context.SharedStyles[xlWorksheet.StyleValue].StyleId; - columnWidth = worksheetColumnWidth; - } - - var column = new Column - { - Min = (UInt32)co, - Max = (UInt32)co, - Style = styleId, - Width = columnWidth, - CustomWidth = true - }; - - if (isHidden) - column.Hidden = true; - if (collapsed) - column.Collapsed = true; - if (outlineLevel > 0) - column.OutlineLevel = (byte)outlineLevel; - - UpdateColumn(column, columns, sheetColumnsByMin); //, sheetColumnsByMax); - } - - var collection = maxInColumnsCollection; - foreach ( - var col in - columns.Elements().Where(c => c.Min > (UInt32)(collection)).OrderBy( - c => c.Min.Value)) - { - col.Style = worksheetStyleId; - col.Width = worksheetColumnWidth; - col.CustomWidth = true; - - if ((Int32)col.Max.Value > maxInColumnsCollection) - maxInColumnsCollection = (Int32)col.Max.Value; - } - - if (maxInColumnsCollection < XLHelper.MaxColumnNumber && worksheetStyleId != 0) - { - var column = new Column - { - Min = (UInt32)(maxInColumnsCollection + 1), - Max = (UInt32)(XLHelper.MaxColumnNumber), - Style = worksheetStyleId, - Width = worksheetColumnWidth, - CustomWidth = true - }; - columns.AppendChild(column); - } - - CollapseColumns(columns, sheetColumnsByMin); - - if (!columns.Any()) - { - worksheetPart.Worksheet.RemoveAllChildren(); - cm.SetElement(XLWorksheetContents.Columns, null); - } - } - - #endregion Columns - - #region SheetData - - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.SheetData); - worksheetPart.Worksheet.InsertAfter(new SheetData(), previousElement); - } - - var sheetData = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.SheetData, sheetData); - - var lastRow = 0; - var existingSheetDataRows = - sheetData.Elements().ToDictionary(r => r.RowIndex == null ? ++lastRow : (Int32)r.RowIndex.Value, - r => r); - foreach ( - var r in - xlWorksheet.Internals.RowsCollection.Deleted.Where(r => existingSheetDataRows.ContainsKey(r.Key))) - { - sheetData.RemoveChild(existingSheetDataRows[r.Key]); - existingSheetDataRows.Remove(r.Key); - xlWorksheet.Internals.CellsCollection.Deleted.Remove(r.Key); - } - - var tableTotalCells = new HashSet( - xlWorksheet.Tables - .Where(table => table.ShowTotalsRow) - .SelectMany(table => - table.TotalsRow().CellsUsed()) - .Select(cell => cell.Address)); - - var distinctRows = xlWorksheet.Internals.CellsCollection.RowsCollection.Keys.Union(xlWorksheet.Internals.RowsCollection.Keys); - var noRows = !sheetData.Elements().Any(); - foreach (var distinctRow in distinctRows.OrderBy(r => r)) - { - Row row; - if (!existingSheetDataRows.TryGetValue(distinctRow, out row)) - row = new Row { RowIndex = (UInt32)distinctRow }; - - if (maxColumn > 0) - row.Spans = new ListValue { InnerText = "1:" + maxColumn.ToInvariantString() }; - - row.Height = null; - row.CustomHeight = null; - row.Hidden = null; - row.StyleIndex = null; - row.CustomFormat = null; - row.Collapsed = null; - if (xlWorksheet.Internals.RowsCollection.TryGetValue(distinctRow, out XLRow thisRow)) - { - if (thisRow.HeightChanged) - { - row.Height = thisRow.Height.SaveRound(); - row.CustomHeight = true; - } - - if (thisRow.StyleValue != xlWorksheet.StyleValue) - { - row.StyleIndex = context.SharedStyles[thisRow.StyleValue].StyleId; - row.CustomFormat = true; - } - - if (thisRow.IsHidden) - row.Hidden = true; - if (thisRow.Collapsed) - row.Collapsed = true; - if (thisRow.OutlineLevel > 0) - row.OutlineLevel = (byte)thisRow.OutlineLevel; - } - - var lastCell = 0; - var currentOpenXmlRowCells = row.Elements() - .ToDictionary - ( - c => c.CellReference?.Value ?? XLHelper.GetColumnLetterFromNumber(++lastCell) + distinctRow, - c => c - ); - - if (xlWorksheet.Internals.CellsCollection.Deleted.TryGetValue(distinctRow, out HashSet deletedColumns)) - { - foreach (var deletedColumn in deletedColumns.ToList()) - { - var key = XLHelper.GetColumnLetterFromNumber(deletedColumn) + distinctRow.ToInvariantString(); - - if (!currentOpenXmlRowCells.TryGetValue(key, out Cell cell)) - continue; - - row.RemoveChild(cell); - deletedColumns.Remove(deletedColumn); - } - if (deletedColumns.Count == 0) - xlWorksheet.Internals.CellsCollection.Deleted.Remove(distinctRow); - } - - if (xlWorksheet.Internals.CellsCollection.RowsCollection.TryGetValue(distinctRow, out Dictionary cells)) - { - var isNewRow = !row.Elements().Any(); - lastCell = 0; - var mRows = row.Elements().ToDictionary(c => XLHelper.GetColumnNumberFromAddress(c.CellReference == null - ? (XLHelper.GetColumnLetterFromNumber(++lastCell) + distinctRow) : c.CellReference.Value), c => c); - foreach (var xlCell in cells.Values - .OrderBy(c => c.Address.ColumnNumber) - .Select(c => c)) - { - XLTableField field = null; - - var styleId = context.SharedStyles[xlCell.StyleValue].StyleId; - var cellReference = (xlCell.Address).GetTrimmedAddress(); - - // For saving cells to file, ignore conditional formatting, data validation rules and merged - // ranges. They just bloat the file - var isEmpty = xlCell.IsEmpty(XLCellsUsedOptions.All - & ~XLCellsUsedOptions.ConditionalFormats - & ~XLCellsUsedOptions.DataValidation - & ~XLCellsUsedOptions.MergedRanges); - - if (currentOpenXmlRowCells.TryGetValue(cellReference, out Cell cell)) - { - if (isEmpty) - { - cell.Remove(); - } - - // reset some stuff that we'll populate later - cell.DataType = null; - cell.RemoveAllChildren(); - } - - if (!isEmpty) - { - if (cell == null) - { - cell = new Cell(); - cell.CellReference = new StringValue(cellReference); - - if (isNewRow) - row.AppendChild(cell); - else - { - var newColumn = XLHelper.GetColumnNumberFromAddress(cellReference); - - Cell cellBeforeInsert = null; - int[] lastCo = { Int32.MaxValue }; - foreach (var c in mRows.Where(kp => kp.Key > newColumn).Where(c => lastCo[0] > c.Key)) - { - cellBeforeInsert = c.Value; - lastCo[0] = c.Key; - } - if (cellBeforeInsert == null) - row.AppendChild(cell); - else - row.InsertBefore(cell, cellBeforeInsert); - } - } - - cell.StyleIndex = styleId; - if (xlCell.HasFormula) - { - var formula = xlCell.FormulaA1; - if (xlCell.HasArrayFormula) - { - formula = formula.Substring(1, formula.Length - 2); - var f = new CellFormula { FormulaType = CellFormulaValues.Array }; - - if (xlCell.FormulaReference == null) - xlCell.FormulaReference = xlCell.AsRange().RangeAddress; - - if (xlCell.FormulaReference.FirstAddress.Equals(xlCell.Address)) - { - f.Text = formula; - f.Reference = xlCell.FormulaReference.ToStringRelative(); - } - - cell.CellFormula = f; - } - else - { - cell.CellFormula = new CellFormula(); - cell.CellFormula.Text = formula; - } - - if (!options.EvaluateFormulasBeforeSaving || xlCell.CachedValue == null || xlCell.NeedsRecalculation) - cell.CellValue = null; - else - { - string valueCalculated; - if (xlCell.CachedValue is int) - valueCalculated = ((int)xlCell.CachedValue).ToInvariantString(); - else if (xlCell.CachedValue is double) - valueCalculated = ((double)xlCell.CachedValue).ToInvariantString(); - else - valueCalculated = xlCell.CachedValue.ToString(); - - cell.CellValue = new CellValue(valueCalculated); - } - } - else if (tableTotalCells.Contains(xlCell.Address)) - { - var table = xlWorksheet.Tables.First(t => t.AsRange().Contains(xlCell)); - field = table.Fields.First(f => f.Column.ColumnNumber() == xlCell.Address.ColumnNumber) as XLTableField; - - if (!String.IsNullOrWhiteSpace(field.TotalsRowLabel)) - { - cell.DataType = XLWorkbook.CvSharedString; - } - else - { - cell.DataType = null; - } - cell.CellFormula = null; - } - else - { - cell.CellFormula = null; - cell.DataType = xlCell.DataType == XLDataType.DateTime ? null : GetCellValueType(xlCell); - } - - if (options.EvaluateFormulasBeforeSaving || field != null || !xlCell.HasFormula) - SetCellValue(xlCell, field, cell, context); - } - } - xlWorksheet.Internals.CellsCollection.Deleted.Remove(distinctRow); - } - - // If we're adding a new row (not in sheet already and it's not "empty" - if (!existingSheetDataRows.ContainsKey(distinctRow)) - { - var invalidRow = row.Height == null - && row.CustomHeight == null - && row.Hidden == null - && row.StyleIndex == null - && row.CustomFormat == null - && row.Collapsed == null - && row.OutlineLevel == null - && !row.Elements().Any(); - - if (!invalidRow) - { - if (noRows) - { - sheetData.AppendChild(row); - noRows = false; - } - else - { - if (existingSheetDataRows.Any(r => r.Key > row.RowIndex.Value)) - { - var minRow = existingSheetDataRows.Where(r => r.Key > (Int32)row.RowIndex.Value).Min(r => r.Key); - var rowBeforeInsert = existingSheetDataRows[minRow]; - sheetData.InsertBefore(row, rowBeforeInsert); - } - else - sheetData.AppendChild(row); - } - } - } - } - - foreach (var r in xlWorksheet.Internals.CellsCollection.Deleted.Keys) - { - if (existingSheetDataRows.TryGetValue(r, out Row row)) - { - sheetData.RemoveChild(row); - existingSheetDataRows.Remove(r); - } - } - - #endregion SheetData - - #region SheetProtection - - if (xlWorksheet.Protection.IsProtected) - { - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.SheetProtection); - worksheetPart.Worksheet.InsertAfter(new SheetProtection(), previousElement); - } - - var sheetProtection = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.SheetProtection, sheetProtection); - - var protection = xlWorksheet.Protection; - sheetProtection.Sheet = OpenXmlHelper.GetBooleanValue(protection.IsProtected, false); - - sheetProtection.Password = null; - sheetProtection.AlgorithmName = null; - sheetProtection.HashValue = null; - sheetProtection.SpinCount = null; - sheetProtection.SaltValue = null; - - if (protection.Algorithm == XLProtectionAlgorithm.Algorithm.SimpleHash) - { - if (!String.IsNullOrWhiteSpace(protection.PasswordHash)) - sheetProtection.Password = protection.PasswordHash; - } - else - { - sheetProtection.AlgorithmName = DescribedEnumParser.ToDescription(protection.Algorithm); - sheetProtection.HashValue = protection.PasswordHash; - sheetProtection.SpinCount = protection.SpinCount; - sheetProtection.SaltValue = protection.Base64EncodedSalt; - } - - // default value of "1" - sheetProtection.FormatCells = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.FormatCells), true); - sheetProtection.FormatColumns = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.FormatColumns), true); - sheetProtection.FormatRows = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.FormatRows), true); - sheetProtection.InsertColumns = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.InsertColumns), true); - sheetProtection.InsertRows = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.InsertRows), true); - sheetProtection.InsertHyperlinks = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.InsertHyperlinks), true); - sheetProtection.DeleteColumns = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.DeleteColumns), true); - sheetProtection.DeleteRows = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.DeleteRows), true); - sheetProtection.Sort = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.Sort), true); - sheetProtection.AutoFilter = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.AutoFilter), true); - sheetProtection.PivotTables = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.PivotTables), true); - sheetProtection.Scenarios = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.EditScenarios), true); - - // default value of "0" - sheetProtection.Objects = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.EditObjects), false); - sheetProtection.SelectLockedCells = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.SelectLockedCells), false); - sheetProtection.SelectUnlockedCells = OpenXmlHelper.GetBooleanValue(!protection.AllowedElements.HasFlag(XLSheetProtectionElements.SelectUnlockedCells), false); - } - else - { - worksheetPart.Worksheet.RemoveAllChildren(); - cm.SetElement(XLWorksheetContents.SheetProtection, null); - } - - #endregion SheetProtection - - #region AutoFilter - - worksheetPart.Worksheet.RemoveAllChildren(); - if (xlWorksheet.AutoFilter.IsEnabled) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.AutoFilter); - worksheetPart.Worksheet.InsertAfter(new AutoFilter(), previousElement); - - var autoFilter = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.AutoFilter, autoFilter); - - PopulateAutoFilter(xlWorksheet.AutoFilter, autoFilter); - } - else - { - cm.SetElement(XLWorksheetContents.AutoFilter, null); - } - - #endregion AutoFilter - - #region MergeCells - - if ((xlWorksheet).Internals.MergedRanges.Any()) - { - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.MergeCells); - worksheetPart.Worksheet.InsertAfter(new MergeCells(), previousElement); - } - - var mergeCells = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.MergeCells, mergeCells); - mergeCells.RemoveAllChildren(); - - foreach (var mergeCell in (xlWorksheet).Internals.MergedRanges.Select( - m => m.RangeAddress.FirstAddress.ToString() + ":" + m.RangeAddress.LastAddress.ToString()).Select( - merged => new MergeCell { Reference = merged })) - mergeCells.AppendChild(mergeCell); - - mergeCells.Count = (UInt32)mergeCells.Count(); - } - else - { - worksheetPart.Worksheet.RemoveAllChildren(); - cm.SetElement(XLWorksheetContents.MergeCells, null); - } - - #endregion MergeCells - - #region Conditional Formatting - - if (!xlWorksheet.ConditionalFormats.Any()) - { - worksheetPart.Worksheet.RemoveAllChildren(); - cm.SetElement(XLWorksheetContents.ConditionalFormatting, null); - } - else - { - worksheetPart.Worksheet.RemoveAllChildren(); - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.ConditionalFormatting); - - var conditionalFormats = xlWorksheet.ConditionalFormats.ToList(); // Required for IndexOf method - - foreach (var cfGroup in conditionalFormats - .GroupBy( - c => string.Join(" ", c.Ranges.Select(r => r.RangeAddress.ToStringRelative(false))), - c => c, - (key, g) => new { RangeId = key, CfList = g.ToList() } - ) - ) - { - var conditionalFormatting = new ConditionalFormatting - { - SequenceOfReferences = - new ListValue { InnerText = cfGroup.RangeId } - }; - foreach (var cf in cfGroup.CfList) - { - var priority = conditionalFormats.IndexOf(cf) + 1; - conditionalFormatting.Append(XLCFConverters.Convert(cf, priority, context)); - } - worksheetPart.Worksheet.InsertAfter(conditionalFormatting, previousElement); - previousElement = conditionalFormatting; - cm.SetElement(XLWorksheetContents.ConditionalFormatting, conditionalFormatting); - } - } - - var exlst = from c in xlWorksheet.ConditionalFormats where c.ConditionalFormatType == XLConditionalFormatType.DataBar && typeof(IXLConditionalFormat).IsAssignableFrom(c.GetType()) select c; - if (exlst != null && exlst.Any()) - { - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.WorksheetExtensionList); - worksheetPart.Worksheet.InsertAfter(new WorksheetExtensionList(), previousElement); - } - - WorksheetExtensionList worksheetExtensionList = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.WorksheetExtensionList, worksheetExtensionList); - - var conditionalFormattings = worksheetExtensionList.Descendants().SingleOrDefault(); - if (conditionalFormattings == null || !conditionalFormattings.Any()) - { - WorksheetExtension worksheetExtension1 = new WorksheetExtension { Uri = "{78C0D931-6437-407d-A8EE-F0AAD7539E65}" }; - worksheetExtension1.AddNamespaceDeclaration("x14", "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); - worksheetExtensionList.Append(worksheetExtension1); - - conditionalFormattings = new DocumentFormat.OpenXml.Office2010.Excel.ConditionalFormattings(); - worksheetExtension1.Append(conditionalFormattings); - } - - foreach (var cfGroup in exlst - .GroupBy( - c => string.Join(" ", c.Ranges.Select(r => r.RangeAddress.ToStringRelative(false))), - c => c, - (key, g) => new { RangeId = key, CfList = g.ToList() } - ) - ) - { - foreach (var xlConditionalFormat in cfGroup.CfList.Cast()) - { - var conditionalFormattingRule = conditionalFormattings.Descendants() - .SingleOrDefault(r => r.Id == xlConditionalFormat.Id.WrapInBraces()); - if (conditionalFormattingRule != null) - { - var conditionalFormat = conditionalFormattingRule.Ancestors().SingleOrDefault(); - conditionalFormattings.RemoveChild(conditionalFormat); - } - - var conditionalFormatting = new DocumentFormat.OpenXml.Office2010.Excel.ConditionalFormatting(); - conditionalFormatting.AddNamespaceDeclaration("xm", "http://schemas.microsoft.com/office/excel/2006/main"); - conditionalFormatting.Append(XLCFConvertersExtension.Convert(xlConditionalFormat, context)); - var referenceSequence = new DocumentFormat.OpenXml.Office.Excel.ReferenceSequence { Text = cfGroup.RangeId }; - conditionalFormatting.Append(referenceSequence); - - conditionalFormattings.Append(conditionalFormatting); - } - } - } - - #endregion Conditional Formatting - - #region Sparklines - - const string sparklineGroupsExtensionUri = "{05C60535-1F16-4fd2-B633-F4F36F0B64E0}"; - - if (!xlWorksheet.SparklineGroups.Any()) - { - var worksheetExtensionList = worksheetPart.Worksheet.Elements().FirstOrDefault(); - var worksheetExtension = worksheetExtensionList?.Elements() - .FirstOrDefault(ext => string.Equals(ext.Uri, sparklineGroupsExtensionUri, StringComparison.InvariantCultureIgnoreCase)); - - worksheetExtension?.RemoveAllChildren(); - - if (worksheetExtensionList != null) - { - if (worksheetExtension != null && !worksheetExtension.HasChildren) - { - worksheetExtensionList.RemoveChild(worksheetExtension); - } - - if (!worksheetExtensionList.HasChildren) - { - worksheetPart.Worksheet.RemoveChild(worksheetExtensionList); - cm.SetElement(XLWorksheetContents.WorksheetExtensionList, null); - } - } - } - else - { - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.WorksheetExtensionList); - worksheetPart.Worksheet.InsertAfter(new WorksheetExtensionList(), previousElement); - } - - var worksheetExtensionList = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.WorksheetExtensionList, worksheetExtensionList); - - var sparklineGroups = worksheetExtensionList.Descendants().SingleOrDefault(); - - if (sparklineGroups == null || !sparklineGroups.Any()) - { - var worksheetExtension1 = new WorksheetExtension() { Uri = sparklineGroupsExtensionUri }; - worksheetExtension1.AddNamespaceDeclaration("x14", "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"); - worksheetExtensionList.Append(worksheetExtension1); - - sparklineGroups = new X14.SparklineGroups(); - sparklineGroups.AddNamespaceDeclaration("xm", "http://schemas.microsoft.com/office/excel/2006/main"); - worksheetExtension1.Append(sparklineGroups); - } - else - { - sparklineGroups.RemoveAllChildren(); - } - - foreach (var xlSparklineGroup in xlWorksheet.SparklineGroups) - { - // Do not create an empty Sparkline group - if (!xlSparklineGroup.Any()) - continue; - - var sparklineGroup = new X14.SparklineGroup(); - sparklineGroup.SetAttribute(new OpenXmlAttribute("xr2", "uid", "http://schemas.microsoft.com/office/spreadsheetml/2015/revision2", "{A98FF5F8-AE60-43B5-8001-AD89004F45D3}")); - - sparklineGroup.FirstMarkerColor = new X14.FirstMarkerColor().FromClosedXMLColor(xlSparklineGroup.Style.FirstMarkerColor); - sparklineGroup.LastMarkerColor = new X14.LastMarkerColor().FromClosedXMLColor(xlSparklineGroup.Style.LastMarkerColor); - sparklineGroup.HighMarkerColor = new X14.HighMarkerColor().FromClosedXMLColor(xlSparklineGroup.Style.HighMarkerColor); - sparklineGroup.LowMarkerColor = new X14.LowMarkerColor().FromClosedXMLColor(xlSparklineGroup.Style.LowMarkerColor); - sparklineGroup.SeriesColor = new X14.SeriesColor().FromClosedXMLColor(xlSparklineGroup.Style.SeriesColor); - sparklineGroup.NegativeColor = new X14.NegativeColor().FromClosedXMLColor(xlSparklineGroup.Style.NegativeColor); - sparklineGroup.MarkersColor = new X14.MarkersColor().FromClosedXMLColor(xlSparklineGroup.Style.MarkersColor); - - sparklineGroup.High = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.HighPoint); - sparklineGroup.Low = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.LowPoint); - sparklineGroup.First = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.FirstPoint); - sparklineGroup.Last = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.LastPoint); - sparklineGroup.Negative = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.NegativePoints); - sparklineGroup.Markers = xlSparklineGroup.ShowMarkers.HasFlag(XLSparklineMarkers.Markers); - - sparklineGroup.DisplayHidden = xlSparklineGroup.DisplayHidden; - sparklineGroup.LineWeight = xlSparklineGroup.LineWeight; - sparklineGroup.Type = xlSparklineGroup.Type.ToOpenXml(); - sparklineGroup.DisplayEmptyCellsAs = xlSparklineGroup.DisplayEmptyCellsAs.ToOpenXml(); - - sparklineGroup.AxisColor = new X14.AxisColor() { Rgb = xlSparklineGroup.HorizontalAxis.Color.Color.ToHex() }; - sparklineGroup.DisplayXAxis = xlSparklineGroup.HorizontalAxis.IsVisible; - sparklineGroup.RightToLeft = xlSparklineGroup.HorizontalAxis.RightToLeft; - sparklineGroup.DateAxis = xlSparklineGroup.HorizontalAxis.DateAxis; - if (xlSparklineGroup.HorizontalAxis.DateAxis) - sparklineGroup.Formula = new OfficeExcel.Formula( - xlSparklineGroup.DateRange.RangeAddress.ToString(XLReferenceStyle.A1, true)); - - sparklineGroup.MinAxisType = xlSparklineGroup.VerticalAxis.MinAxisType.ToOpenXml(); - if (xlSparklineGroup.VerticalAxis.MinAxisType == XLSparklineAxisMinMax.Custom) - sparklineGroup.ManualMin = xlSparklineGroup.VerticalAxis.ManualMin; - - sparklineGroup.MaxAxisType = xlSparklineGroup.VerticalAxis.MaxAxisType.ToOpenXml(); - if (xlSparklineGroup.VerticalAxis.MaxAxisType == XLSparklineAxisMinMax.Custom) - sparklineGroup.ManualMax = xlSparklineGroup.VerticalAxis.ManualMax; - - var sparklines = new X14.Sparklines(xlSparklineGroup - .Select(xlSparkline => new X14.Sparkline - { - Formula = new OfficeExcel.Formula(xlSparkline.SourceData.RangeAddress.ToString(XLReferenceStyle.A1, true)), - ReferenceSequence = - new OfficeExcel.ReferenceSequence(xlSparkline.Location.Address.ToString()) - }) - ); - - sparklineGroup.Append(sparklines); - sparklineGroups.Append(sparklineGroup); - } - - // if all Sparkline groups had no Sparklines, remove the entire SparklineGroup element - if (sparklineGroups.ChildElements.Count == 0) - { - sparklineGroups.Remove(); - } - } - - #endregion Sparklines - - #region DataValidations - - if (!xlWorksheet.DataValidations.Any(d => d.IsDirty())) - { - worksheetPart.Worksheet.RemoveAllChildren(); - cm.SetElement(XLWorksheetContents.DataValidations, null); - } - else - { - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.DataValidations); - worksheetPart.Worksheet.InsertAfter(new DataValidations(), previousElement); - } - - var dataValidations = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.DataValidations, dataValidations); - dataValidations.RemoveAllChildren(); - - if (options.ConsolidateDataValidationRanges) - { - xlWorksheet.DataValidations.Consolidate(); - } - - foreach (var dv in xlWorksheet.DataValidations) - { - var sequence = dv.Ranges.Aggregate(String.Empty, (current, r) => current + (r.RangeAddress + " ")); - - if (sequence.Length > 0) - sequence = sequence.Substring(0, sequence.Length - 1); - - var dataValidation = new DataValidation - { - AllowBlank = dv.IgnoreBlanks, - Formula1 = new Formula1(dv.MinValue), - Formula2 = new Formula2(dv.MaxValue), - Type = dv.AllowedValues.ToOpenXml(), - ShowErrorMessage = dv.ShowErrorMessage, - Prompt = dv.InputMessage, - PromptTitle = dv.InputTitle, - ErrorTitle = dv.ErrorTitle, - Error = dv.ErrorMessage, - ShowDropDown = !dv.InCellDropdown, - ShowInputMessage = dv.ShowInputMessage, - ErrorStyle = dv.ErrorStyle.ToOpenXml(), - Operator = dv.Operator.ToOpenXml(), - SequenceOfReferences = - new ListValue { InnerText = sequence } - }; - - dataValidations.AppendChild(dataValidation); - } - dataValidations.Count = (UInt32)xlWorksheet.DataValidations.Count(); - } - - #endregion DataValidations - - #region Hyperlinks - - var relToRemove = worksheetPart.HyperlinkRelationships.ToList(); - relToRemove.ForEach(worksheetPart.DeleteReferenceRelationship); - if (!xlWorksheet.Hyperlinks.Any()) - { - worksheetPart.Worksheet.RemoveAllChildren(); - cm.SetElement(XLWorksheetContents.Hyperlinks, null); - } - else - { - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.Hyperlinks); - worksheetPart.Worksheet.InsertAfter(new Hyperlinks(), previousElement); - } - - var hyperlinks = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.Hyperlinks, hyperlinks); - hyperlinks.RemoveAllChildren(); - foreach (var hl in xlWorksheet.Hyperlinks) - { - Hyperlink hyperlink; - if (hl.IsExternal) - { - var rId = context.RelIdGenerator.GetNext(RelType.Workbook); - hyperlink = new Hyperlink { Reference = hl.Cell.Address.ToString(), Id = rId }; - worksheetPart.AddHyperlinkRelationship(hl.ExternalAddress, true, rId); - } - else - { - hyperlink = new Hyperlink - { - Reference = hl.Cell.Address.ToString(), - Location = hl.InternalAddress, - Display = hl.Cell.GetFormattedString() - }; - } - if (!String.IsNullOrWhiteSpace(hl.Tooltip)) - hyperlink.Tooltip = hl.Tooltip; - hyperlinks.AppendChild(hyperlink); - } - } - - #endregion Hyperlinks - - #region PrintOptions - - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.PrintOptions); - worksheetPart.Worksheet.InsertAfter(new PrintOptions(), previousElement); - } - - var printOptions = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.PrintOptions, printOptions); - - printOptions.HorizontalCentered = xlWorksheet.PageSetup.CenterHorizontally; - printOptions.VerticalCentered = xlWorksheet.PageSetup.CenterVertically; - printOptions.Headings = xlWorksheet.PageSetup.ShowRowAndColumnHeadings; - printOptions.GridLines = xlWorksheet.PageSetup.ShowGridlines; - - #endregion PrintOptions - - #region PageMargins - - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.PageMargins); - worksheetPart.Worksheet.InsertAfter(new PageMargins(), previousElement); - } - - var pageMargins = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.PageMargins, pageMargins); - pageMargins.Left = xlWorksheet.PageSetup.Margins.Left; - pageMargins.Right = xlWorksheet.PageSetup.Margins.Right; - pageMargins.Top = xlWorksheet.PageSetup.Margins.Top; - pageMargins.Bottom = xlWorksheet.PageSetup.Margins.Bottom; - pageMargins.Header = xlWorksheet.PageSetup.Margins.Header; - pageMargins.Footer = xlWorksheet.PageSetup.Margins.Footer; - - #endregion PageMargins - - #region PageSetup - - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.PageSetup); - worksheetPart.Worksheet.InsertAfter(new PageSetup(), previousElement); - } - - var pageSetup = worksheetPart.Worksheet.Elements().First(); - cm.SetElement(XLWorksheetContents.PageSetup, pageSetup); - - pageSetup.Orientation = xlWorksheet.PageSetup.PageOrientation.ToOpenXml(); - pageSetup.PaperSize = (UInt32)xlWorksheet.PageSetup.PaperSize; - pageSetup.BlackAndWhite = xlWorksheet.PageSetup.BlackAndWhite; - pageSetup.Draft = xlWorksheet.PageSetup.DraftQuality; - pageSetup.PageOrder = xlWorksheet.PageSetup.PageOrder.ToOpenXml(); - pageSetup.CellComments = xlWorksheet.PageSetup.ShowComments.ToOpenXml(); - pageSetup.Errors = xlWorksheet.PageSetup.PrintErrorValue.ToOpenXml(); - - if (xlWorksheet.PageSetup.FirstPageNumber.HasValue) - { - pageSetup.FirstPageNumber = UInt32Value.FromUInt32(xlWorksheet.PageSetup.FirstPageNumber.Value); - pageSetup.UseFirstPageNumber = true; - } - else - { - pageSetup.FirstPageNumber = null; - pageSetup.UseFirstPageNumber = null; - } - - if (xlWorksheet.PageSetup.HorizontalDpi > 0) - pageSetup.HorizontalDpi = (UInt32)xlWorksheet.PageSetup.HorizontalDpi; - else - pageSetup.HorizontalDpi = null; - - if (xlWorksheet.PageSetup.VerticalDpi > 0) - pageSetup.VerticalDpi = (UInt32)xlWorksheet.PageSetup.VerticalDpi; - else - pageSetup.VerticalDpi = null; - - if (xlWorksheet.PageSetup.Scale > 0) - { - pageSetup.Scale = (UInt32)xlWorksheet.PageSetup.Scale; - pageSetup.FitToWidth = null; - pageSetup.FitToHeight = null; - } - else - { - pageSetup.Scale = null; - - if (xlWorksheet.PageSetup.PagesWide >= 0 && xlWorksheet.PageSetup.PagesWide != 1) - pageSetup.FitToWidth = (UInt32)xlWorksheet.PageSetup.PagesWide; - - if (xlWorksheet.PageSetup.PagesTall >= 0 && xlWorksheet.PageSetup.PagesTall != 1) - pageSetup.FitToHeight = (UInt32)xlWorksheet.PageSetup.PagesTall; - } - - // For some reason some Excel files already contains pageSetup.Copies = 0 - // The validation fails for this - // Let's remove the attribute of that's the case. - if ((pageSetup?.Copies ?? 0) <= 0) - pageSetup.Copies = null; - - #endregion PageSetup - - #region HeaderFooter - - var headerFooter = worksheetPart.Worksheet.Elements().FirstOrDefault(); - if (headerFooter == null) - headerFooter = new HeaderFooter(); - else - worksheetPart.Worksheet.RemoveAllChildren(); - - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.HeaderFooter); - worksheetPart.Worksheet.InsertAfter(headerFooter, previousElement); - cm.SetElement(XLWorksheetContents.HeaderFooter, headerFooter); - } - if (((XLHeaderFooter)xlWorksheet.PageSetup.Header).Changed - || ((XLHeaderFooter)xlWorksheet.PageSetup.Footer).Changed) - { - headerFooter.RemoveAllChildren(); - - headerFooter.ScaleWithDoc = xlWorksheet.PageSetup.ScaleHFWithDocument; - headerFooter.AlignWithMargins = xlWorksheet.PageSetup.AlignHFWithMargins; - headerFooter.DifferentFirst = xlWorksheet.PageSetup.DifferentFirstPageOnHF; - headerFooter.DifferentOddEven = xlWorksheet.PageSetup.DifferentOddEvenPagesOnHF; - - var oddHeader = new OddHeader(xlWorksheet.PageSetup.Header.GetText(XLHFOccurrence.OddPages)); - headerFooter.AppendChild(oddHeader); - var oddFooter = new OddFooter(xlWorksheet.PageSetup.Footer.GetText(XLHFOccurrence.OddPages)); - headerFooter.AppendChild(oddFooter); - - var evenHeader = new EvenHeader(xlWorksheet.PageSetup.Header.GetText(XLHFOccurrence.EvenPages)); - headerFooter.AppendChild(evenHeader); - var evenFooter = new EvenFooter(xlWorksheet.PageSetup.Footer.GetText(XLHFOccurrence.EvenPages)); - headerFooter.AppendChild(evenFooter); - - var firstHeader = new FirstHeader(xlWorksheet.PageSetup.Header.GetText(XLHFOccurrence.FirstPage)); - headerFooter.AppendChild(firstHeader); - var firstFooter = new FirstFooter(xlWorksheet.PageSetup.Footer.GetText(XLHFOccurrence.FirstPage)); - headerFooter.AppendChild(firstFooter); - } - - #endregion HeaderFooter - - #region RowBreaks - - var rowBreakCount = xlWorksheet.PageSetup.RowBreaks.Count; - if (rowBreakCount > 0) - { - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.RowBreaks); - worksheetPart.Worksheet.InsertAfter(new RowBreaks(), previousElement); - } - - var rowBreaks = worksheetPart.Worksheet.Elements().First(); - - var existingBreaks = rowBreaks.ChildElements.OfType(); - var rowBreaksToDelete = existingBreaks - .Where(rb => !rb.Id.HasValue || - !xlWorksheet.PageSetup.RowBreaks.Contains((int)rb.Id.Value)) - .ToList(); - - foreach (var rb in rowBreaksToDelete) - { - rowBreaks.RemoveChild(rb); - } - - var rowBreaksToAdd = xlWorksheet.PageSetup.RowBreaks - .Where(xlRb => !existingBreaks.Any(rb => rb.Id.HasValue && rb.Id.Value == xlRb)); - - rowBreaks.Count = (UInt32)rowBreakCount; - rowBreaks.ManualBreakCount = (UInt32)rowBreakCount; - var lastRowNum = (UInt32)xlWorksheet.RangeAddress.LastAddress.RowNumber; - foreach (var break1 in rowBreaksToAdd.Select(rb => new Break - { - Id = (UInt32)rb, - Max = lastRowNum, - ManualPageBreak = true - })) - rowBreaks.AppendChild(break1); - cm.SetElement(XLWorksheetContents.RowBreaks, rowBreaks); - } - else - { - worksheetPart.Worksheet.RemoveAllChildren(); - cm.SetElement(XLWorksheetContents.RowBreaks, null); - } - - #endregion RowBreaks - - #region ColumnBreaks - - var columnBreakCount = xlWorksheet.PageSetup.ColumnBreaks.Count; - if (columnBreakCount > 0) - { - if (!worksheetPart.Worksheet.Elements().Any()) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.ColumnBreaks); - worksheetPart.Worksheet.InsertAfter(new ColumnBreaks(), previousElement); - } - - var columnBreaks = worksheetPart.Worksheet.Elements().First(); - - var existingBreaks = columnBreaks.ChildElements.OfType(); - var columnBreaksToDelete = existingBreaks - .Where(cb => !cb.Id.HasValue || - !xlWorksheet.PageSetup.ColumnBreaks.Contains((int)cb.Id.Value)) - .ToList(); - - foreach (var rb in columnBreaksToDelete) - { - columnBreaks.RemoveChild(rb); - } - - var columnBreaksToAdd = xlWorksheet.PageSetup.ColumnBreaks - .Where(xlCb => !existingBreaks.Any(cb => cb.Id.HasValue && cb.Id.Value == xlCb)); - - columnBreaks.Count = (UInt32)columnBreakCount; - columnBreaks.ManualBreakCount = (UInt32)columnBreakCount; - var maxColumnNumber = (UInt32)xlWorksheet.RangeAddress.LastAddress.ColumnNumber; - foreach (var break1 in columnBreaksToAdd.Select(cb => new Break - { - Id = (UInt32)cb, - Max = maxColumnNumber, - ManualPageBreak = true - })) - columnBreaks.AppendChild(break1); - cm.SetElement(XLWorksheetContents.ColumnBreaks, columnBreaks); - } - else - { - worksheetPart.Worksheet.RemoveAllChildren(); - cm.SetElement(XLWorksheetContents.ColumnBreaks, null); - } - - #endregion ColumnBreaks - - #region Tables - - GenerateTables(xlWorksheet, worksheetPart, context, cm); - - #endregion Tables - - #region Drawings - - if (worksheetPart.DrawingsPart != null) - { - var xlPictures = xlWorksheet.Pictures as Drawings.XLPictures; - foreach (var removedPicture in xlPictures.Deleted) - { - worksheetPart.DrawingsPart.DeletePart(removedPicture); - } - xlPictures.Deleted.Clear(); - } - - foreach (var pic in xlWorksheet.Pictures) - { - AddPictureAnchor(worksheetPart, pic, context); - } - - if (xlWorksheet.Pictures.Any()) - RebaseNonVisualDrawingPropertiesIds(worksheetPart); - - var tableParts = worksheetPart.Worksheet.Elements().First(); - if (xlWorksheet.Pictures.Any() && !worksheetPart.Worksheet.OfType().Any()) - { - var worksheetDrawing = new Drawing { Id = worksheetPart.GetIdOfPart(worksheetPart.DrawingsPart) }; - worksheetDrawing.AddNamespaceDeclaration("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); - worksheetPart.Worksheet.InsertBefore(worksheetDrawing, tableParts); - cm.SetElement(XLWorksheetContents.Drawing, worksheetPart.Worksheet.Elements().First()); - } - - // Instead of saving a file with an empty Drawings.xml file, rather remove the .xml file - if (!xlWorksheet.Pictures.Any() && worksheetPart.DrawingsPart != null - && !worksheetPart.DrawingsPart.Parts.Any()) - { - var id = worksheetPart.GetIdOfPart(worksheetPart.DrawingsPart); - worksheetPart.Worksheet.RemoveChild(worksheetPart.Worksheet.OfType().FirstOrDefault(p => p.Id == id)); - worksheetPart.DeletePart(worksheetPart.DrawingsPart); - cm.SetElement(XLWorksheetContents.Drawing, null); - } - - #endregion Drawings - - #region LegacyDrawing - - if (xlWorksheet.LegacyDrawingIsNew) - { - worksheetPart.Worksheet.RemoveAllChildren(); - - if (!String.IsNullOrWhiteSpace(xlWorksheet.LegacyDrawingId)) - { - var previousElement = cm.GetPreviousElementFor(XLWorksheetContents.LegacyDrawing); - worksheetPart.Worksheet.InsertAfter(new LegacyDrawing { Id = xlWorksheet.LegacyDrawingId }, - previousElement); - - cm.SetElement(XLWorksheetContents.LegacyDrawing, worksheetPart.Worksheet.Elements().First()); - } - } - - #endregion LegacyDrawing - - #region LegacyDrawingHeaderFooter - - //LegacyDrawingHeaderFooter legacyHeaderFooter = worksheetPart.Worksheet.Elements().FirstOrDefault(); - //if (legacyHeaderFooter != null) - //{ - // worksheetPart.Worksheet.RemoveAllChildren(); - // { - // var previousElement = cm.GetPreviousElementFor(XLWSContentManager.XLWSContents.LegacyDrawingHeaderFooter); - // worksheetPart.Worksheet.InsertAfter(new LegacyDrawingHeaderFooter { Id = xlWorksheet.LegacyDrawingId }, - // previousElement); - // } - //} - - #endregion LegacyDrawingHeaderFooter - } - - private static void SetCellValue(XLCell xlCell, XLTableField field, Cell openXmlCell, SaveContext context) - { - if (field != null) - { - if (!String.IsNullOrWhiteSpace(field.TotalsRowLabel)) - { - var cellValue = new CellValue(); - cellValue.Text = xlCell.SharedStringId.ToInvariantString(); - openXmlCell.DataType = CvSharedString; - openXmlCell.CellValue = cellValue; - } - else if (field.TotalsRowFunction == XLTotalsRowFunction.None) - { - openXmlCell.DataType = CvSharedString; - openXmlCell.CellValue = null; - } - return; - } - - if (xlCell.HasFormula) - { - openXmlCell.InlineString = null; - var cellValue = new CellValue(); - try - { - var v = xlCell.Value; - cellValue.Text = v.ObjectToInvariantString(); - switch (v) - { - case String s: - openXmlCell.DataType = new EnumValue(CellValues.String); - break; - - case DateTime dt: - openXmlCell.DataType = new EnumValue(CellValues.Date); - break; - - case Boolean b: - openXmlCell.DataType = new EnumValue(CellValues.Boolean); - break; - - default: - if (v.IsNumber()) - openXmlCell.DataType = new EnumValue(CellValues.Number); - else - openXmlCell.DataType = null; - break; - } - } - catch - { - cellValue = null; - } - - openXmlCell.CellValue = cellValue; - return; - } - else - openXmlCell.CellValue = null; - - var dataType = xlCell.DataType; - - if (dataType != XLDataType.Text) - openXmlCell.InlineString = null; - - if (dataType == XLDataType.Text) - { - if (!xlCell.StyleValue.IncludeQuotePrefix && xlCell.InnerText.Length == 0) - openXmlCell.CellValue = null; - else - { - if (xlCell.ShareString) - { - var cellValue = new CellValue(); - cellValue.Text = xlCell.SharedStringId.ToInvariantString(); - openXmlCell.CellValue = cellValue; - - openXmlCell.InlineString = null; - } - else - { - var inlineString = new InlineString(); - if (xlCell.HasRichText) - { - PopulatedRichTextElements(inlineString, xlCell, context); - } - else - { - var text = xlCell.GetString(); - var t = new Text(text); - if (text.PreserveSpaces()) - t.Space = SpaceProcessingModeValues.Preserve; - - inlineString.Text = t; - } - - openXmlCell.InlineString = inlineString; - } - } - } - else if (dataType == XLDataType.TimeSpan) - { - var timeSpan = xlCell.GetTimeSpan(); - var cellValue = new CellValue(); - cellValue.Text = timeSpan.TotalDays.ToInvariantString(); - openXmlCell.CellValue = cellValue; - } - else if (dataType == XLDataType.DateTime || dataType == XLDataType.Number) - { - if (!String.IsNullOrWhiteSpace(xlCell.InnerText)) - { - var cellValue = new CellValue(); - var d = Double.Parse(xlCell.InnerText, XLHelper.NumberStyle, XLHelper.ParseCulture); - - if (xlCell.Worksheet.Workbook.Use1904DateSystem && xlCell.DataType == XLDataType.DateTime) - { - // Internally ClosedXML stores cells as standard 1900-based style - // so if a workbook is in 1904-format, we do that adjustment here and when loading. - d -= 1462; - } - - cellValue.Text = d.ToInvariantString(); - openXmlCell.CellValue = cellValue; - } - } - else - { - var cellValue = new CellValue(); - cellValue.Text = xlCell.InnerText; - openXmlCell.CellValue = cellValue; - } - } - - private static void PopulateAutoFilter(XLAutoFilter xlAutoFilter, AutoFilter autoFilter) - { - var filterRange = xlAutoFilter.Range; - autoFilter.Reference = filterRange.RangeAddress.ToString(); - - foreach (var kp in xlAutoFilter.Filters) - { - var filterColumn = new FilterColumn { ColumnId = (UInt32)kp.Key - 1 }; - var xlFilterColumn = xlAutoFilter.Column(kp.Key); - - switch (xlFilterColumn.FilterType) - { - case XLFilterType.Custom: - var customFilters = new CustomFilters(); - foreach (var filter in kp.Value) - { - var customFilter = new CustomFilter { Val = filter.Value.ObjectToInvariantString() }; - - if (filter.Operator != XLFilterOperator.Equal) - customFilter.Operator = filter.Operator.ToOpenXml(); - - if (filter.Connector == XLConnector.And) - customFilters.And = true; - - customFilters.Append(customFilter); - } - filterColumn.Append(customFilters); - break; - - case XLFilterType.TopBottom: - - var top101 = new Top10 { Val = (double)xlFilterColumn.TopBottomValue }; - if (xlFilterColumn.TopBottomType == XLTopBottomType.Percent) - top101.Percent = true; - if (xlFilterColumn.TopBottomPart == XLTopBottomPart.Bottom) - top101.Top = false; - - filterColumn.Append(top101); - break; - - case XLFilterType.Dynamic: - - var dynamicFilter = new DynamicFilter - { Type = xlFilterColumn.DynamicType.ToOpenXml(), Val = xlFilterColumn.DynamicValue }; - filterColumn.Append(dynamicFilter); - break; - - case XLFilterType.DateTimeGrouping: - var dateTimeGroupFilters = new Filters(); - foreach (var filter in kp.Value) - { - if (filter.Value is DateTime) - { - var d = (DateTime)filter.Value; - var dgi = new DateGroupItem - { - Year = (UInt16)d.Year, - DateTimeGrouping = filter.DateTimeGrouping.ToOpenXml() - }; - - if (filter.DateTimeGrouping >= XLDateTimeGrouping.Month) dgi.Month = (UInt16)d.Month; - if (filter.DateTimeGrouping >= XLDateTimeGrouping.Day) dgi.Day = (UInt16)d.Day; - if (filter.DateTimeGrouping >= XLDateTimeGrouping.Hour) dgi.Hour = (UInt16)d.Hour; - if (filter.DateTimeGrouping >= XLDateTimeGrouping.Minute) dgi.Minute = (UInt16)d.Minute; - if (filter.DateTimeGrouping >= XLDateTimeGrouping.Second) dgi.Second = (UInt16)d.Second; - - dateTimeGroupFilters.Append(dgi); - } - } - filterColumn.Append(dateTimeGroupFilters); - break; - - default: - var filters = new Filters(); - foreach (var filter in kp.Value) - { - filters.Append(new Filter { Val = filter.Value.ObjectToInvariantString() }); - } - - filterColumn.Append(filters); - break; - } - autoFilter.Append(filterColumn); - } - - if (xlAutoFilter.Sorted) - { - string reference = null; - - if (filterRange.FirstCell().Address.RowNumber < filterRange.LastCell().Address.RowNumber) - reference = filterRange.Range(filterRange.FirstCell().CellBelow(), filterRange.LastCell()).RangeAddress.ToString(); - else - reference = filterRange.RangeAddress.ToString(); - - var sortState = new SortState - { - Reference = reference - }; - - var sortCondition = new SortCondition - { - Reference = - filterRange.Range(1, xlAutoFilter.SortColumn, filterRange.RowCount(), - xlAutoFilter.SortColumn).RangeAddress.ToString() - }; - if (xlAutoFilter.SortOrder == XLSortOrder.Descending) - sortCondition.Descending = true; - - sortState.Append(sortCondition); - autoFilter.Append(sortState); - } - } - - private static void CollapseColumns(Columns columns, Dictionary sheetColumns) - { - UInt32 lastMin = 1; - var count = sheetColumns.Count; - var arr = sheetColumns.OrderBy(kp => kp.Key).ToArray(); - // sheetColumns[kp.Key + 1] - //Int32 i = 0; - //foreach (KeyValuePair kp in arr - // //.Where(kp => !(kp.Key < count && ColumnsAreEqual(kp.Value, ))) - // ) - for (var i = 0; i < count; i++) - { - var kp = arr[i]; - if (i + 1 != count && ColumnsAreEqual(kp.Value, arr[i + 1].Value)) continue; - - var newColumn = (Column)kp.Value.CloneNode(true); - newColumn.Min = lastMin; - var newColumnMax = newColumn.Max.Value; - var columnsToRemove = - columns.Elements().Where(co => co.Min >= lastMin && co.Max <= newColumnMax). - Select(co => co).ToList(); - columnsToRemove.ForEach(c => columns.RemoveChild(c)); - - columns.AppendChild(newColumn); - lastMin = kp.Key + 1; - //i++; - } - } - - private static double GetColumnWidth(double columnWidth) - { - return Math.Min(255.0, Math.Max(0.0, columnWidth + ColumnWidthOffset)); - } - - private static void UpdateColumn(Column column, Columns columns, Dictionary sheetColumnsByMin) - { - if (!sheetColumnsByMin.TryGetValue(column.Min.Value, out Column newColumn)) - { - newColumn = (Column)column.CloneNode(true); - columns.AppendChild(newColumn); - sheetColumnsByMin.Add(column.Min.Value, newColumn); - } - else - { - var existingColumn = sheetColumnsByMin[column.Min.Value]; - newColumn = (Column)existingColumn.CloneNode(true); - newColumn.Min = column.Min; - newColumn.Max = column.Max; - newColumn.Style = column.Style; - newColumn.Width = column.Width.SaveRound(); - newColumn.CustomWidth = column.CustomWidth; - - if (column.Hidden != null) - newColumn.Hidden = true; - else - newColumn.Hidden = null; - - if (column.Collapsed != null) - newColumn.Collapsed = true; - else - newColumn.Collapsed = null; - - if (column.OutlineLevel != null && column.OutlineLevel > 0) - newColumn.OutlineLevel = (byte)column.OutlineLevel; - else - newColumn.OutlineLevel = null; - - sheetColumnsByMin.Remove(column.Min.Value); - if (existingColumn.Min + 1 > existingColumn.Max) - { - //existingColumn.Min = existingColumn.Min + 1; - //columns.InsertBefore(existingColumn, newColumn); - //existingColumn.Remove(); - columns.RemoveChild(existingColumn); - columns.AppendChild(newColumn); - sheetColumnsByMin.Add(newColumn.Min.Value, newColumn); - } - else - { - //columns.InsertBefore(existingColumn, newColumn); - columns.AppendChild(newColumn); - sheetColumnsByMin.Add(newColumn.Min.Value, newColumn); - existingColumn.Min = existingColumn.Min + 1; - sheetColumnsByMin.Add(existingColumn.Min.Value, existingColumn); - } - } - } - - private static bool ColumnsAreEqual(Column left, Column right) - { - return - ((left.Style == null && right.Style == null) - || (left.Style != null && right.Style != null && left.Style.Value == right.Style.Value)) - && ((left.Width == null && right.Width == null) - || (left.Width != null && right.Width != null && (Math.Abs(left.Width.Value - right.Width.Value) < XLHelper.Epsilon))) - && ((left.Hidden == null && right.Hidden == null) - || (left.Hidden != null && right.Hidden != null && left.Hidden.Value == right.Hidden.Value)) - && ((left.Collapsed == null && right.Collapsed == null) - || - (left.Collapsed != null && right.Collapsed != null && left.Collapsed.Value == right.Collapsed.Value)) - && ((left.OutlineLevel == null && right.OutlineLevel == null) - || - (left.OutlineLevel != null && right.OutlineLevel != null && - left.OutlineLevel.Value == right.OutlineLevel.Value)); } - #endregion GenerateWorksheetPartContent } } diff --git a/ClosedXML/Excel/XLWorksheet.cs b/ClosedXML/Excel/XLWorksheet.cs index d4ada8dee..2041869ac 100644 --- a/ClosedXML/Excel/XLWorksheet.cs +++ b/ClosedXML/Excel/XLWorksheet.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using ClosedXML.Excel.InsertData; using static ClosedXML.Excel.XLProtectionAlgorithm; namespace ClosedXML.Excel @@ -19,6 +20,8 @@ internal class XLWorksheet : XLRangeBase, IXLWorksheet private readonly XLRangeFactory _rangeFactory; private readonly XLRangeRepository _rangeRepository; private readonly List _rangeIndices; + private readonly XLRanges _selectedRanges; + internal Int32 ZOrder = 1; private String _name; internal Int32 _position; @@ -26,7 +29,6 @@ internal class XLWorksheet : XLRangeBase, IXLWorksheet private Double _rowHeight; private Boolean _tabActive; private XLSheetProtection _protection; - internal Boolean EventTrackingEnabled; /// /// Fake address to be used everywhere the invalid address is needed. @@ -37,16 +39,15 @@ internal class XLWorksheet : XLRangeBase, IXLWorksheet #region Constructor - public XLWorksheet(String sheetName, XLWorkbook workbook) + public XLWorksheet(String sheetName, XLWorkbook workbook, UInt32 sheetId) : base( new XLRangeAddress( new XLAddress(null, XLHelper.MinRowNumber, XLHelper.MinColumnNumber, false, false), new XLAddress(null, XLHelper.MaxRowNumber, XLHelper.MaxColumnNumber, false, false)), - (workbook.Style as XLStyle).Value) + ((XLStyle)workbook.Style).Value) { - EventTrackingEnabled = workbook.EventTracking == XLEventTracking.Enabled; - Workbook = workbook; + SheetId = sheetId; InvalidAddress = new XLAddress(this, 0, 0, false, false); var firstAddress = new XLAddress(this, RangeAddress.FirstAddress.RowNumber, RangeAddress.FirstAddress.ColumnNumber, @@ -59,24 +60,26 @@ public XLWorksheet(String sheetName, XLWorkbook workbook) _rangeIndices = new List(); Pictures = new XLPictures(this); - NamedRanges = new XLNamedRanges(this); + DefinedNames = new XLDefinedNames(this); SheetView = new XLSheetView(this); Tables = new XLTables(); - Hyperlinks = new XLHyperlinks(); + Hyperlinks = new XLHyperlinks(this); DataValidations = new XLDataValidations(this); PivotTables = new XLPivotTables(this); - Protection = new XLSheetProtection(DefaultProtectionAlgorithm); + _protection = new XLSheetProtection(DefaultProtectionAlgorithm); AutoFilter = new XLAutoFilter(); ConditionalFormats = new XLConditionalFormats(); - SparklineGroups = new XLSparklineGroups(this); - Internals = new XLWorksheetInternals(new XLCellsCollection(), new XLColumnsCollection(), + SparklineGroupsInternal = new XLSparklineGroups(this); + Internals = new XLWorksheetInternals(new XLCellsCollection(this), new XLColumnsCollection(), new XLRowsCollection(), new XLRanges()); PageSetup = new XLPageSetup((XLPageSetup)workbook.PageOptions, this); Outline = new XLOutline(workbook.Outline); _columnWidth = workbook.ColumnWidth; _rowHeight = workbook.RowHeight; RowHeightChanged = Math.Abs(workbook.RowHeight - XLWorkbook.DefaultRowHeight) > XLHelper.Epsilon; - Name = sheetName; + + XLHelper.ValidateSheetName(sheetName); + _name = sheetName; Charts = new XLCharts(); ShowFormulas = workbook.ShowFormulas; ShowGridLines = workbook.ShowGridLines; @@ -87,45 +90,48 @@ public XLWorksheet(String sheetName, XLWorkbook workbook) ShowZeros = workbook.ShowZeros; RightToLeft = workbook.RightToLeft; TabColor = XLColor.NoColor; - SelectedRanges = new XLRanges(); + _selectedRanges = new XLRanges(); Author = workbook.Author; } #endregion Constructor + [Obsolete($"Use {nameof(DefinedNames)} instead.")] + IXLDefinedNames IXLWorksheet.NamedRanges => DefinedNames; + + IXLDefinedNames IXLWorksheet.DefinedNames => DefinedNames; + + internal XLDefinedNames DefinedNames { get; } + public override XLRangeType RangeType { get { return XLRangeType.Worksheet; } } - public string LegacyDrawingId; - public Boolean LegacyDrawingIsNew; + /// + /// Reference to a VML that contains notes, forms controls and so on. All such things are generally unified into + /// a single legacy VML file, set during load/save. + /// + public string? LegacyDrawingId; + private Double _columnWidth; public XLWorksheetInternals Internals { get; private set; } + internal XLSparklineGroups SparklineGroupsInternal { get; } + public XLRangeFactory RangeFactory { get { return _rangeFactory; } } - public override IEnumerable Styles - { - get - { - yield return GetStyle(); - foreach (XLCell c in Internals.CellsCollection.GetCells()) - yield return c.Style; - } - } - protected override IEnumerable Children { get { var columnsUsed = Internals.ColumnsCollection.Keys - .Union(Internals.CellsCollection.ColumnsUsed.Keys) + .Union(Internals.CellsCollection.ColumnsUsedKeys) .Distinct() .OrderBy(c => c) .ToList(); @@ -133,7 +139,7 @@ protected override IEnumerable Children yield return Column(col); var rowsUsed = Internals.RowsCollection.Keys - .Union(Internals.CellsCollection.RowsUsed.Keys) + .Union(Internals.CellsCollection.RowsUsedKeys) .Distinct() .OrderBy(r => r) .ToList(); @@ -146,9 +152,27 @@ protected override IEnumerable Children internal Boolean ColumnWidthChanged { get; set; } - public Int32 SheetId { get; set; } + /// + /// + /// The id of a sheet that is unique across the workbook, kept across load/save. + /// The ids of sheets are not reused. That is important for referencing the sheet + /// range/point through sheetId. If sheetIds were reused, references would refer + /// to the wrong sheet after the original sheetId was reused. Excel also doesn't + /// reuse sheetIds. + /// + /// + /// Referencing sheet through non-reused sheetIds means that reference can survive + /// sheet renaming without any changes. Always > 0 (Excel will crash on 0). + /// + /// + internal UInt32 SheetId { get; set; } - internal String RelId { get; set; } + /// + /// A cached r:id of the sheet from the file. If the sheet is a new one (not + /// yet saved), the value is null until workbook is saved. Use + /// instead is possible. Mostly for removing deleted sheet parts during save. + /// + internal String? RelId { get; set; } public XLDataValidations DataValidations { get; private set; } @@ -157,10 +181,7 @@ protected override IEnumerable Children public XLSheetProtection Protection { get => _protection; - set - { - _protection = value.Clone().CastTo(); - } + set => _protection = value.Clone().CastTo(); } public XLAutoFilter AutoFilter { get; private set; } @@ -235,38 +256,22 @@ public Int32 Position public IXLOutline Outline { get; private set; } - IXLRow IXLWorksheet.FirstRowUsed() + IXLRow? IXLWorksheet.FirstRowUsed() { return FirstRowUsed(); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRow IXLWorksheet.FirstRowUsed(Boolean includeFormats) - { - return FirstRowUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - - IXLRow IXLWorksheet.FirstRowUsed(XLCellsUsedOptions options) + IXLRow? IXLWorksheet.FirstRowUsed(XLCellsUsedOptions options) { return FirstRowUsed(options); } - IXLRow IXLWorksheet.LastRowUsed() + IXLRow? IXLWorksheet.LastRowUsed() { return LastRowUsed(); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLRow IXLWorksheet.LastRowUsed(Boolean includeFormats) - { - return LastRowUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - - IXLRow IXLWorksheet.LastRowUsed(XLCellsUsedOptions options) + IXLRow? IXLWorksheet.LastRowUsed(XLCellsUsedOptions options) { return LastRowUsed(options); } @@ -291,38 +296,22 @@ IXLRow IXLWorksheet.LastRow() return LastRow(); } - IXLColumn IXLWorksheet.FirstColumnUsed() + IXLColumn? IXLWorksheet.FirstColumnUsed() { return FirstColumnUsed(); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLColumn IXLWorksheet.FirstColumnUsed(Boolean includeFormats) - { - return FirstColumnUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - - IXLColumn IXLWorksheet.FirstColumnUsed(XLCellsUsedOptions options) + IXLColumn? IXLWorksheet.FirstColumnUsed(XLCellsUsedOptions options) { return FirstColumnUsed(options); } - IXLColumn IXLWorksheet.LastColumnUsed() + IXLColumn? IXLWorksheet.LastColumnUsed() { return LastColumnUsed(); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - IXLColumn IXLWorksheet.LastColumnUsed(Boolean includeFormats) - { - return LastColumnUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents); - } - - IXLColumn IXLWorksheet.LastColumnUsed(XLCellsUsedOptions options) + IXLColumn? IXLWorksheet.LastColumnUsed(XLCellsUsedOptions options) { return LastColumnUsed(options); } @@ -331,7 +320,7 @@ public IXLColumns Columns() { var columnMap = new HashSet(); - columnMap.UnionWith(Internals.CellsCollection.ColumnsUsed.Keys); + columnMap.UnionWith(Internals.CellsCollection.ColumnsUsedKeys); columnMap.UnionWith(Internals.ColumnsCollection.Keys); var retVal = new XLColumns(this, StyleValue, columnMap.Select(Column)); @@ -391,7 +380,7 @@ public IXLRows Rows() { var rowMap = new HashSet(); - rowMap.UnionWith(Internals.CellsCollection.RowsUsed.Keys); + rowMap.UnionWith(Internals.CellsCollection.RowsUsedKeys); rowMap.UnionWith(Internals.RowsCollection.Keys); var retVal = new XLRows(this, StyleValue, rowMap.Select(Row)); @@ -455,7 +444,7 @@ IXLCell IXLWorksheet.Cell(int row, int column) IXLCell IXLWorksheet.Cell(string cellAddressInRange) { - return Cell(cellAddressInRange); + return Cell(cellAddressInRange) ?? throw new ArgumentException($"'{cellAddressInRange}' is not A1 address or workbook named range."); } IXLCell IXLWorksheet.Cell(int row, string column) @@ -475,7 +464,7 @@ IXLRange IXLWorksheet.Range(IXLRangeAddress rangeAddress) IXLRange IXLWorksheet.Range(string rangeAddress) { - return Range(rangeAddress); + return Range(rangeAddress) ?? throw new ArgumentException($"'{rangeAddress}' is not A1 address or named range."); } IXLRange IXLWorksheet.Range(IXLCell firstCell, IXLCell lastCell) @@ -498,6 +487,8 @@ IXLRange IXLWorksheet.Range(int firstCellRow, int firstCellColumn, int lastCellR return Range(firstCellRow, firstCellColumn, lastCellRow, lastCellColumn); } + IXLRanges IXLWorksheet.Ranges(String ranges) => Ranges(ranges); + public IXLWorksheet CollapseRows() { Enumerable.Range(1, 8).ForEach(i => CollapseRows(i)); @@ -561,22 +552,29 @@ public IXLWorksheet ExpandColumns(Int32 outlineLevel) public void Delete() { IsDeleted = true; - (Workbook.NamedRanges as XLNamedRanges).OnWorksheetDeleted(Name); + Workbook.DefinedNamesInternal.OnWorksheetDeleted(Name); + Workbook.NotifyWorksheetDeleting(this); Workbook.WorksheetsInternal.Delete(Name); } - public IXLNamedRanges NamedRanges { get; private set; } - public IXLNamedRange NamedRange(String rangeName) + [Obsolete($"Used {nameof(DefinedName)} instead.")] + IXLDefinedName IXLWorksheet.NamedRange(String name) => DefinedName(name); + + IXLDefinedName IXLWorksheet.DefinedName(String name) => DefinedName(name); + + internal XLDefinedName DefinedName(String name) { - return NamedRanges.NamedRange(rangeName); + return DefinedNames.DefinedName(name); } IXLSheetView IXLWorksheet.SheetView { get => SheetView; } public XLSheetView SheetView { get; private set; } - public IXLTables Tables { get; private set; } + IXLTables IXLWorksheet.Tables => Tables; + + internal XLTables Tables { get; } public IXLTable Table(Int32 index) { @@ -625,16 +623,16 @@ public IXLWorksheet CopyTo(XLWorkbook workbook, String newSheetName, Int32 posit targetSheet.RowHeightChanged = RowHeightChanged; targetSheet.InnerStyle = InnerStyle; targetSheet.PageSetup = new XLPageSetup((XLPageSetup)PageSetup, targetSheet); - (targetSheet.PageSetup.Header as XLHeaderFooter).Changed = true; - (targetSheet.PageSetup.Footer as XLHeaderFooter).Changed = true; + ((XLHeaderFooter)targetSheet.PageSetup.Header).Changed = true; + ((XLHeaderFooter)targetSheet.PageSetup.Footer).Changed = true; targetSheet.Outline = new XLOutline(Outline); targetSheet.SheetView = new XLSheetView(targetSheet, SheetView); targetSheet.SelectedRanges.RemoveAll(); Pictures.ForEach(picture => picture.CopyTo(targetSheet)); - NamedRanges.ForEach(nr => nr.CopyTo(targetSheet)); - Tables.Cast().ForEach(t => t.CopyTo(targetSheet, false)); - PivotTables.ForEach(pt => pt.CopyTo(targetSheet.Cell(pt.TargetCell.Address.CastTo().WithoutWorksheet()))); + Tables.ForEach(t => t.CopyTo(targetSheet, false)); + DefinedNames.ForEach(nr => nr.CopyTo(targetSheet)); // Names must modify table references, so keep the order. + PivotTables.ForEach(pt => pt.CopyTo(targetSheet.Cell(pt.TargetCell.Address.CastTo().WithoutWorksheet()))); ConditionalFormats.ForEach(cf => cf.CopyTo(targetSheet)); SparklineGroups.CopyTo(targetSheet); MergedRanges.ForEach(mr => targetSheet.Range(((XLRangeAddress)mr.RangeAddress).WithoutWorksheet()).Merge()); @@ -649,7 +647,9 @@ public IXLWorksheet CopyTo(XLWorkbook workbook, String newSheetName, Int32 posit return targetSheet; } - public new IXLHyperlinks Hyperlinks { get; private set; } + internal XLHyperlinks Hyperlinks { get; } + + IXLHyperlinks IXLWorksheet.Hyperlinks => Hyperlinks; IXLDataValidations IXLWorksheet.DataValidations { @@ -685,7 +685,7 @@ public IXLWorksheet Unhide() IXLSheetProtection IXLProtectable.Protection { get => Protection; - set => Protection = value as XLSheetProtection; + set => Protection = (XLSheetProtection)value; } public IXLSheetProtection Protect(Algorithm algorithm = DefaultProtectionAlgorithm) @@ -913,7 +913,9 @@ IXLPivotTable IXLWorksheet.PivotTable(String name) return PivotTable(name); } - public IXLPivotTables PivotTables { get; private set; } + IXLPivotTables IXLWorksheet.PivotTables => PivotTables; + + public XLPivotTables PivotTables { get; } public Boolean RightToLeft { get; set; } @@ -929,7 +931,7 @@ public IXLWorksheet SetRightToLeft(Boolean value) return this; } - public override IXLRanges Ranges(String ranges) + public override XLRanges Ranges(String ranges) { var retVal = new XLRanges(); foreach (string rangeAddressStr in ranges.Split(',').Select(s => s.Trim())) @@ -942,14 +944,14 @@ public override IXLRanges Ranges(String ranges) { retVal.Add(Range(new XLRangeAddress(Worksheet, rangeAddressStr))); } - else if (NamedRanges.TryGetValue(rangeAddressStr, out IXLNamedRange worksheetNamedRange)) + else if (DefinedNames.TryGetValue(rangeAddressStr, out var worksheetNamedRange)) { worksheetNamedRange.Ranges.ForEach(retVal.Add); } - else if (Workbook.NamedRanges.TryGetValue(rangeAddressStr, out IXLNamedRange workbookNamedRange) - && workbookNamedRange.Ranges.First().Worksheet == this) + else if (Workbook.DefinedNames.TryGetValue(rangeAddressStr, out var workbookDefinedName) + && workbookDefinedName.Ranges.First().Worksheet == this) { - workbookNamedRange.Ranges.ForEach(retVal.Add); + workbookDefinedName.Ranges.ForEach(retVal.Add); } } return retVal; @@ -960,23 +962,16 @@ IXLAutoFilter IXLWorksheet.AutoFilter get { return AutoFilter; } } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLRows RowsUsed(Boolean includeFormats, Func predicate = null) - { - return RowsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, - predicate); - } - - public IXLRows RowsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents, Func predicate = null) + public IXLRows RowsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents, Func? predicate = null) { var rows = new XLRows(worksheet: null, StyleValue); var rowsUsed = new HashSet(); - Internals.RowsCollection.Keys.ForEach(r => rowsUsed.Add(r)); - Internals.CellsCollection.RowsUsed.Keys.ForEach(r => rowsUsed.Add(r)); - foreach (var rowNum in rowsUsed) + foreach (var rowNum in Internals.RowsCollection.Keys.Concat(Internals.CellsCollection.RowsUsedKeys)) { + if (!rowsUsed.Add(rowNum)) + { + continue; + } var row = Row(rowNum); if (!row.IsEmpty(options) && (predicate == null || predicate(row))) rows.Add(row); @@ -984,26 +979,17 @@ public IXLRows RowsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllConte return rows; } - public IXLRows RowsUsed(Func predicate = null) + public IXLRows RowsUsed(Func? predicate = null) { return RowsUsed(XLCellsUsedOptions.AllContents, predicate); } - [Obsolete("Use the overload with XLCellsUsedOptions")] - public IXLColumns ColumnsUsed(Boolean includeFormats, Func predicate = null) - { - return ColumnsUsed(includeFormats - ? XLCellsUsedOptions.All - : XLCellsUsedOptions.AllContents, - predicate); - } - - public IXLColumns ColumnsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents, Func predicate = null) + public IXLColumns ColumnsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.AllContents, Func? predicate = null) { var columns = new XLColumns(worksheet: null, StyleValue); var columnsUsed = new HashSet(); Internals.ColumnsCollection.Keys.ForEach(r => columnsUsed.Add(r)); - Internals.CellsCollection.ColumnsUsed.Keys.ForEach(r => columnsUsed.Add(r)); + Internals.CellsCollection.ColumnsUsedKeys.ForEach(r => columnsUsed.Add(r)); foreach (var columnNum in columnsUsed) { var column = Column(columnNum); @@ -1013,7 +999,7 @@ public IXLColumns ColumnsUsed(XLCellsUsedOptions options = XLCellsUsedOptions.Al return columns; } - public IXLColumns ColumnsUsed(Func predicate = null) + public IXLColumns ColumnsUsed(Func? predicate = null) { return ColumnsUsed(XLCellsUsedOptions.AllContents, predicate); } @@ -1090,23 +1076,23 @@ public Int32 GetMaxRowOutline() #endregion Outlines - public XLRow FirstRowUsed() + public XLRow? FirstRowUsed() { return FirstRowUsed(XLCellsUsedOptions.AllContents); } - public XLRow FirstRowUsed(XLCellsUsedOptions options) + public XLRow? FirstRowUsed(XLCellsUsedOptions options) { var rngRow = AsRange().FirstRowUsed(options); return rngRow != null ? Row(rngRow.RangeAddress.FirstAddress.RowNumber) : null; } - public XLRow LastRowUsed() + public XLRow? LastRowUsed() { return LastRowUsed(XLCellsUsedOptions.AllContents); } - public XLRow LastRowUsed(XLCellsUsedOptions options) + public XLRow? LastRowUsed(XLCellsUsedOptions options) { var rngRow = AsRange().LastRowUsed(options); return rngRow != null ? Row(rngRow.RangeAddress.LastAddress.RowNumber) : null; @@ -1132,23 +1118,23 @@ public XLRow LastRow() return Row(XLHelper.MaxRowNumber); } - public XLColumn FirstColumnUsed() + public XLColumn? FirstColumnUsed() { return FirstColumnUsed(XLCellsUsedOptions.AllContents); } - public XLColumn FirstColumnUsed(XLCellsUsedOptions options) + public XLColumn? FirstColumnUsed(XLCellsUsedOptions options) { var rngColumn = AsRange().FirstColumnUsed(options); return rngColumn != null ? Column(rngColumn.RangeAddress.FirstAddress.ColumnNumber) : null; } - public XLColumn LastColumnUsed() + public XLColumn? LastColumnUsed() { return LastColumnUsed(XLCellsUsedOptions.AllContents); } - public XLColumn LastColumnUsed(XLCellsUsedOptions options) + public XLColumn? LastColumnUsed(XLCellsUsedOptions options) { var rngColumn = AsRange().LastColumnUsed(options); return rngColumn != null ? Column(rngColumn.RangeAddress.LastAddress.ColumnNumber) : null; @@ -1170,7 +1156,7 @@ public XLColumn Column(Int32 columnNumber) { // This is a new column so we're going to reference all // cells in this column to preserve their formatting - Internals.RowsCollection.Keys.ForEach(r => Cell(r, columnNumber)); + Internals.RowsCollection.Keys.ForEach(r => Cell(r, columnNumber).PingStyle()); column = RangeFactory.CreateColumn(columnNumber); Internals.ColumnsCollection.Add(columnNumber, column); @@ -1207,12 +1193,28 @@ internal override void WorksheetRangeShiftedColumns(XLRange range, int columnsSh } } - Workbook.Worksheets.ForEach(ws => MoveNamedRangesColumns(range, columnsShifted, ws.NamedRanges)); - MoveNamedRangesColumns(range, columnsShifted, Workbook.NamedRanges); + Workbook.WorksheetsInternal.ForEach(ws => MoveDefinedNamesColumns(range, columnsShifted, ws.DefinedNames)); + MoveDefinedNamesColumns(range, columnsShifted, Workbook.DefinedNamesInternal); ShiftConditionalFormattingColumns(range, columnsShifted); ShiftDataValidationColumns(range, columnsShifted); ShiftPageBreaksColumns(range, columnsShifted); RemoveInvalidSparklines(); + + ISheetListener hyperlinks = Hyperlinks; + if (columnsShifted > 0) + { + var area = XLSheetRange + .FromRangeAddress(range.RangeAddress) + .ExtendRight(columnsShifted - 1); + Workbook.CalcEngine.OnInsertAreaAndShiftRight(range.Worksheet, area); + hyperlinks.OnInsertAreaAndShiftRight(range.Worksheet, area); + } + else if (columnsShifted < 0) + { + var area = XLSheetRange.FromRangeAddress(range.RangeAddress); + Workbook.CalcEngine.OnDeleteAreaAndShiftLeft(range.Worksheet, area); + hyperlinks.OnDeleteAreaAndShiftLeft(range.Worksheet, area); + } } private void ShiftPageBreaksColumns(XLRange range, int columnsShifted) @@ -1337,12 +1339,28 @@ internal override void WorksheetRangeShiftedRows(XLRange range, int rowsShifted) } } - Workbook.Worksheets.ForEach(ws => MoveNamedRangesRows(range, rowsShifted, ws.NamedRanges)); - MoveNamedRangesRows(range, rowsShifted, Workbook.NamedRanges); + Workbook.WorksheetsInternal.ForEach(ws => MoveDefinedNamesRows(range, rowsShifted, ws.DefinedNames)); + MoveDefinedNamesRows(range, rowsShifted, Workbook.DefinedNamesInternal); ShiftConditionalFormattingRows(range, rowsShifted); ShiftDataValidationRows(range, rowsShifted); RemoveInvalidSparklines(); ShiftPageBreaksRows(range, rowsShifted); + + ISheetListener hyperlinks = Hyperlinks; + if (rowsShifted > 0) + { + var area = XLSheetRange + .FromRangeAddress(range.RangeAddress) + .ExtendBelow(rowsShifted - 1); + Workbook.CalcEngine.OnInsertAreaAndShiftDown(range.Worksheet, area); + hyperlinks.OnInsertAreaAndShiftDown(range.Worksheet, area); + } + else if (rowsShifted < 0) + { + var area = XLSheetRange.FromRangeAddress(range.RangeAddress); + Workbook.CalcEngine.OnDeleteAreaAndShiftUp(range.Worksheet, area); + hyperlinks.OnDeleteAreaAndShiftUp(range.Worksheet, area); + } } private void ShiftPageBreaksRows(XLRange range, int rowsShifted) @@ -1459,87 +1477,90 @@ private void RemoveInvalidSparklines() } } - private void MoveNamedRangesRows(XLRange range, int rowsShifted, IXLNamedRanges namedRanges) + private void MoveDefinedNamesRows(XLRange range, int rowsShifted, XLDefinedNames definedNames) { - foreach (XLNamedRange nr in namedRanges) + foreach (var definedName in definedNames) { - var newRangeList = - nr.RangeList.Select(r => XLCell.ShiftFormulaRows(r, this, range, rowsShifted)).Where( - newReference => newReference.Length > 0).ToList(); - nr.RangeList = newRangeList; + if (definedName.SheetReferencesList.Count() > 0) + { + var newRangeList = + definedName.SheetReferencesList.Select(r => XLCell.ShiftFormulaRows(r, this, range, rowsShifted)).Where( + newReference => newReference.Length > 0).ToList(); + var unionFormula = string.Join(",", newRangeList); + definedName.SetRefersTo(unionFormula); + } } } - private void MoveNamedRangesColumns(XLRange range, int columnsShifted, IXLNamedRanges namedRanges) + private void MoveDefinedNamesColumns(XLRange range, int columnsShifted, XLDefinedNames definedNames) { - foreach (XLNamedRange nr in namedRanges) + foreach (var definedName in definedNames) { var newRangeList = - nr.RangeList.Select(r => XLCell.ShiftFormulaColumns(r, this, range, columnsShifted)).Where( + definedName.SheetReferencesList.Select(r => XLCell.ShiftFormulaColumns(r, this, range, columnsShifted)).Where( newReference => newReference.Length > 0).ToList(); - nr.RangeList = newRangeList; + var unionFormula = string.Join(",", newRangeList); + definedName.SetRefersTo(unionFormula); } } public void NotifyRangeShiftedRows(XLRange range, Int32 rowsShifted) { - try - { - SuspendEvents(); - - var rangesToShift = _rangeRepository - .Where(r => r.RangeAddress.IsValid) - .OrderBy(r => r.RangeAddress.FirstAddress.RowNumber * -Math.Sign(rowsShifted)) - .ToList(); + var rangesToShift = _rangeRepository + .Where(r => r.RangeAddress.IsValid) + .OrderBy(r => r.RangeAddress.FirstAddress.RowNumber * -Math.Sign(rowsShifted)) + .ToList(); - WorksheetRangeShiftedRows(range, rowsShifted); + WorksheetRangeShiftedRows(range, rowsShifted); - foreach (var storedRange in rangesToShift) - { - if (storedRange.IsEntireColumn()) - continue; + bool collapsed = false; + foreach (var storedRange in rangesToShift) + { + if (storedRange.IsEntireColumn()) + continue; - if (ReferenceEquals(range, storedRange)) - continue; + if (ReferenceEquals(range, storedRange)) + continue; - storedRange.WorksheetRangeShiftedRows(range, rowsShifted); + storedRange.WorksheetRangeShiftedRows(range, rowsShifted); + if (range.RangeAddress == storedRange.RangeAddress) + { + collapsed = true; } - range.WorksheetRangeShiftedRows(range, rowsShifted); } - finally + if (!collapsed) { - ResumeEvents(); + range.WorksheetRangeShiftedRows(range, rowsShifted); } } public void NotifyRangeShiftedColumns(XLRange range, Int32 columnsShifted) { - try - { - SuspendEvents(); - - var rangesToShift = _rangeRepository - .Where(r => r.RangeAddress.IsValid) - .OrderBy(r => r.RangeAddress.FirstAddress.ColumnNumber * -Math.Sign(columnsShifted)) - .ToList(); + var rangesToShift = _rangeRepository + .Where(r => r.RangeAddress.IsValid) + .OrderBy(r => r.RangeAddress.FirstAddress.ColumnNumber * -Math.Sign(columnsShifted)) + .ToList(); - WorksheetRangeShiftedColumns(range, columnsShifted); + WorksheetRangeShiftedColumns(range, columnsShifted); - foreach (var storedRange in rangesToShift) - { - if (storedRange.IsEntireRow()) - continue; + bool collapsed = false; + foreach (var storedRange in rangesToShift) + { + if (storedRange.IsEntireRow()) + continue; - if (ReferenceEquals(range, storedRange)) - continue; + if (ReferenceEquals(range, storedRange)) + continue; - storedRange.WorksheetRangeShiftedColumns(range, columnsShifted); + storedRange.WorksheetRangeShiftedColumns(range, columnsShifted); + if (range.RangeAddress == storedRange.RangeAddress) + { + collapsed = true; } - range.WorksheetRangeShiftedColumns(range, columnsShifted); } - finally + if (!collapsed) { - ResumeEvents(); + range.WorksheetRangeShiftedColumns(range, columnsShifted); } } @@ -1556,8 +1577,7 @@ public XLRow Row(Int32 rowNumber, Boolean pingCells) { // This is a new row so we're going to reference all // cells in columns of this row to preserve their formatting - - Internals.ColumnsCollection.Keys.ForEach(c => Cell(rowNumber, c)); + Internals.ColumnsCollection.Keys.ForEach(c => Cell(rowNumber, c).PingStyle()); } row = RangeFactory.CreateRow(rowNumber); @@ -1569,7 +1589,7 @@ public XLRow Row(Int32 rowNumber, Boolean pingCells) public IXLTable Table(XLRange range, Boolean addToTables, Boolean setAutofilter = true) { - return Table(range, GetNewTableName("Table"), addToTables, setAutofilter); + return Table(range, TableNameGenerator.GetNewTableName(Workbook), addToTables, setAutofilter); } public IXLTable Table(XLRange range, String name, Boolean addToTables, Boolean setAutofilter = true) @@ -1604,7 +1624,7 @@ public IXLTable Table(XLRange range, String name, Boolean addToTables, Boolean s private void CheckRangeNotOverlappingOtherEntities(XLRange range) { // Check that the range doesn't overlap with any existing tables - var firstOverlappingTable = Tables.FirstOrDefault(t => t.RangeUsed().Intersects(range)); + var firstOverlappingTable = Tables.FirstOrDefault(t => t.RangeUsed().Intersects(range)); if (firstOverlappingTable != null) throw new InvalidOperationException($"The range {range.RangeAddress.ToStringRelative(includeSheet: true)} is already part of table '{firstOverlappingTable.Name}'"); @@ -1613,25 +1633,6 @@ private void CheckRangeNotOverlappingOtherEntities(XLRange range) throw new InvalidOperationException($"The range {range.RangeAddress.ToStringRelative(includeSheet: true)} overlaps with the worksheet's autofilter."); } - private string GetNewTableName(string baseName) - { - var existingTableNames = new HashSet( - this.Workbook.Worksheets - .SelectMany(ws => ws.Tables) - .Select(t => t.Name), - StringComparer.OrdinalIgnoreCase); - - var i = 1; - string tableName; - do - { - tableName = baseName + i.ToString(); - i++; - } while (existingTableNames.Contains(tableName)); - - return tableName; - } - private IXLRange GetRangeForSort() { var range = RangeUsed(); @@ -1650,7 +1651,7 @@ public override IXLCells Cells() return Cells(true, XLCellsUsedOptions.All); } - public override IXLCells Cells(Boolean usedCellsOnly) + public override XLCells Cells(Boolean usedCellsOnly) { if (usedCellsOnly) return Cells(true, XLCellsUsedOptions.AllContents); @@ -1660,23 +1661,24 @@ public override IXLCells Cells(Boolean usedCellsOnly) .Cells(false, XLCellsUsedOptions.All); } - public override XLCell Cell(String cellAddressInRange) + public override XLCell? Cell(String cellAddressInRange) { var cell = base.Cell(cellAddressInRange); - if (cell != null) return cell; + if (cell is not null) + return cell; - if (Workbook.NamedRanges.TryGetValue(cellAddressInRange, out IXLNamedRange workbookNamedRange)) + if (Workbook.DefinedNames.TryGetValue(cellAddressInRange, out var definedName)) { - if (!workbookNamedRange.Ranges.Any()) + if (!definedName.Ranges.Any()) return null; - return workbookNamedRange.Ranges.FirstOrDefault().FirstCell().CastTo(); + return definedName.Ranges.First().FirstCell().CastTo(); } return null; } - public override XLRange Range(String rangeAddressStr) + public override XLRange? Range(String rangeAddressStr) { if (XLHelper.IsValidRangeAddress(rangeAddressStr)) return Range(new XLRangeAddress(Worksheet, rangeAddressStr)); @@ -1684,15 +1686,15 @@ public override XLRange Range(String rangeAddressStr) if (rangeAddressStr.Contains("[")) return Table(rangeAddressStr.Substring(0, rangeAddressStr.IndexOf("["))) as XLRange; - if (NamedRanges.TryGetValue(rangeAddressStr, out IXLNamedRange worksheetNamedRange)) - return worksheetNamedRange.Ranges.First().CastTo(); + if (DefinedNames.TryGetValue(rangeAddressStr, out var sheetDefinedName)) + return sheetDefinedName.Ranges.First().CastTo(); - if (Workbook.NamedRanges.TryGetValue(rangeAddressStr, out IXLNamedRange workbookNamedRange)) + if (Workbook.DefinedNamesInternal.TryGetValue(rangeAddressStr, out var workbookDefinedName)) { - if (!workbookNamedRange.Ranges.Any()) + if (!workbookDefinedName.Ranges.Any()) return null; - return workbookNamedRange.Ranges.First().CastTo(); + return workbookDefinedName.Ranges.First().CastTo(); } return null; @@ -1700,54 +1702,44 @@ public override XLRange Range(String rangeAddressStr) public IXLRanges MergedRanges { get { return Internals.MergedRanges; } } - public IXLConditionalFormats ConditionalFormats { get; private set; } - - public IXLSparklineGroups SparklineGroups { get; private set; } - - private Boolean _eventTracking; - - public void SuspendEvents() - { - _eventTracking = EventTrackingEnabled; - EventTrackingEnabled = false; - } + IXLConditionalFormats IXLWorksheet.ConditionalFormats => ConditionalFormats; - public void ResumeEvents() - { - EventTrackingEnabled = _eventTracking; - } + internal XLConditionalFormats ConditionalFormats { get; } - private IXLRanges _selectedRanges; + public IXLSparklineGroups SparklineGroups => SparklineGroupsInternal; public IXLRanges SelectedRanges { get { - _selectedRanges?.RemoveAll(r => !r.RangeAddress.IsValid); + _selectedRanges.RemoveAll(r => !r.RangeAddress.IsValid); return _selectedRanges; } - internal set - { - _selectedRanges = value; - } } - public IXLCell ActiveCell { get; set; } - - internal XLCalcEngine CalcEngine => Workbook.CalcEngine; - - public Object Evaluate(String expression, string formulaAddress = null) + IXLCell? IXLWorksheet.ActiveCell { - IXLAddress address = formulaAddress is not null ? XLAddress.Create(formulaAddress) : null; - return CalcEngine.Evaluate(expression, Workbook, this, address); + get => ActiveCell is not null ? new XLCell(this, ActiveCell.Value) : null; + set => ActiveCell = value is not null ? XLSheetPoint.FromAddress(value.Address) : null; } /// - /// Force recalculation of all cell formulas. + /// Address of active cell/cursor in the worksheet. /// + internal XLSheetPoint? ActiveCell { get; set; } + + private XLCalcEngine CalcEngine => Workbook.CalcEngine; + + public XLCellValue Evaluate(String expression, string? formulaAddress = null) + { + IXLAddress? address = formulaAddress is not null ? XLAddress.Create(formulaAddress) : null; + return CalcEngine.EvaluateFormula(expression, Workbook, this, address, true).ToCellValue(); + } + public void RecalculateAllFormulas() { - CellsUsed().Cast().ForEach(cell => cell.Evaluate(true)); + Internals.CellsCollection.FormulaSlice.MarkDirty(XLSheetRange.Full); + Workbook.CalcEngine.Recalculate(Workbook, SheetId); } public String Author { get; set; } @@ -1780,7 +1772,7 @@ public IXLPicture AddPicture(Stream stream, string name) internal IXLPicture AddPicture(Stream stream, string name, int Id) { - return (Pictures as XLPictures).Add(stream, name, Id); + return ((XLPictures)Pictures).Add(stream, name, Id); } public IXLPicture AddPicture(Stream stream, XLPictureFormat format) @@ -1813,46 +1805,144 @@ public override Boolean IsEntireColumn() return true; } - internal void SetValue(T value, int ro, int co) where T : class + internal IXLTable InsertTable(XLSheetPoint origin, IInsertDataReader reader, String tableName, Boolean createTable, Boolean addHeadings, Boolean transpose) { - if (value == null) - this.Cell(ro, co).SetValue(String.Empty, setTableHeader: true, checkMergedRanges: false); - else if (value is IConvertible) - this.Cell(ro, co).SetValue((T)Convert.ChangeType(value, typeof(T)), setTableHeader: true, checkMergedRanges: false); + if (createTable && Tables.Any(t => t.Area.Contains(origin))) + throw new InvalidOperationException($"This cell '{origin}' is already part of a table."); + + var range = InsertData(origin, reader, addHeadings, transpose); + + if (createTable) + // Create a table and save it in the file + return tableName == null ? range.CreateTable() : range.CreateTable(tableName); else - this.Cell(ro, co).SetValue(value, setTableHeader: true, checkMergedRanges: false); + // Create a table, but keep it in memory. Saved file will contain only "raw" data and column headers + return tableName == null ? range.AsTable() : range.AsTable(tableName); } - /// - /// Get a cell value not initializing it if it has not been initialized yet. - /// - /// Row number - /// Column number - /// Current value of the specified cell. Empty string for non-initialized cells. - internal object GetCellValue(int ro, int co) + internal XLRange InsertData(XLSheetPoint origin, IInsertDataReader reader, Boolean addHeadings, Boolean transpose) { - var cell = GetCell(ro, co); - if (cell is null) - return string.Empty; + // Prepare data. Heading is basically just another row of data, so unify it. + var rows = reader.GetRecords(); + var propCount = reader.GetPropertiesCount(); + if (addHeadings) + { + var headings = new XLCellValue[propCount]; + for (var i = 0; i < propCount; i++) + headings[i] = reader.GetPropertyName(i); + + rows = new[] { headings }.Concat(rows); + } + + if (transpose) + { + rows = TransposeJaggedArray(rows); + } + + var valueSlice = Internals.CellsCollection.ValueSlice; + var styleSlice = Internals.CellsCollection.StyleSlice; + + // A buffer to avoid multiple enumerations of the source. + var rowBuffer = new List(); + var maximumColumn = origin.Column; + var rowNumber = origin.Row; + foreach (var row in rows) + { + rowBuffer.AddRange(row); + + // InsertData should also clear data and if row doesn't have enough data, + // fill in the rest. Only fill up to the props to be consistent. We can't + // know how long any next row will be, so props are used as a source of truth + // for which columns should be cleared. + for (var i = rowBuffer.Count; i < propCount; ++i) + rowBuffer.Add(Blank.Value); + + // Each row can have different number of values, so we have to check every row. + maximumColumn = Math.Max(origin.Column + rowBuffer.Count - 1, maximumColumn); + if (maximumColumn > XLHelper.MaxColumnNumber || rowNumber > XLHelper.MaxRowNumber) + throw new ArgumentException("Data would write out of the sheet."); + + var column = origin.Column; + for (var i = 0; i < rowBuffer.Count; ++i) + { + var value = rowBuffer[i]; + var point = new XLSheetPoint(rowNumber, column); + var modifiedStyle = GetStyleForValue(value, point); + if (modifiedStyle is not null) + { + if (value.IsText && value.GetText()[0] == '\'') + value = value.GetText().Substring(1); + + styleSlice.Set(point, modifiedStyle); + } - // LEGACY: This is deeply suspicious, this only exists so the legacy formulas can get cell value - if (cell.IsEvaluating) - return string.Empty; + valueSlice.SetCellValue(point, value); + column++; + } - return cell.Value; + rowBuffer.Clear(); + rowNumber++; + } + + // If there is no row, rowNumber is kept at origin instead of last row + 1 . + var lastRow = Math.Max(rowNumber - 1, origin.Row); + var insertedArea = new XLSheetRange(origin, new XLSheetPoint(lastRow, maximumColumn)); + + // If inserted area affected a table, we must fix headings and totals, because these values + // are duplicated. Basically the table values are the truth and cells are a reflection of the + // truth, but here we inserted shadow first. + foreach (var table in Tables) + table.RefreshFieldsFromCells(insertedArea); + + // Invalidate only once, not for every cell. + Workbook.CalcEngine.MarkDirty(Worksheet, insertedArea); + + // Return area that contains all inserted cells, no matter how jagged were data. + return Range( + insertedArea.FirstPoint.Row, + insertedArea.FirstPoint.Column, + insertedArea.LastPoint.Row, + insertedArea.LastPoint.Column); + + // Rather memory inefficient, but the original code also materialized + // data through Linq/required multiple enumerations. + static List> TransposeJaggedArray(IEnumerable> enumerable) + { + var destination = new List>(); + + var sourceRow = 1; + foreach (var row in enumerable) + { + var sourceColumn = 1; + foreach (var sourceValue in row) + { + // The original has `sourceValue` at [sourceRow, sourceColumn] + var destinationRowCount = destination.Count; + if (sourceColumn > destinationRowCount) + destination.Add(new List()); + + // There can be jagged arrays and the destination can have spaces between columns. + var destinationRow = destination[sourceColumn - 1]; + while (destinationRow.Count < sourceRow - 1) + destinationRow.Add(Blank.Value); + + destinationRow.Add(sourceValue); + sourceColumn++; + } + + sourceRow++; + } + + return destination; + } } /// /// Get cell or null, if cell doesn't exist. /// - internal XLCell GetCell(int ro, int co) + internal XLCell? GetCell(XLSheetPoint point) { - if (Internals.CellsCollection.MaxRowUsed < ro || - Internals.CellsCollection.MaxColumnUsed < co || - !Internals.CellsCollection.Contains(ro, co)) - return null; - - return Worksheet.Internals.CellsCollection.GetCell(ro, co); + return Worksheet.Internals.CellsCollection.GetUsedCell(point); } public XLRange GetOrCreateRange(XLRangeParameters xlRangeParameters) @@ -1862,7 +1952,7 @@ public XLRange GetOrCreateRange(XLRangeParameters xlRangeParameters) if (xlRangeParameters.DefaultStyle != null && range.StyleValue == StyleValue) range.InnerStyle = xlRangeParameters.DefaultStyle; - return range as XLRange; + return (XLRange)range; } /// @@ -1871,7 +1961,7 @@ public XLRange GetOrCreateRange(XLRangeParameters xlRangeParameters) /// Address of range row. /// Style to apply. If null the worksheet's style is applied. /// Range row with the specified address. - public XLRangeRow RangeRow(XLRangeAddress address, IXLStyle defaultStyle = null) + public XLRangeRow RangeRow(XLRangeAddress address, IXLStyle? defaultStyle = null) { var rangeKey = new XLRangeKey(XLRangeType.RangeRow, address); var rangeRow = (XLRangeRow)_rangeRepository.GetOrCreate(ref rangeKey); @@ -1888,7 +1978,7 @@ public XLRangeRow RangeRow(XLRangeAddress address, IXLStyle defaultStyle = null) /// Address of range column. /// Style to apply. If null the worksheet's style is applied. /// Range column with the specified address. - public XLRangeColumn RangeColumn(XLRangeAddress address, IXLStyle defaultStyle = null) + public XLRangeColumn RangeColumn(XLRangeAddress address, IXLStyle? defaultStyle = null) { var rangeKey = new XLRangeKey(XLRangeType.RangeColumn, address); var rangeColumn = (XLRangeColumn)_rangeRepository.GetOrCreate(ref rangeKey); @@ -1959,5 +2049,93 @@ internal void DeleteRange(XLRangeAddress rangeAddress) var rangeKey = new XLRangeKey(XLRangeType.Range, rangeAddress); _rangeRepository.Remove(ref rangeKey); } + + /// + /// Get the actual style for a point in the sheet. + /// + internal XLStyleValue GetStyleValue(XLSheetPoint point) + { + var styleValue = Internals.CellsCollection.StyleSlice[point]; + if (styleValue is not null) + return styleValue; + + // If the slice doesn't contain any value, determine values by inheriting. + // Cells that lie on an intersection of a XLColumn and a XLRow have their + // style set when column/row is created to avoid problems with correct which + // style has precedence. I.e. set column blue, set row red => cell is red. + // Swap order the the cell is blue. + var sheetStyle = StyleValue; + var rowStyle = Internals.RowsCollection.TryGetValue(point.Row, out var row) + ? row.StyleValue + : sheetStyle; + var colStyle = Internals.ColumnsCollection.TryGetValue(point.Column, out var column) + ? column.StyleValue + : sheetStyle; + + return XLStyleValue.Combine(sheetStyle, rowStyle, colStyle); + } + + /// + /// Get a style that should be used for a , + /// if the value is set to the . + /// + internal XLStyleValue? GetStyleForValue(XLCellValue value, XLSheetPoint point) + { + // Because StyleValue property retrieves value from a slice, + // access it only if necessary. This happens during ever cell + // of modification and thus is performance critical. + switch (value.Type) + { + case XLDataType.DateTime: + { + var onlyDatePart = value.GetUnifiedNumber() % 1 == 0; + var styleValue = GetStyleValue(point); + if (styleValue.NumberFormat.Format.Length == 0 && + styleValue.NumberFormat.NumberFormatId == 0) + { + var dateTimeNumberFormat = styleValue.NumberFormat.WithNumberFormatId(onlyDatePart ? 14 : 22); + return styleValue.WithNumberFormat(dateTimeNumberFormat); + } + } + break; + + case XLDataType.TimeSpan: + { + var styleValue = GetStyleValue(point); + if (styleValue.NumberFormat.Format.Length == 0 && styleValue.NumberFormat.NumberFormatId == 0) + { + var durationNumberFormat = styleValue.NumberFormat.WithNumberFormatId(46); + return styleValue.WithNumberFormat(durationNumberFormat); + } + } + break; + + case XLDataType.Text: + { + var text = value.GetText(); + XLStyleValue? styleValue = null; + if (text.Length > 0 && text[0] == '\'') + { + styleValue = GetStyleValue(point); + styleValue = styleValue.WithIncludeQuotePrefix(true); + } + + var containsNewLine = text.AsSpan() + .Contains(Environment.NewLine.AsSpan(), StringComparison.Ordinal); + if (containsNewLine) + { + styleValue ??= GetStyleValue(point); + if (!styleValue.Alignment.WrapText) + { + styleValue = styleValue.WithAlignment(static alignment => alignment.WithWrapText(true)); + } + } + + return styleValue; + } + } + + return null; + } } } diff --git a/ClosedXML/Excel/XLWorksheetInternals.cs b/ClosedXML/Excel/XLWorksheetInternals.cs index c4f3c8cbc..dcc11c0bf 100644 --- a/ClosedXML/Excel/XLWorksheetInternals.cs +++ b/ClosedXML/Excel/XLWorksheetInternals.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace ClosedXML.Excel @@ -17,14 +19,15 @@ XLRanges mergedRanges MergedRanges = mergedRanges; } - public XLCellsCollection CellsCollection { get; private set; } - public XLColumnsCollection ColumnsCollection { get; private set; } - public XLRowsCollection RowsCollection { get; private set; } + public XLCellsCollection CellsCollection { get; } + public XLColumnsCollection ColumnsCollection { get; } + public XLRowsCollection RowsCollection { get; } public XLRanges MergedRanges { get; internal set; } // Used by Janitor.Fody private void DisposeManaged() { + CellsCollection.ValueSlice.DereferenceSlice(); CellsCollection.Clear(); ColumnsCollection.Clear(); RowsCollection.Clear(); diff --git a/ClosedXML/Excel/XLWorksheets.cs b/ClosedXML/Excel/XLWorksheets.cs index 25cd79d4a..89b3331c5 100644 --- a/ClosedXML/Excel/XLWorksheets.cs +++ b/ClosedXML/Excel/XLWorksheets.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Data; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; namespace ClosedXML.Excel @@ -13,6 +14,11 @@ internal class XLWorksheets : IXLWorksheets, IEnumerable private readonly Dictionary _worksheets = new Dictionary(StringComparer.OrdinalIgnoreCase); internal ICollection Deleted { get; private set; } + /// + /// SheetId that will be assigned to next created sheet. + /// + private UInt32 _nextSheetId = 1; + #region Constructor public XLWorksheets(XLWorkbook workbook) @@ -45,13 +51,25 @@ public Boolean Contains(String sheetName) return _worksheets.ContainsKey(sheetName); } - public bool TryGetWorksheet(string sheetName, out IXLWorksheet worksheet) + bool IXLWorksheets.TryGetWorksheet(string sheetName, [NotNullWhen(true)] out IXLWorksheet? worksheet) + { + if (TryGetWorksheet(sheetName, out var foundSheet)) + { + worksheet = foundSheet; + return true; + } + + worksheet = null; + return false; + } + + internal bool TryGetWorksheet(string sheetName, [NotNullWhen(true)] out XLWorksheet? worksheet) { - if (_worksheets.TryGetValue(sheetName.UnescapeSheetName(), out XLWorksheet w)) + if (_worksheets.TryGetValue(sheetName.UnescapeSheetName(), out worksheet)) { - worksheet = w; return true; } + worksheet = null; return false; } @@ -93,17 +111,25 @@ public IXLWorksheet Add(Int32 position) public IXLWorksheet Add(String sheetName) { - var sheet = new XLWorksheet(sheetName, _workbook); + var sheet = new XLWorksheet(sheetName, _workbook, GetNextSheetId()); Add(sheetName, sheet); sheet._position = _worksheets.Count + _workbook.UnsupportedSheets.Count; return sheet; } public IXLWorksheet Add(String sheetName, Int32 position) + { + return Add(sheetName, position, GetNextSheetId()); + } + + internal XLWorksheet Add(String sheetName, Int32 position, UInt32 sheetId) { _worksheets.Values.Where(w => w._position >= position).ForEach(w => w._position += 1); _workbook.UnsupportedSheets.Where(w => w.Position >= position).ForEach(w => w.Position += 1); - var sheet = new XLWorksheet(sheetName, _workbook); + + // If the loaded sheetId is greater than current, just make sure our next sheetId is even bigger. + _nextSheetId = Math.Max(_nextSheetId, sheetId + 1); + var sheet = new XLWorksheet(sheetName, _workbook, sheetId); Add(sheetName, sheet); sheet._position = position; return sheet; @@ -115,6 +141,8 @@ private void Add(String sheetName, XLWorksheet sheet) throw new ArgumentException(String.Format("A worksheet with the same name ({0}) has already been added.", sheetName), nameof(sheetName)); _worksheets.Add(sheetName, sheet); + + _workbook.NotifyWorksheetAdded(sheet); } public void Delete(String sheetName) @@ -139,7 +167,6 @@ public void Delete(Int32 position) _worksheets.RemoveAll(w => w.Position == position); _worksheets.Values.Where(w => w.Position > position).ForEach(w => w._position -= 1); _workbook.UnsupportedSheets.Where(w => w.Position > position).ForEach(w => w.Position -= 1); - _workbook.InvalidateFormulas(); ws.Cleanup(); } @@ -160,9 +187,14 @@ public IXLWorksheet Add(DataTable dataTable) } public IXLWorksheet Add(DataTable dataTable, String sheetName) + { + return Add(dataTable, sheetName, TableNameGenerator.GetNewTableName(_workbook)); + } + + public IXLWorksheet Add(DataTable dataTable, String sheetName, string tableName) { var ws = Add(sheetName); - ws.Cell(1, 1).InsertTable(dataTable, sheetName); + ws.Cell(1, 1).InsertTable(dataTable, tableName); return ws; } @@ -184,10 +216,36 @@ public void Rename(String oldSheetName, String newSheetName) _worksheets.Remove(oldSheetName); Add(newSheetName, ws); + + foreach (var listener in GetWorkbookListeners()) + listener.OnSheetRenamed(oldSheetName, newSheetName); } #region Private members + private IEnumerable GetWorkbookListeners() + { + // All components that should be updated when sheet is added/removed or renamed should + // be enumerated here. + yield return _workbook.CalcEngine; + + foreach (var sheet in _worksheets.Values) + { + yield return sheet.Internals.CellsCollection; + } + + foreach (var definedName in _workbook.DefinedNamesInternal) + yield return definedName; + + foreach (var sheet in _worksheets.Values) + { + foreach (var definedName in sheet.DefinedNames) + { + yield return definedName; + } + } + } + private String GetNextWorksheetName() { var worksheetNumber = this.Count + 1; @@ -200,6 +258,8 @@ private String GetNextWorksheetName() return sheetName; } + private UInt32 GetNextSheetId() => _nextSheetId++; + #endregion Private members } } diff --git a/ClosedXML/Extensions/AttributeExtensions.cs b/ClosedXML/Extensions/AttributeExtensions.cs index 008de853a..f9df7e732 100644 --- a/ClosedXML/Extensions/AttributeExtensions.cs +++ b/ClosedXML/Extensions/AttributeExtensions.cs @@ -1,12 +1,13 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Linq; -using System.Linq.Expressions; using System.Reflection; namespace ClosedXML { - public static class AttributeExtensions + internal static class AttributeExtensions { public static TAttribute[] GetAttributes( this MemberInfo member) @@ -17,16 +18,6 @@ public static TAttribute[] GetAttributes( return (TAttribute[])attributes; } - public static MethodInfo GetMethod(this T instance, Expression> methodSelector) - { - return ((MethodCallExpression)methodSelector.Body).Method; - } - - public static MethodInfo GetMethod(this T instance, Expression> methodSelector) - { - return ((MethodCallExpression)methodSelector.Body).Method; - } - public static bool HasAttribute( this MemberInfo member) where TAttribute : Attribute diff --git a/ClosedXML/Extensions/ColorExtensions.cs b/ClosedXML/Extensions/ColorExtensions.cs index b832509b6..bb6094c2a 100644 --- a/ClosedXML/Extensions/ColorExtensions.cs +++ b/ClosedXML/Extensions/ColorExtensions.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Drawing; diff --git a/ClosedXML/Extensions/CompatibilityExtensions.cs b/ClosedXML/Extensions/CompatibilityExtensions.cs index 61e8dc7f7..dcf3de2a3 100644 --- a/ClosedXML/Extensions/CompatibilityExtensions.cs +++ b/ClosedXML/Extensions/CompatibilityExtensions.cs @@ -1,4 +1,7 @@ -// This file contains extensions methods that are present in .NET Core, but not in .NET Standard 2.0 +#nullable disable + +// This file contains extensions methods that are present in .NET Core, but not in .NET Standard 2.0 +#if !NETSTANDARD2_1_OR_GREATER namespace System.IO { internal static class StreamCompatibilityExtensions @@ -17,3 +20,4 @@ public static int Read(this Stream stream, Span span) } } } +#endif diff --git a/ClosedXML/Extensions/DateTimeExtensions.cs b/ClosedXML/Extensions/DateTimeExtensions.cs index 667a1286e..8f67a1b8b 100644 --- a/ClosedXML/Extensions/DateTimeExtensions.cs +++ b/ClosedXML/Extensions/DateTimeExtensions.cs @@ -1,48 +1,16 @@ // Keep this file CodeMaid organised and cleaned using System; -using System.Collections.Generic; -using System.Linq; -namespace ClosedXML.Excel +namespace ClosedXML.Excel; + +internal static class DateTimeExtensions { - internal static class DateTimeExtensions + public static double ToSerialDateTime(this DateTime dateTime) { - public static Double MaxOADate - { - get - { - return 2958465.99999999; - } - } - - public static bool IsWorkDay(this DateTime date, IEnumerable bankHolidays) - { - return date.DayOfWeek != DayOfWeek.Saturday - && date.DayOfWeek != DayOfWeek.Sunday - && !bankHolidays.Contains(date); - } - - public static DateTime NextWorkday(this DateTime date, IEnumerable bankHolidays) - { - var nextDate = date.AddDays(1); - while (!nextDate.IsWorkDay(bankHolidays)) - nextDate = nextDate.AddDays(1); - - return nextDate; - } - - public static DateTime PreviousWorkDay(this DateTime date, IEnumerable bankHolidays) - { - var previousDate = date.AddDays(-1); - while (!previousDate.IsWorkDay(bankHolidays)) - previousDate = previousDate.AddDays(-1); - - return previousDate; - } - - public static double ToSerialDateTime(this TimeSpan time) - { - return ((time.Hours % 24) + (time.Minutes % 60) / 60.0 + (time.Seconds % 60) / 3600.0) / 24.0; - } + // Excel says 1900 was a leap year :( Replicate an incorrect behavior thanks + // to Lotus 1-2-3 decision from 1983... + var oDate = dateTime.ToOADate(); + const int nonExistent1900Feb29SerialDate = 60; + return oDate <= nonExistent1900Feb29SerialDate ? oDate - 1 : oDate; } } diff --git a/ClosedXML/Extensions/DictionaryExtensions.cs b/ClosedXML/Extensions/DictionaryExtensions.cs index 559ace5e7..dc5987421 100644 --- a/ClosedXML/Extensions/DictionaryExtensions.cs +++ b/ClosedXML/Extensions/DictionaryExtensions.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; diff --git a/ClosedXML/Extensions/DoubleExtensions.cs b/ClosedXML/Extensions/DoubleExtensions.cs index 8a096f386..2be0e868e 100644 --- a/ClosedXML/Extensions/DoubleExtensions.cs +++ b/ClosedXML/Extensions/DoubleExtensions.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; @@ -9,5 +11,38 @@ public static Double SaveRound(this Double value) { return Math.Round(value, 6); } + + public static TimeSpan ToSerialTimeSpan(this Double value) + { + return XLHelper.GetTimeSpan(value); + } + + public static DateTime ToSerialDateTime(this Double value) + { + if (value >= 61.0) + return DateTime.FromOADate(value); + if (value <= 60.0) + return DateTime.FromOADate(value + 1); + + throw new ArgumentException($"Serial date 60 is on a leap year of 1900 - date that doesn't exist and isn't representable in DateTime."); + } + + /// + /// Round the number to the integer. + /// + /// A helper method to avoid need to specify the midpoint rounding and casting each time. + public static Int32 RoundToInt(this Double value) + { + return (int)Math.Round(value, MidpointRounding.AwayFromZero); + } + + /// + /// Round the number to specified number of digits. + /// + /// A helper method to avoid need to specify the midpoint rounding each time. + public static Double Round(this Double value, int digits) + { + return Math.Round(value, digits, MidpointRounding.AwayFromZero); + } } } diff --git a/ClosedXML/Extensions/DoubleValueExtensions.cs b/ClosedXML/Extensions/DoubleValueExtensions.cs index e5f77b054..cd68bbf2f 100644 --- a/ClosedXML/Extensions/DoubleValueExtensions.cs +++ b/ClosedXML/Extensions/DoubleValueExtensions.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using DocumentFormat.OpenXml; using System; diff --git a/ClosedXML/Extensions/EnumerableExtensions.cs b/ClosedXML/Extensions/EnumerableExtensions.cs index 68049e48f..097dd1ac6 100644 --- a/ClosedXML/Extensions/EnumerableExtensions.cs +++ b/ClosedXML/Extensions/EnumerableExtensions.cs @@ -14,15 +14,12 @@ public static void ForEach(this IEnumerable source, Action action) action(item); } - public static Type GetItemType(this IEnumerable source) + public static Type? GetItemType(this IEnumerable source) { - return GetGenericArgument(source?.GetType()); + return GetGenericArgument(source.GetType()); - Type GetGenericArgument(Type collectionType) + Type? GetGenericArgument(Type collectionType) { - if (collectionType == null) - return null; - var ienumerable = collectionType.GetInterfaces() .SingleOrDefault(i => i.GetGenericArguments().Length == 1 && i.Name == "IEnumerable`1"); @@ -31,6 +28,28 @@ Type GetGenericArgument(Type collectionType) } } + public static HashSet ToHashSet(this IEnumerable source) + { + return new HashSet(source); + } + + /// + /// Skip last element of a sequence. + /// + public static IEnumerable SkipLast(this IEnumerable source) + { + using var enumerator = source.GetEnumerator(); + if (!enumerator.MoveNext()) + yield break; + + T prev = enumerator.Current; + while (enumerator.MoveNext()) + { + yield return prev; + prev = enumerator.Current; + } + } + public static Boolean HasDuplicates(this IEnumerable source) { HashSet distinctItems = new HashSet(); @@ -43,5 +62,14 @@ public static Boolean HasDuplicates(this IEnumerable source) } return false; } + + /// + /// Select all that are not null. + /// + public static IEnumerable WhereNotNull(this IEnumerable source, Func property) + where TItem : struct + { + return source.Select(property).Where(x => x.HasValue).Select(x => x!.Value); + } } } diff --git a/ClosedXML/Extensions/FontBaseExtensions.cs b/ClosedXML/Extensions/FontBaseExtensions.cs index ea0ed375e..8c71b104f 100644 --- a/ClosedXML/Extensions/FontBaseExtensions.cs +++ b/ClosedXML/Extensions/FontBaseExtensions.cs @@ -17,6 +17,7 @@ public static void CopyFont(this IXLFontBase font, IXLFontBase sourceFont) font.FontName = sourceFont.FontName; font.FontFamilyNumbering = sourceFont.FontFamilyNumbering; font.FontCharSet = sourceFont.FontCharSet; + font.FontScheme = sourceFont.FontScheme; } } } diff --git a/ClosedXML/Extensions/FormatExtensions.cs b/ClosedXML/Extensions/FormatExtensions.cs index c8a3addd6..045ac2a24 100644 --- a/ClosedXML/Extensions/FormatExtensions.cs +++ b/ClosedXML/Extensions/FormatExtensions.cs @@ -1,4 +1,4 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using ExcelNumberFormat; using System.Globalization; @@ -6,13 +6,13 @@ namespace ClosedXML.Extensions { internal static class FormatExtensions { - public static string ToExcelFormat(this object o, string format) + public static string ToExcelFormat(this object o, string format, CultureInfo culture) { var nf = new NumberFormat(format); if (!nf.IsValid) return format; - return nf.Format(o, CultureInfo.InvariantCulture); + return nf.Format(o, culture); } } } diff --git a/ClosedXML/Extensions/GuidExtensions.cs b/ClosedXML/Extensions/GuidExtensions.cs index fe2f3a514..18b3868f2 100644 --- a/ClosedXML/Extensions/GuidExtensions.cs +++ b/ClosedXML/Extensions/GuidExtensions.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; namespace ClosedXML.Extensions diff --git a/ClosedXML/Extensions/IntegerExtensions.cs b/ClosedXML/Extensions/IntegerExtensions.cs index 27cf90b1c..e5f2c183f 100644 --- a/ClosedXML/Extensions/IntegerExtensions.cs +++ b/ClosedXML/Extensions/IntegerExtensions.cs @@ -1,4 +1,9 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned + +using System.Diagnostics; + namespace ClosedXML.Excel { internal static class IntegerExtensions @@ -7,5 +12,56 @@ public static bool Between(this int val, int from, int to) { return val >= from && val <= to; } + + /// + /// Get index of highest set bit <= to or -1 if no such bit. + /// + internal static int GetHighestSetBitBelow(this uint value, int maximalIndex) + { + Debug.Assert(maximalIndex >= 0 && maximalIndex < 32); + const uint highestBit = 0x80000000; + value <<= 31 - maximalIndex; + while (value != 0) + { + if ((value & highestBit) != 0) + return maximalIndex; + value <<= 1; + maximalIndex--; + } + + return -1; + } + + /// + /// Get index of lowest set bit >= to or -1 if no such bit. + /// + internal static int GetLowestSetBitAbove(this uint value, int minimalIndex) + { + value >>= minimalIndex; + while (value != 0) + { + if ((value & 1) == 1) + return minimalIndex; + value >>= 1; + minimalIndex++; + } + + return -1; + } + + /// + /// Get highest set bit index or -1 if no bit is set. + /// + internal static int GetHighestSetBit(this uint value) + { + var highestSetBitIndex = -1; + while (value != 0) + { + value >>= 1; + highestSetBitIndex++; + } + + return highestSetBitIndex; + } } } diff --git a/ClosedXML/Extensions/ObjectExtensions.cs b/ClosedXML/Extensions/ObjectExtensions.cs index c4016baa7..4f143958b 100644 --- a/ClosedXML/Extensions/ObjectExtensions.cs +++ b/ClosedXML/Extensions/ObjectExtensions.cs @@ -11,21 +11,6 @@ public static T CastTo(this Object o) return (T)Convert.ChangeType(o, typeof(T)); } - public static bool IsNumber(this object value) - { - return value is sbyte - || value is byte - || value is short - || value is ushort - || value is int - || value is uint - || value is long - || value is ulong - || value is float - || value is double - || value is decimal; - } - public static string ToInvariantString(this T value) where T : struct { return value switch @@ -47,30 +32,5 @@ public static string ToInvariantString(this T value) where T : struct _ => value.ToString(), }; } - - // This method may cause boxing of value types so it is better to replace its calls with - // the generic version, where applicable - public static string ObjectToInvariantString(this object value) - { - return value switch - { - null => string.Empty, - sbyte v => v.ToString(CultureInfo.InvariantCulture), - byte v => v.ToString(CultureInfo.InvariantCulture), - short v => v.ToString(CultureInfo.InvariantCulture), - ushort v => v.ToString(CultureInfo.InvariantCulture), - int v => v.ToString(CultureInfo.InvariantCulture), - uint v => v.ToString(CultureInfo.InvariantCulture), - long v => v.ToString(CultureInfo.InvariantCulture), - ulong v => v.ToString(CultureInfo.InvariantCulture), - float v => v.ToString("G7", CultureInfo.InvariantCulture), // Specify precision explicitly for backward compatibility - double v => v.ToString("G15", CultureInfo.InvariantCulture), // Specify precision explicitly for backward compatibility - decimal v => v.ToString(CultureInfo.InvariantCulture), - TimeSpan ts => ts.ToString("c", CultureInfo.InvariantCulture), - DateTime d => d.ToString(CultureInfo.InvariantCulture), - bool b => b.ToString().ToLowerInvariant(), - _ => value.ToString(), - }; - } } } diff --git a/ClosedXML/Extensions/OpenXmlPartContainerExtensions.cs b/ClosedXML/Extensions/OpenXmlPartContainerExtensions.cs index 3c5ed9351..2473e82b6 100644 --- a/ClosedXML/Extensions/OpenXmlPartContainerExtensions.cs +++ b/ClosedXML/Extensions/OpenXmlPartContainerExtensions.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using DocumentFormat.OpenXml.Packaging; using System; diff --git a/ClosedXML/Extensions/OpenXmlPartReaderExtensions.cs b/ClosedXML/Extensions/OpenXmlPartReaderExtensions.cs new file mode 100644 index 000000000..e2ac6a410 --- /dev/null +++ b/ClosedXML/Extensions/OpenXmlPartReaderExtensions.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.ObjectModel; +using System.Globalization; +using ClosedXML.Excel; +using ClosedXML.Excel.IO; +using DocumentFormat.OpenXml; + +namespace ClosedXML.Extensions +{ + internal static class OpenXmlPartReaderExtensions + { + internal static bool IsStartElement(this OpenXmlPartReader reader, string localName) + { + return reader.LocalName == localName && reader.NamespaceUri == OpenXmlConst.Main2006SsNs && reader.IsStartElement; + } + + internal static void MoveAhead(this OpenXmlPartReader reader) + { + if (!reader.Read()) + throw new InvalidOperationException("Unexpected end of stream."); + } + + internal static string? GetAttribute(this ReadOnlyCollection attributes, string name) + { + // Don't use foreach, performance critical + var length = attributes.Count; + for (var i = 0; i < length; ++i) + { + var attr = attributes[i]; + if (attr.LocalName == name && string.IsNullOrEmpty(attr.NamespaceUri)) + return attr.Value; + } + + return null; + } + + internal static string? GetAttribute(this ReadOnlyCollection attributes, string name, string namespaceUri) + { + // Don't use foreach, performance critical + var length = attributes.Count; + for (var i = 0; i < length; ++i) + { + var attr = attributes[i]; + if (attr.LocalName == name && attr.NamespaceUri == namespaceUri) + return attr.Value; + } + + return null; + } + + internal static bool GetBoolAttribute(this ReadOnlyCollection attributes, string name, bool defaultValue) + { + var attribute = attributes.GetAttribute(name); + return ParseBool(attribute, defaultValue); + } + + internal static int? GetIntAttribute(this ReadOnlyCollection attributes, string name) + { + var attribute = attributes.GetAttribute(name); + if (!string.IsNullOrEmpty(attribute)) + return int.Parse(attribute); + + return null; + } + + internal static uint? GetUintAttribute(this ReadOnlyCollection attributes, string name) + { + var attribute = attributes.GetAttribute(name); + if (!string.IsNullOrEmpty(attribute)) + return uint.Parse(attribute); + + return null; + } + + internal static double? GetDoubleAttribute(this ReadOnlyCollection attributes, string name, string namespaceUri) + { + var attribute = attributes.GetAttribute(name, namespaceUri); + if (!string.IsNullOrEmpty(attribute)) + return double.Parse(attribute, NumberStyles.Float, XLHelper.ParseCulture); + + return null; + } + + internal static double? GetDoubleAttribute(this ReadOnlyCollection attributes, string name) + { + var attribute = attributes.GetAttribute(name); + if (!string.IsNullOrEmpty(attribute)) + return double.Parse(attribute, NumberStyles.Float, XLHelper.ParseCulture); + + return null; + } + + /// + /// Get value of attribute with type ST_CellRef. + /// + internal static XLSheetPoint? GetCellRefAttribute(this ReadOnlyCollection attributes, string name) + { + var attribute = attributes.GetAttribute(name); + if (!string.IsNullOrEmpty(attribute)) + return XLSheetPoint.Parse(attribute); + + return null; + } + + /// + /// Get value of attribute with type ST_Ref. + /// + internal static XLSheetRange? GetRefAttribute(this ReadOnlyCollection attributes, string name) + { + var attribute = attributes.GetAttribute(name); + if (!string.IsNullOrEmpty(attribute)) + return XLSheetRange.Parse(attribute); + + return null; + } + + private static bool ParseBool(string? input, bool defaultValue) + { + if (string.IsNullOrEmpty(input)) + return defaultValue; + + var isTrue = input == "1" || string.Equals("true", input, StringComparison.OrdinalIgnoreCase); + if (isTrue) + return true; + + var isFalse = input == "0" || string.Equals("false", input, StringComparison.OrdinalIgnoreCase); + if (isFalse) + return false; + + throw new FormatException($"Unable to parse '{input}' to bool."); + } + } +} diff --git a/ClosedXML/Extensions/ReferenceAreaExtensions.cs b/ClosedXML/Extensions/ReferenceAreaExtensions.cs new file mode 100644 index 000000000..cf6dd8bb7 --- /dev/null +++ b/ClosedXML/Extensions/ReferenceAreaExtensions.cs @@ -0,0 +1,83 @@ +using System; +using ClosedXML.Excel; +using ClosedXML.Parser; + +namespace ClosedXML.Extensions +{ + /// + /// Extensions method for . + /// + internal static class ReferenceAreaExtensions + { + /// + /// Convert area to an absolute sheet range (regardless if the area is A1 or R1C1). + /// + /// Area to convert + /// An anchor address that is the center of R1C1 relative address. + /// Converted absolute range. + public static XLSheetRange ToSheetRange(this ReferenceArea area, XLSheetPoint anchor) + { + int col1, row1, col2, row2; + if (area.First.IsA1) + { + row1 = A1ToPosition(area.First.RowType, area.First.RowValue, XLHelper.MinRowNumber); + col1 = A1ToPosition(area.First.ColumnType, area.First.ColumnValue, XLHelper.MinColumnNumber); + row2 = A1ToPosition(area.Second.RowType, area.Second.RowValue, XLHelper.MaxRowNumber); + col2 = A1ToPosition(area.Second.ColumnType, area.Second.ColumnValue, XLHelper.MaxColumnNumber); + } + else + { + row1 = R1C1ToPosition(area.First.RowType, area.First.RowValue, anchor.Row, XLHelper.MinRowNumber, XLHelper.MaxRowNumber); + col1 = R1C1ToPosition(area.First.ColumnType, area.First.ColumnValue, anchor.Column, XLHelper.MinColumnNumber, XLHelper.MaxColumnNumber); + row2 = R1C1ToPosition(area.Second.RowType, area.Second.RowValue, anchor.Row, XLHelper.MaxRowNumber, XLHelper.MaxRowNumber); + col2 = R1C1ToPosition(area.Second.ColumnType, area.Second.ColumnValue, anchor.Column, XLHelper.MaxColumnNumber, XLHelper.MaxColumnNumber); + } + + // Points in the token `area` don't have to be in top left and bottom right corners, + // e.g. D4:A1 or D1:A4. Normalize coordinates, so the sheet range has expected corners. + var colStart = Math.Min(col1, col2); + var colEnd = Math.Max(col1, col2); + var rowStart = Math.Min(row1, row2); + var rowEnd = Math.Max(row1, row2); + return new XLSheetRange(rowStart, colStart, rowEnd, colEnd); + } + + private static int A1ToPosition(ReferenceAxisType axisType, int position, int defaultPosition) + { + return axisType switch + { + ReferenceAxisType.Absolute => position, // $A$1 => R1C1 + ReferenceAxisType.Relative => position, // A1 => R1C1 + ReferenceAxisType.None => defaultPosition, // Only other axis specified, e.g. A:B doesn't have row. + _ => throw new NotSupportedException() + }; + } + + private static int R1C1ToPosition(ReferenceAxisType axisType, int position, int anchor, int defaultPosition, int dimensionSize) + { + switch (axisType) + { + case ReferenceAxisType.Absolute: // R2C5 + return position; + + case ReferenceAxisType.Relative: // R[2]C[5] + { + var absolutePosition = anchor + position; + if (absolutePosition < 1) + return absolutePosition + dimensionSize; + + if (absolutePosition > dimensionSize) + return absolutePosition - dimensionSize; + + return absolutePosition; + } + + case ReferenceAxisType.None: + return defaultPosition; // other axis specified, e.g. R3:R5 doesn't have row. + + default: + throw new NotSupportedException(); + } + } + } +} diff --git a/ClosedXML/Extensions/ReflectionExtensions.cs b/ClosedXML/Extensions/ReflectionExtensions.cs index a6984e161..ea83f593c 100644 --- a/ClosedXML/Extensions/ReflectionExtensions.cs +++ b/ClosedXML/Extensions/ReflectionExtensions.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System.Reflection; diff --git a/ClosedXML/Extensions/StringExtensions.cs b/ClosedXML/Extensions/StringExtensions.cs index f53ed5d0f..33fd96c43 100644 --- a/ClosedXML/Extensions/StringExtensions.cs +++ b/ClosedXML/Extensions/StringExtensions.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using System.Linq; using System.Text; @@ -52,7 +54,7 @@ internal static String FixNewLines(this String value) internal static Boolean PreserveSpaces(this String value) { - return value.StartsWith(" ") || value.EndsWith(" ") || value.Contains(Environment.NewLine); + return value.StartsWith(' ') || value.EndsWith(' ') || value.AsSpan().IndexOfAny('\n', '\r', '\t') >= 0; } internal static String ToCamel(this String value) @@ -88,5 +90,77 @@ internal static string WithoutLast(this String value, int length) { return length < value.Length ? value.Substring(0, value.Length - length) : String.Empty; } + + /// + /// Convert a string (containing code units) into code points. + /// Surrogate pairs of code units are joined to code points. + /// + /// UTF-16 code units to convert. + /// Output containing code points. Must always be able to fit whole . + /// Number of code points in the . + internal static int ToCodePoints(this ReadOnlySpan text, Span output) + { + var j = 0; + for (var i = 0; i < text.Length; ++i, ++j) + { + if (i + 1 < text.Length && char.IsSurrogatePair(text[i], text[i + 1])) + { + output[j] = char.ConvertToUtf32(text[i], text[i + 1]); + i++; + } + else + { + output[j] = text[i]; + } + } + + return j; + } + + /// + /// Is the string a new line of any kind (widnows/unix/mac)? + /// + /// Input text to check for EOL at the beginning. + /// Length of EOL chars. + /// True, if text has EOL at the beginning. + internal static bool TrySliceNewLine(this ReadOnlySpan text, out int length) + { + if (text.Length >= 2 && text[0] == '\r' && text[1] == '\n') + { + length = 2; + return true; + } + + if (text.Length >= 1 && (text[0] == '\n' || text[0] == '\r')) + { + length = 1; + return true; + } + + length = default; + return false; + } + + /// + /// Convert a magic text to a number, where the first letter is in the highest byte of the number. + /// + internal static UInt32 ToMagicNumber(this string magic) + { + if (magic.Length > 4) + { + throw new ArgumentException(); + } + + return Encoding.ASCII.GetBytes(magic).Select(x => (uint)x).Aggregate((acc, cur) => acc * 256 + cur); + } + + internal static String TrimFormulaEqual(this String text) + { + var trimmed = text.AsSpan().Trim(); + if (trimmed.Length > 1 && trimmed[0] == '=') + return trimmed[1..].TrimStart().ToString(); + + return text; + } } } diff --git a/ClosedXML/Extensions/TimeSpanExtensions.cs b/ClosedXML/Extensions/TimeSpanExtensions.cs new file mode 100644 index 000000000..b80ec6762 --- /dev/null +++ b/ClosedXML/Extensions/TimeSpanExtensions.cs @@ -0,0 +1,46 @@ +#nullable disable + +using ClosedXML.Excel.CalcEngine; +using System; +using System.Globalization; +using System.Text; + +namespace ClosedXML.Excel +{ + internal static class TimeSpanExtensions + { + public static double ToSerialDateTime(this TimeSpan time) + { + return time.Ticks / (double)TimeSpan.TicksPerDay; + } + + /// + /// Return a string representation of a TimeSpan that can be parsed by an Excel through text-to-number coercion. + /// + /// + /// Excel can convert time span string back to a number, but only if it doesn't has days in the string, only hours. + /// It's an opposite of . + /// + public static String ToExcelString(this TimeSpan ts, CultureInfo culture) + { + var timeSep = culture.DateTimeFormat.TimeSeparator; + var sb = new StringBuilder() + .Append(ts.Hours + 24 * ts.Days).Append(timeSep) + .AppendFormat("{0:D2}", ts.Minutes).Append(timeSep) + .AppendFormat("{0:D2}", ts.Seconds); + // the ts.Miliseconds property uses whole division and due to serial datetime conversion, it should be rounded instead + var ms = (int)Math.Round((ts.Ticks % TimeSpan.TicksPerSecond) * 1000.0 / (TimeSpan.TicksPerSecond)); + if (ms != 0) + { + sb.Append(culture.NumberFormat.CurrencyDecimalSeparator); + if (ms % 100 == 0) + sb.AppendFormat("{0:0}", ms / 100); + else if (ms % 10 == 0) + sb.AppendFormat("{0:00}", ms / 10); + else + sb.AppendFormat("{0:000}", ms); + } + return sb.ToString(); + } + } +} diff --git a/ClosedXML/Extensions/TypeExtensions.cs b/ClosedXML/Extensions/TypeExtensions.cs index 3b4433c4c..71e15c443 100644 --- a/ClosedXML/Extensions/TypeExtensions.cs +++ b/ClosedXML/Extensions/TypeExtensions.cs @@ -1,3 +1,5 @@ +#nullable disable + // Keep this file CodeMaid organised and cleaned using System; diff --git a/ClosedXML/Extensions/XDocumentExtensions.cs b/ClosedXML/Extensions/XDocumentExtensions.cs index 36a7a4410..5c1ffe4ac 100644 --- a/ClosedXML/Extensions/XDocumentExtensions.cs +++ b/ClosedXML/Extensions/XDocumentExtensions.cs @@ -7,7 +7,7 @@ namespace ClosedXML.Excel { internal static class XDocumentExtensions { - public static XDocument Load(Stream stream) + public static XDocument? Load(Stream stream) { using (XmlReader reader = XmlReader.Create(stream)) { diff --git a/ClosedXML/Extensions/XLErrorExtensions.cs b/ClosedXML/Extensions/XLErrorExtensions.cs new file mode 100644 index 000000000..f27d3f2a8 --- /dev/null +++ b/ClosedXML/Extensions/XLErrorExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using ClosedXML.Excel; + +namespace ClosedXML.Extensions +{ + internal static class XLErrorExtensions + { + public static string ToDisplayString(this XLError error) => + error switch + { + XLError.CellReference => "#REF!", + XLError.IncompatibleValue => "#VALUE!", + XLError.DivisionByZero => "#DIV/0!", + XLError.NameNotRecognized => "#NAME?", + XLError.NoValueAvailable => "#N/A", + XLError.NullValue => "#NULL!", + XLError.NumberInvalid => "#NUM!", + _ => throw new ArgumentOutOfRangeException() + }; + } + + internal static class XLErrorParser + { + private static readonly Dictionary ErrorMap = new(StringComparer.Ordinal) + { + ["#REF!"] = XLError.CellReference, + ["#VALUE!"] = XLError.IncompatibleValue, + ["#DIV/0!"] = XLError.DivisionByZero, + ["#NAME?"] = XLError.NameNotRecognized, + ["#N/A"] = XLError.NoValueAvailable, + ["#NULL!"] = XLError.NullValue, + ["#NUM!"] = XLError.NumberInvalid + }; + + public static bool TryParseError(String input, out XLError error) + => ErrorMap.TryGetValue(input.Trim(), out error); + } +} diff --git a/ClosedXML/Extensions/XmlWriterExtensions.cs b/ClosedXML/Extensions/XmlWriterExtensions.cs new file mode 100644 index 000000000..b5008c47e --- /dev/null +++ b/ClosedXML/Extensions/XmlWriterExtensions.cs @@ -0,0 +1,147 @@ +using System; +using System.Xml; +using ClosedXML.Excel; +using ClosedXML.Excel.IO; + +namespace ClosedXML.Extensions +{ + internal static class XmlWriterExtensions + { + public static void WriteAttribute(this XmlWriter w, String attrName, String value) + { + w.WriteStartAttribute(attrName); + w.WriteValue(value); + w.WriteEndAttribute(); + } + + public static void WriteAttributeOptional(this XmlWriter w, String attrName, String? value) + { + if (!string.IsNullOrEmpty(value)) + w.WriteAttribute(attrName, value); + } + + public static void WriteAttribute(this XmlWriter w, String attrName, Int32 value) + { + w.WriteStartAttribute(attrName); + w.WriteValue(value); + w.WriteEndAttribute(); + } + + public static void WriteAttribute(this XmlWriter w, String attrName, UInt32 value) + { + w.WriteStartAttribute(attrName); + w.WriteValue(value); + w.WriteEndAttribute(); + } + + public static void WriteAttributeOptional(this XmlWriter w, String attrName, UInt32? value) + { + if (value is not null) + w.WriteAttribute(attrName, value.Value); + } + + public static void WriteAttributeOptional(this XmlWriter w, String attrName, Int32? value) + { + if (value is not null) + w.WriteAttribute(attrName, value.Value); + } + + public static void WriteAttribute(this XmlWriter w, String attrName, Double value) + { + w.WriteStartAttribute(attrName); + w.WriteNumberValue(value); + w.WriteEndAttribute(); + } + + public static void WriteAttribute(this XmlWriter w, String attrName, Boolean value) + { + w.WriteStartAttribute(attrName); + w.WriteValue(value ? "1" : "0"); + w.WriteEndAttribute(); + } + + public static void WriteAttributeDefault(this XmlWriter w, String attrName, Boolean value, Boolean defaultValue) + { + if (value != defaultValue) + w.WriteAttribute(attrName, value); + } + + public static void WriteAttributeOptional(this XmlWriter w, String attrName, Boolean? value) + { + if (value is not null) + w.WriteAttribute(attrName, value.Value); + } + + public static void WriteAttributeDefault(this XmlWriter w, String attrName, int value, int defaultValue) + { + if (value != defaultValue) + w.WriteAttribute(attrName, value); + } + + public static void WriteAttributeDefault(this XmlWriter w, String attrName, uint value, uint defaultValue) + { + if (value != defaultValue) + w.WriteAttribute(attrName, value); + } + + /// + /// Write date in a format 2015-01-01T00:00:00 (ignore kind). + /// + public static void WriteAttribute(this XmlWriter w, String attrName, DateTime value) + { + w.WriteStartAttribute(attrName); + w.WriteValue(value.ToString("s")); + w.WriteEndAttribute(); + } + + public static void WriteAttribute(this XmlWriter w, String attrName, String ns, Double value) + { + w.WriteStartAttribute(attrName, ns); + w.WriteNumberValue(value); + w.WriteEndAttribute(); + } + + public static void WriteNumberValue(this XmlWriter w, Double value) + { + // G17 will survive roundtrip to file and back + w.WriteValue(value.ToInvariantString()); + } + + public static void WritePreserveSpaceAttr(this XmlWriter w) + { + w.WriteAttributeString("xml", "space", OpenXmlConst.Xml1998Ns, "preserve"); + } + + public static void WriteEmptyElement(this XmlWriter w, String elName) + { + w.WriteStartElement(elName, OpenXmlConst.Main2006SsNs); + w.WriteEndElement(); + } + + public static void WriteColor(this XmlWriter w, String elName, XLColor xlColor, Boolean isDifferential = false) + { + w.WriteStartElement(elName, OpenXmlConst.Main2006SsNs); + switch (xlColor.ColorType) + { + case XLColorType.Color: + w.WriteAttributeString("rgb", xlColor.Color.ToHex()); + break; + + case XLColorType.Indexed: + // 64 is 'transparent' and should be ignored for differential formats + if (!isDifferential || xlColor.Indexed != 64) + w.WriteAttribute("indexed", xlColor.Indexed); + break; + + case XLColorType.Theme: + w.WriteAttribute("theme", (int)xlColor.ThemeColor); + + if (xlColor.ThemeTint != 0) + w.WriteAttribute("tint", xlColor.ThemeTint); + break; + } + + w.WriteEndElement(); + } + } +} diff --git a/ClosedXML/Graphics/BmpInfoReader.cs b/ClosedXML/Graphics/BmpInfoReader.cs index 0716680ff..b1ab4e0bd 100644 --- a/ClosedXML/Graphics/BmpInfoReader.cs +++ b/ClosedXML/Graphics/BmpInfoReader.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Drawing; using System.IO; using ClosedXML.Excel.Drawings; diff --git a/ClosedXML/Graphics/DefaultGraphicEngine.cs b/ClosedXML/Graphics/DefaultGraphicEngine.cs index 61204ad4f..6ab668231 100644 --- a/ClosedXML/Graphics/DefaultGraphicEngine.cs +++ b/ClosedXML/Graphics/DefaultGraphicEngine.cs @@ -1,14 +1,25 @@ -using System; +#nullable disable + +using System; using System.Collections.Concurrent; using System.IO; +using System.Reflection; using ClosedXML.Excel; using ClosedXML.Excel.Drawings; using SixLabors.Fonts; +using SixLabors.Fonts.Unicode; namespace ClosedXML.Graphics { public class DefaultGraphicEngine : IXLGraphicEngine { + /// + /// Carlito is a Calibri metric compatible font. This is a version stripped of everything but metric information + /// to keep the embedded file small. It is reasonably accurate for many alphabets (contains 2531 glyphs). It has + /// no glyph outlines, no TTF instructions, no substitutions, glyph positioning ect. It is created from Carlito + /// font through strip-fonts.sh script. + /// + private const string EmbeddedFontName = "CarlitoBare"; private const float FontMetricSize = 16f; private readonly ImageInfoReader[] _imageReaders = { @@ -19,9 +30,11 @@ public class DefaultGraphicEngine : IXLGraphicEngine new BmpInfoReader(), new EmfInfoReader(), new WmfInfoReader(), + new WebpInfoReader(), new PcxInfoReader() // Due to poor magic detection, keep last }; + private readonly Lazy _fontCollection; private readonly string _fallbackFont; /// @@ -39,7 +52,7 @@ public class DefaultGraphicEngine : IXLGraphicEngine /// /// Get a singleton instance of the engine that uses Microsoft Sans Serif as a fallback font. /// - public static Lazy Instance { get; } = new(() => new DefaultGraphicEngine("Microsoft Sans Serif")); + public static Lazy Instance { get; } = new(() => new("Microsoft Sans Serif")); /// /// Initialize a new instance of the engine. @@ -50,11 +63,82 @@ public DefaultGraphicEngine(string fallbackFont) if (string.IsNullOrWhiteSpace(fallbackFont)) throw new ArgumentException(nameof(fallbackFont)); + var fontCollection = new FontCollection(); + AddEmbeddedFont(fontCollection); + + _fontCollection = new Lazy(() => fontCollection.AddSystemFonts()); _fallbackFont = fallbackFont; _loadFont = LoadFont; _calculateMaxDigitWidth = CalculateMaxDigitWidth; } + /// + /// Initialize a new instance of the engine. The engine will be able to use system fonts and fonts loaded from external sources. + /// + /// Useful/necessary for environments without an access to filesystem. + /// A stream that contains a fallback font. + /// Should engine try to use system fonts? If false, system fonts won't be loaded which can significantly speed up library startup. + /// Extra fonts that should be loaded to the engine. + private DefaultGraphicEngine(Stream fallbackFontStream, bool useSystemFonts, Stream[] fontStreams) + { + if (fallbackFontStream is null) + throw new ArgumentNullException(nameof(fallbackFontStream)); + + if (fontStreams is null) + throw new ArgumentNullException(nameof(fontStreams)); + + var fontCollection = new FontCollection(); + AddEmbeddedFont(fontCollection); + var fallbackFamily = fontCollection.Add(fallbackFontStream); + foreach (var fontStream in fontStreams) + fontCollection.Add(fontStream); + + _fontCollection = useSystemFonts + ? new Lazy(() => fontCollection.AddSystemFonts()) + : new Lazy(() => fontCollection); + _fallbackFont = fallbackFamily.Name; + _loadFont = LoadFont; + _calculateMaxDigitWidth = CalculateMaxDigitWidth; + } + + /// + /// Create a default graphic engine that uses only fallback font and additional fonts passed as streams. + /// It ignores all system fonts and that can lead to decrease of initialization time. + /// + /// + /// + /// Font is determined by a name and style in the worksheet, but the font name must be mapped to a font file/stream. + /// System fonts on Windows contain hundreds of font files that have to be checked to find the correct font + /// file for the font name and style. That means to read hundreds of files and parse data inside them. + /// Even though SixLabors.Fonts does this only once (lazily too) and stores data in a static variable, it is + /// an overhead that can be avoided. + /// + /// + /// This factory method is useful in several scenarios: + /// + /// Client side Blazor doesn't have access to any system fonts. + /// Worksheet contains only limited number of fonts. It might be sufficient to just load few fonts we are + /// + /// + /// + /// A stream that contains a fallback font. + /// Fonts that should be loaded to the engine. + public static IXLGraphicEngine CreateOnlyWithFonts(Stream fallbackFontStream, params Stream[] fontStreams) + { + return new DefaultGraphicEngine(fallbackFontStream, false, fontStreams); + } + + /// + /// Create a default graphic engine that uses only fallback font and additional fonts passed as streams. + /// It also uses system fonts. + /// + /// A stream that contains a fallback font. + /// Fonts that should be loaded to the engine. + public static IXLGraphicEngine CreateWithFontsAndSystemFonts(Stream fallbackFontStream, params Stream[] fontStreams) + { + return new DefaultGraphicEngine(fallbackFontStream, true, fontStreams); + } + public XLPictureInfo GetPictureInfo(Stream stream, XLPictureFormat expectedFormat) { foreach (var imageReader in _imageReaders) @@ -69,7 +153,12 @@ public XLPictureInfo GetPictureInfo(Stream stream, XLPictureFormat expectedForma public double GetDescent(IXLFontBase font, double dpiY) { var metrics = GetMetrics(font); - return PointsToPixels(-metrics.Descender * font.FontSize / metrics.UnitsPerEm, dpiY); + return GetDescent(font, dpiY, metrics); + } + + private double GetDescent(IXLFontBase font, double dpiY, FontMetrics metrics) + { + return PointsToPixels(-metrics.VerticalMetrics.Descender * font.FontSize / metrics.UnitsPerEm, dpiY); } public double GetMaxDigitWidth(IXLFontBase fontBase, double dpiX) @@ -82,13 +171,13 @@ public double GetMaxDigitWidth(IXLFontBase fontBase, double dpiX) public double GetTextHeight(IXLFontBase font, double dpiY) { var metrics = GetMetrics(font); - return PointsToPixels((metrics.Ascender - 2 * metrics.Descender) * font.FontSize / metrics.UnitsPerEm, dpiY); + return PointsToPixels((metrics.VerticalMetrics.Ascender - 2 * metrics.VerticalMetrics.Descender) * font.FontSize / metrics.UnitsPerEm, dpiY); } public double GetTextWidth(string text, IXLFontBase fontBase, double dpiX) { var font = GetFont(fontBase); - var dimensionsPx = TextMeasurer.Measure(text, new TextOptions(font) + var dimensionsPx = TextMeasurer.MeasureAdvance(text, new TextOptions(font) { Dpi = 72, // Normalize DPI, so 1px is 1pt KerningMode = KerningMode.None @@ -96,6 +185,41 @@ public double GetTextWidth(string text, IXLFontBase fontBase, double dpiX) return PointsToPixels(dimensionsPx.Width / FontMetricSize * fontBase.FontSize, dpiX); } + /// + public GlyphBox GetGlyphBox(ReadOnlySpan graphemeCluster, IXLFontBase font, Dpi dpi) + { + // SixLabors.Fonts don't have a way to get a glyph representation of a cluster + // without a TextRenderer that has unacceptable performance. + var metric = GetMetrics(font); + var advanceFu = 0; + for (var i = 0; i < graphemeCluster.Length; ++i) + { + var containsMetrics = metric.TryGetGlyphMetrics( + new CodePoint(graphemeCluster[i]), + TextAttributes.None, + TextDecorations.None, + LayoutMode.HorizontalTopBottom, + ColorFontSupport.None, + out var glyphs); + + // As of SixLabors.Fonts 1.0.0, the TryGetGlyphMetrics method never fails. It returns .notdef glyph 0 + // as a fallback glyph, but it might change in the future. + if (!containsMetrics) + continue; + + foreach (var glyph in glyphs) + advanceFu += glyph.AdvanceWidth; + } + + var emInPx = font.FontSize / 72d * dpi.X; + var advancePx = PointsToPixels(advanceFu * font.FontSize / metric.UnitsPerEm, dpi.X); + var descentPx = GetDescent(font, dpi.Y, metric); + return new GlyphBox( + (float)Math.Round(advancePx, MidpointRounding.AwayFromZero), + (float)Math.Round(emInPx, MidpointRounding.AwayFromZero), + (float)Math.Round(descentPx, MidpointRounding.AwayFromZero)); + } + private FontMetrics GetMetrics(IXLFontBase fontBase) { var font = GetFont(fontBase); @@ -114,15 +238,35 @@ private Font GetFont(MetricId metricId) private Font LoadFont(MetricId metricId) { - if (!SystemFonts.TryGet(metricId.Name, out var fontFamily) && - !SystemFonts.TryGet(_fallbackFont, out fontFamily)) - throw new ArgumentException($"Unable to find font {metricId.Name} or fallback font {_fallbackFont}. " + - "Install missing fonts or specify a different fallback font through " + - "'LoadOptions.DefaultGraphicEngine = new DefaultGraphicEngine(\"Fallback font name\")'."); + // First try the specified fallback font. On windows, unknown fonts should use MS Sans Serif + if (!_fontCollection.Value.TryGet(metricId.Name, out var fontFamily) && + !_fontCollection.Value.TryGet(_fallbackFont, out fontFamily)) + { + // If not present, e.g. it's unlikely to be present on Linux, use embedded font as an ultimate fallback. + fontFamily = _fontCollection.Value.Get(EmbeddedFontName); + } return fontFamily.CreateFont(FontMetricSize); // Size is irrelevant for metric } + private void AddEmbeddedFont(FontCollection fontCollection) + { + var assembly = Assembly.GetExecutingAssembly(); + const string resourcePath = "ClosedXML.Graphics.Fonts.CarlitoBare-{0}.ttf"; + + using var regular = assembly.GetManifestResourceStream(string.Format(resourcePath, "Regular"))!; + fontCollection.Add(regular); + + using var bold = assembly.GetManifestResourceStream(string.Format(resourcePath, "Bold"))!; + fontCollection.Add(bold); + + using var italic = assembly.GetManifestResourceStream(string.Format(resourcePath, "Italic"))!; + fontCollection.Add(italic); + + using var boldItalic = assembly.GetManifestResourceStream(string.Format(resourcePath, "BoldItalic"))!; + fontCollection.Add(boldItalic); + } + private double CalculateMaxDigitWidth(MetricId metricId) { var font = GetFont(metricId); @@ -130,7 +274,16 @@ private double CalculateMaxDigitWidth(MetricId metricId) var maxWidth = int.MinValue; for (var c = '0'; c <= '9'; ++c) { - var glyphMetrics = metrics.GetGlyphMetrics(new SixLabors.Fonts.Unicode.CodePoint(c), ColorFontSupport.None); + var containsMetrics = metrics.TryGetGlyphMetrics( + new CodePoint(c), + TextAttributes.None, + TextDecorations.None, + LayoutMode.HorizontalTopBottom, + ColorFontSupport.None, + out var glyphMetrics); + if (!containsMetrics) + continue; + var glyphAdvance = 0; foreach (var glyphMetric in glyphMetrics) glyphAdvance += glyphMetric.AdvanceWidth; diff --git a/ClosedXML/Graphics/Dpi.cs b/ClosedXML/Graphics/Dpi.cs new file mode 100644 index 000000000..160735dc2 --- /dev/null +++ b/ClosedXML/Graphics/Dpi.cs @@ -0,0 +1,26 @@ +#nullable disable + +namespace ClosedXML.Graphics +{ + /// + /// A DPI resolution. + /// + public readonly struct Dpi + { + /// + /// Horizontal DPI resolution. + /// + public double X { get; } + + /// + /// Vertical DPI resolution. + /// + public double Y { get; } + + public Dpi(double dpiX, double dpiY) + { + X = dpiX; + Y = dpiY; + } + } +} diff --git a/ClosedXML/Graphics/EmfInfoReader.cs b/ClosedXML/Graphics/EmfInfoReader.cs index 303fe3949..39ce42a3a 100644 --- a/ClosedXML/Graphics/EmfInfoReader.cs +++ b/ClosedXML/Graphics/EmfInfoReader.cs @@ -1,4 +1,6 @@ -using System.Drawing; +#nullable disable + +using System.Drawing; using System.IO; using ClosedXML.Excel.Drawings; using ClosedXML.Utils; diff --git a/ClosedXML/Graphics/Fonts/CarlitoBare-Bold.ttf b/ClosedXML/Graphics/Fonts/CarlitoBare-Bold.ttf new file mode 100644 index 000000000..280771dcd Binary files /dev/null and b/ClosedXML/Graphics/Fonts/CarlitoBare-Bold.ttf differ diff --git a/ClosedXML/Graphics/Fonts/CarlitoBare-BoldItalic.ttf b/ClosedXML/Graphics/Fonts/CarlitoBare-BoldItalic.ttf new file mode 100644 index 000000000..014dcee84 Binary files /dev/null and b/ClosedXML/Graphics/Fonts/CarlitoBare-BoldItalic.ttf differ diff --git a/ClosedXML/Graphics/Fonts/CarlitoBare-Italic.ttf b/ClosedXML/Graphics/Fonts/CarlitoBare-Italic.ttf new file mode 100644 index 000000000..f2ccc7889 Binary files /dev/null and b/ClosedXML/Graphics/Fonts/CarlitoBare-Italic.ttf differ diff --git a/ClosedXML/Graphics/Fonts/CarlitoBare-Regular.ttf b/ClosedXML/Graphics/Fonts/CarlitoBare-Regular.ttf new file mode 100644 index 000000000..387d2f5c4 Binary files /dev/null and b/ClosedXML/Graphics/Fonts/CarlitoBare-Regular.ttf differ diff --git a/ClosedXML/Graphics/GifInfoReader.cs b/ClosedXML/Graphics/GifInfoReader.cs index c3d8e85fa..7e0b88277 100644 --- a/ClosedXML/Graphics/GifInfoReader.cs +++ b/ClosedXML/Graphics/GifInfoReader.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Drawing; using System.IO; using ClosedXML.Excel.Drawings; diff --git a/ClosedXML/Graphics/GlyphBox.cs b/ClosedXML/Graphics/GlyphBox.cs new file mode 100644 index 000000000..d1ddf3c02 --- /dev/null +++ b/ClosedXML/Graphics/GlyphBox.cs @@ -0,0 +1,61 @@ +#nullable disable + +using System.Diagnostics; + +namespace ClosedXML.Graphics +{ + /// + /// A bounding box for a glyph, in pixels. + /// + /// + /// In most cases, a glyph represents a single unicode code point. In some cases, + /// multiple code points are represented by a single glyph (emojis in most cases). + /// Although data type is float, the actual values should be whole numbers. + /// That best fits to Excel behavior, but there might be some cases in the future, + /// where values can be a floats (e.g. advance could be non-pixels aligned). + /// + [DebuggerDisplay("{AdvanceWidth}x{LineHeight}")] + public readonly struct GlyphBox + { + /// + /// A special glyph box that indicates a line break. Dimensions are kept at 0, so it doesn't affect any calculations. + /// + internal static GlyphBox LineBreak => default; + + public GlyphBox(float advanceWidth, float emSize, float descent) + { + AdvanceWidth = advanceWidth; + EmSize = emSize; + Descent = descent; + } + + /// + /// Advance width in px of a box for code point. Value should be whole number. + /// + public float AdvanceWidth { get; } + + /// + /// Size of Em square in pixels. If em is not a square, vertical dimension of + /// em square. Value should be whole number. + /// + public float EmSize { get; } + + /// + /// Distance in px from baseline to the bottom of the box. + /// + /// + /// Descent/height is determined by font, not by codepoints + /// of the glyph. Value should be whole number. + /// + public float Descent { get; } + + internal bool IsLineBreak => AdvanceWidth == 0 && EmSize == 0 && Descent == 0; + + /// + /// Get line width of the glyph box. It is calculated as central band with a text and + /// a lower and an upper bands. Central band (text) has height is em-square - descent + /// and the bands are descent. + /// + internal float LineHeight => EmSize + Descent; + } +} diff --git a/ClosedXML/Graphics/IXLGraphicEngine.cs b/ClosedXML/Graphics/IXLGraphicEngine.cs index 02da18a0e..c250ae017 100644 --- a/ClosedXML/Graphics/IXLGraphicEngine.cs +++ b/ClosedXML/Graphics/IXLGraphicEngine.cs @@ -1,4 +1,7 @@ -using System; +#nullable disable + +using System; +using System.Globalization; using System.IO; using ClosedXML.Excel; using ClosedXML.Excel.Drawings; @@ -40,5 +43,26 @@ public interface IXLGraphicEngine /// /// Excel is using OS/2 WinAscent/WinDescent for TrueType fonts (e.g. Calibri), not a correct font ascent/descent. double GetDescent(IXLFontBase font, double dpiY); + + /// + /// Get a glyph bounding box for a grapheme cluster. + /// + /// + /// In 99+%, grapheme cluster will be just a codepoint. Method uses grapheme instead, so it can be + /// future-proof signature and have less braking changes. Implementing method by adding widths of + /// individual code points is acceptable. + /// + /// + /// A part of a string in code points (or runes in C# terminology, not UTF-16 code units) that together + /// form a grapheme. Multiple unicode codepoints can form a single glyph, e.g. family grapheme is a single + /// glyph created from 6 codepoints (man, zero-width-join, woman, zero-width-join and a girl). A string + /// can be split into a grapheme clusters through . + /// + /// Font used to determine size of a glyph for the grapheme cluster. + /// + /// A resolution used to determine pixel size of a glyph. Font might be rendered differently at different resolutions. + /// + /// Bounding box containing the glyph. + GlyphBox GetGlyphBox(ReadOnlySpan graphemeCluster, IXLFontBase font, Dpi dpi); } } diff --git a/ClosedXML/Graphics/ImageInfoReader.cs b/ClosedXML/Graphics/ImageInfoReader.cs index 3ffbab06b..19db5871a 100644 --- a/ClosedXML/Graphics/ImageInfoReader.cs +++ b/ClosedXML/Graphics/ImageInfoReader.cs @@ -1,4 +1,6 @@ -using System.IO; +#nullable disable + +using System.IO; namespace ClosedXML.Graphics { diff --git a/ClosedXML/Graphics/JpegInfoReader.cs b/ClosedXML/Graphics/JpegInfoReader.cs index b46eb3cac..6de2f5396 100644 --- a/ClosedXML/Graphics/JpegInfoReader.cs +++ b/ClosedXML/Graphics/JpegInfoReader.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Drawing; using System.IO; using System.Linq; @@ -15,6 +17,7 @@ internal class JpegInfoReader : ImageInfoReader { private static readonly byte[] APP0Identifer = Encoding.ASCII.GetBytes("JFIF\0"); private static readonly byte[] APP1Identifer = Encoding.ASCII.GetBytes("Exif\0\0"); + private static readonly byte[] APP14Identifer = Encoding.ASCII.GetBytes("Adobe\0"); protected override bool CheckHeader(Stream stream) { @@ -30,6 +33,8 @@ protected override bool CheckHeader(Stream stream) return IsIdentifier(stream, APP0Identifer); case Marker.APP1: return IsIdentifier(stream, APP1Identifer); + case Marker.APP14: + return IsIdentifier(stream, APP14Identifer); default: stream.Position += length; break; @@ -57,6 +62,7 @@ protected override XLPictureInfo ReadInfo(Stream stream) double xDpi = 0, yDpi = 0; while (TryGetMarker(stream, out var marker) && TryGetLength(stream, out var length)) { + var segmentStart = stream.Position; if (marker == Marker.APP0) { const int versionLength = 2; @@ -68,10 +74,6 @@ protected override XLPictureInfo ReadInfo(Stream stream) xDpi = ConvertToDpi(xDensity, units); yDpi = ConvertToDpi(yDensity, units); - - var xThumbnail = stream.ReadU8(); - var yThumbnail = stream.ReadU8(); - stream.Position += 3 * xThumbnail * yThumbnail; } else if (Marker.SOFx.Contains(marker)) { @@ -83,10 +85,8 @@ protected override XLPictureInfo ReadInfo(Stream stream) // End here, before we get to SOS segment that doesn't contain explicit segment length return new XLPictureInfo(XLPictureFormat.Jpeg, new Size(width, height), Size.Empty, xDpi, yDpi); } - else - { - stream.Position += length; - } + + stream.Position = segmentStart + length; } throw new ArgumentException("SOF not found in the JFIF."); @@ -126,6 +126,7 @@ private static class Marker public const ushort SOI = 0xFFD8; public const ushort APP0 = 0xFFE0; public const ushort APP1 = 0xFFE1; + public const ushort APP14 = 0xFFEE; public static readonly ushort[] SOFx = new ushort[] { 0xFFC0, 0xFFC1, 0xFFC2, 0xFFC3, 0xFFC5, 0xFFC6, 0xFFC7, 0xFFC9, 0xFFCA, 0xFFCB, 0xFFCD, 0xFFCE, 0xFFCF }; } diff --git a/ClosedXML/Graphics/PcxInfoReader.cs b/ClosedXML/Graphics/PcxInfoReader.cs index 1d5720897..72def42a6 100644 --- a/ClosedXML/Graphics/PcxInfoReader.cs +++ b/ClosedXML/Graphics/PcxInfoReader.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Drawing; using System.IO; using ClosedXML.Excel.Drawings; diff --git a/ClosedXML/Graphics/PngInfoReader.cs b/ClosedXML/Graphics/PngInfoReader.cs index b4c9dbc06..88a95f3fb 100644 --- a/ClosedXML/Graphics/PngInfoReader.cs +++ b/ClosedXML/Graphics/PngInfoReader.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.IO; using ClosedXML.Excel.Drawings; using ClosedXML.Utils; diff --git a/ClosedXML/Graphics/TiffInfoReader.cs b/ClosedXML/Graphics/TiffInfoReader.cs index 613ed00ee..78b5183fb 100644 --- a/ClosedXML/Graphics/TiffInfoReader.cs +++ b/ClosedXML/Graphics/TiffInfoReader.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.Drawings; +#nullable disable + +using ClosedXML.Excel.Drawings; using ClosedXML.Utils; using System; using System.IO; diff --git a/ClosedXML/Graphics/WebpInfoReader.cs b/ClosedXML/Graphics/WebpInfoReader.cs new file mode 100644 index 000000000..2a171bb3e --- /dev/null +++ b/ClosedXML/Graphics/WebpInfoReader.cs @@ -0,0 +1,136 @@ +#nullable disable + +using ClosedXML.Utils; +using System; +using System.Drawing; +using System.IO; +using ClosedXML.Excel; +using ClosedXML.Excel.Drawings; + +namespace ClosedXML.Graphics +{ + /// + /// Reader of dimensions for WebP image format. + /// + internal class WebpInfoReader : ImageInfoReader + { + private const int Vp8ChunkMagicBytes = 0x9d012a; + private const int Vp8LChunkMagicByte = 0x2F; + + private static readonly UInt32 LossyVp8Code = "VP8 ".ToMagicNumber(); + private static readonly UInt32 LosslessVp8Code = "VP8L".ToMagicNumber(); + private static readonly UInt32 ExtendedV8Code = "VP8X".ToMagicNumber(); + + protected override bool CheckHeader(Stream stream) + { + Span header = stackalloc byte[12]; + if (stream.Read(header) != header.Length) + { + return false; + } + + return header[0] == 'R' && + header[1] == 'I' && + header[2] == 'F' && + header[3] == 'F' && + header[8] == 'W' && + header[9] == 'E' && + header[10] == 'B' && + header[11] == 'P'; + } + + protected override XLPictureInfo ReadInfo(Stream stream) + { + // Skip header and file size + stream.Position += 12; + + var chunkCode = stream.ReadU32BE(); + + // Skip chunk size + stream.Position += 4; + if (chunkCode == ExtendedV8Code) + { + // https://developers.google.com/speed/webp/docs/riff_container#extended_file_format + // Skip image features + stream.Position += 4; + var width = stream.ReadU24LE() + 1; + var height = stream.ReadU24LE() + 1; + + // There is a potential EXIF/XMP chunk in extended format, but use default DPI to keep it simple. + return new XLPictureInfo(XLPictureFormat.Webp, new Size(width, height), Size.Empty, 72, 72); + } + + if (chunkCode == LossyVp8Code) + { + // https://datatracker.ietf.org/doc/html/rfc6386#section-9.1 + // First 3 bytes are a frame tag. It's read as a big endian for easier processing + var frameTag = stream.ReadU24LE(); + var isKeyFrame = (frameTag & 1) == 0; + if (!isKeyFrame) + { + throw new ArgumentException("Image is not a key frame."); + } + + var showFrameFlag = ((frameTag >> 4) & 0x1) == 1; + if (!showFrameFlag) + { + throw new ArgumentException("Frame is not visible."); + } + + // Next 3 bytes are magic bytes + var magicBytes = stream.ReadU24BE(); + if (magicBytes != Vp8ChunkMagicBytes) + { + throw new ArgumentException("Invalid magic bytes for VP8 lossy chunk."); + } + + // Scaling is used only for rendering, underlaying data are unscaled + var widthAndScale = stream.ReadU16LE(); + var width = GetSize(widthAndScale); + + var heightAndScale = stream.ReadU16LE(); + var height = GetSize(heightAndScale); + + return new XLPictureInfo(XLPictureFormat.Webp, new Size(width, height), Size.Empty, 72, 72); + + static int GetSize(ushort sizeAndScale) + { + var size = sizeAndScale & 0x3FFF; + var scale = sizeAndScale >> 14; + return scale switch + { + 0 => size, + 1 => size * 5 / 4, + 2 => size * 5 / 3, + _ => size * 2 + }; + } + } + + if (chunkCode == LosslessVp8Code) + { + // https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification + var magic = stream.ReadByte(); + if (magic != Vp8LChunkMagicByte) + { + throw new ArgumentException("Invalid magic for VP8L chunk."); + } + + Span header = stackalloc byte[4]; + var readBytes = stream.Read(header); + if (readBytes != 4) + { + throw new ArgumentException("Unexpected end of file."); + } + + // Width is 14 bits and height is 14 bit, packed into 4 bytes + var width = header[0] + ((header[1] & 0x3F) << 8) + 1; + var height = ((header[1] & 0xC0) >> 6) + (header[2] << 2) + ((header[3] & 0xF) << 10) + 1; + + return new XLPictureInfo(XLPictureFormat.Webp, new Size(width, height), Size.Empty, 72, 72); + } + + throw new ArgumentException("Invalid chunk for WebP file."); + } + } +} diff --git a/ClosedXML/Graphics/WmfInfoReader.cs b/ClosedXML/Graphics/WmfInfoReader.cs index 8ba801748..f520e69e0 100644 --- a/ClosedXML/Graphics/WmfInfoReader.cs +++ b/ClosedXML/Graphics/WmfInfoReader.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.Drawings; +#nullable disable + +using ClosedXML.Excel.Drawings; using ClosedXML.Utils; using System; using System.Drawing; diff --git a/ClosedXML/Graphics/XLPictureInfo.cs b/ClosedXML/Graphics/XLPictureInfo.cs index 1957aa8bf..aa147175d 100644 --- a/ClosedXML/Graphics/XLPictureInfo.cs +++ b/ClosedXML/Graphics/XLPictureInfo.cs @@ -1,4 +1,6 @@ -using ClosedXML.Excel.Drawings; +#nullable disable + +using ClosedXML.Excel.Drawings; using System; using System.Drawing; @@ -40,6 +42,7 @@ public XLPictureInfo(XLPictureFormat format, uint width, uint height, double dpi throw new ArgumentException("Size of picture too large."); Format = format; SizePx = new Size((int)width, (int)height); + SizePhys = Size.Empty; DpiX = dpiX; DpiY = dpiY; } diff --git a/ClosedXML/Properties/AssemblyInfo.cs b/ClosedXML/Properties/AssemblyInfo.cs index 18efcaad0..95866871d 100644 --- a/ClosedXML/Properties/AssemblyInfo.cs +++ b/ClosedXML/Properties/AssemblyInfo.cs @@ -1 +1 @@ -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ClosedXML.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100a1fb8ba59167fe734d64128ca73d32c45cb8a117246d09c95c8769db88fe332b0a3396bedd0ea48ee42b0e5796fec0798ca5cb628a9a6de80d35d6c67b936ca1670347b3d4f2b769c8ce2ddcf959dbac6bcd88e6c08751ea1fffa0522de3507193e7035305a8aa008d6c88cca1341b3120fa9c347ab3f97e2d772e2709277da5")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ClosedXML.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100a1fb8ba59167fe734d64128ca73d32c45cb8a117246d09c95c8769db88fe332b0a3396bedd0ea48ee42b0e5796fec0798ca5cb628a9a6de80d35d6c67b936ca1670347b3d4f2b769c8ce2ddcf959dbac6bcd88e6c08751ea1fffa0522de3507193e7035305a8aa008d6c88cca1341b3120fa9c347ab3f97e2d772e2709277da5")] diff --git a/ClosedXML/Utils/ColorStringParser.cs b/ClosedXML/Utils/ColorStringParser.cs index a5c601025..18ad6a65a 100644 --- a/ClosedXML/Utils/ColorStringParser.cs +++ b/ClosedXML/Utils/ColorStringParser.cs @@ -1,38 +1,41 @@ -using System; -using System.Drawing; -using System.Globalization; +using System; +using Color = System.Drawing.Color; namespace ClosedXML.Utils { internal static class ColorStringParser { - public static Color ParseFromArgb(string argbColor) + internal static Color ParseFromHtml(string argbColor) { - if (argbColor[0] == '#') - argbColor = argbColor.Substring(1); + // Half working incorrect parser: + // * accepts #aarrggbb, but HTML would expect #rrggbbaa + // * doesn't accept color names + ReadOnlySpan argb = argbColor.AsSpan(); + if (argb[0] == '#') + argb = argb.Slice(1); - if (argbColor.Length == 8) + if (argb.Length == 8) { return Color.FromArgb( - ReadHex(argbColor, 0, 2), - ReadHex(argbColor, 2, 2), - ReadHex(argbColor, 4, 2), - ReadHex(argbColor, 6, 2)); + ReadHex(argb, 0, 2), + ReadHex(argb, 2, 2), + ReadHex(argb, 4, 2), + ReadHex(argb, 6, 2)); } - if (argbColor.Length == 6) + if (argb.Length == 6) { return Color.FromArgb( - ReadHex(argbColor, 0, 2), - ReadHex(argbColor, 2, 2), - ReadHex(argbColor, 4, 2)); + ReadHex(argb, 0, 2), + ReadHex(argb, 2, 2), + ReadHex(argb, 4, 2)); } - if (argbColor.Length == 3) + if (argb.Length == 3) { - var r = ReadHex(argbColor, 0, 1); - var g = ReadHex(argbColor, 1, 1); - var b = ReadHex(argbColor, 2, 1); + var r = ReadHex(argb, 0, 1); + var g = ReadHex(argb, 1, 1); + var b = ReadHex(argb, 2, 1); return Color.FromArgb( (r << 4) | r, (g << 4) | g, @@ -42,9 +45,109 @@ public static Color ParseFromArgb(string argbColor) throw new FormatException($"Unable to parse color {argbColor}."); } - private static int ReadHex(string text, int start, int length) + /// + /// Parse ARGB color stored in ST_UnsignedIntHex the same way as Excel does. + /// + internal static Color ParseFromArgb(ReadOnlySpan argb) { - return Int32.Parse(text.Substring(start, length), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + // This algorithm mimics how Excel parses ST_UnsignedIntHex to color. + // ST_UnsignedIntHex should be exactly 8 digits and Excel uses black for longer texts. + if (argb.Length > 8) + return Color.Black; + + // Excel tries to parse hex numbers as long as possible and shifts them, + // e.g. 'ABC+' is turned into 'FF000ABC'. Signed shift keeps highest bit, + // so keep color in uint. + uint color = 0x00000000; + var index = 0; + while (index < argb.Length && TryGetHex(argb[index], out var hexDigit)) + { + color = (color << 4) | hexDigit; + index++; + } + + // Although Excel always uses FF for alpha, keep alpha for valid AARRGGBB. + var isValidArgb = index == 8; + if (!isValidArgb) + color |= 0xFF000000; + + return Color.FromArgb(unchecked((int)color)); + } + + /// + /// Parse RRGGBB color. + /// + internal static Color ParseFromRgb6(string rgbColor) + { + if (!TryParseRgb6(rgbColor.AsSpan(), out var color)) + throw new FormatException($"Unable to parse {rgbColor}."); + + return color; + } + + internal static bool TryParseRgb6(ReadOnlySpan rgbColor, out Color color) + { + if (rgbColor.Length != 6) + { + color = default; + return false; + } + + if (!TryReadHex(rgbColor, 0, 2, out var red) || + !TryReadHex(rgbColor, 2, 2, out var green) || + !TryReadHex(rgbColor, 4, 2, out var blue)) + { + color = default; + return false; + } + + color = Color.FromArgb(red, green, blue); + return true; + } + + private static int ReadHex(ReadOnlySpan text, int start, int length) + { + if (!TryReadHex(text, start, length, out var hexValue)) + throw new FormatException($"Unable to parse {text.ToString()}."); + + return hexValue; + } + + private static bool TryReadHex(ReadOnlySpan text, int start, int length, out int result) + { + var value = 0; + for (var i = start; i < start + length; ++i) + { + if (!TryGetHex(text[i], out var hexDigit)) + { + result = 0; + return false; + } + + value = value * 16 + (int)hexDigit; + } + + result = value; + return true; + } + + private static bool TryGetHex(char c, out uint hexDigit) + { + switch (c) + { + case >= '0' and <= '9': + hexDigit = c - (uint)'0'; + return true; + case >= 'A' and <= 'F': + hexDigit = c - (uint)'A' + 10; + return true; + case >= 'a' and <= 'f': + hexDigit = c - (uint)'a' + 10; + return true; + default: + hexDigit = 0; + return false; + } } } } diff --git a/ClosedXML/Utils/CryptographicAlgorithms.cs b/ClosedXML/Utils/CryptographicAlgorithms.cs index 472d5de40..c8db9d140 100644 --- a/ClosedXML/Utils/CryptographicAlgorithms.cs +++ b/ClosedXML/Utils/CryptographicAlgorithms.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using System.Linq; using System.Security.Cryptography; @@ -9,18 +11,6 @@ namespace ClosedXML.Utils { internal static class CryptographicAlgorithms { - public static string Base64Decode(string base64EncodedData) - { - var base64EncodedBytes = Convert.FromBase64String(base64EncodedData); - return Encoding.UTF8.GetString(base64EncodedBytes); - } - - public static string Base64Encode(string plainText) - { - var plainTextBytes = Encoding.UTF8.GetBytes(plainText); - return Convert.ToBase64String(plainTextBytes); - } - public static String GenerateNewSalt(Algorithm algorithm) { if (RequiresSalt(algorithm)) @@ -37,7 +27,7 @@ public static String GetPasswordHash(Algorithm algorithm, String password, Strin if (salt == null) throw new ArgumentNullException(nameof(salt)); - if ("" == password) return ""; + if (password.Length == 0) return ""; switch (algorithm) { diff --git a/ClosedXML/Utils/DescribedEnumParser.cs b/ClosedXML/Utils/DescribedEnumParser.cs index 838a76844..49921f86d 100644 --- a/ClosedXML/Utils/DescribedEnumParser.cs +++ b/ClosedXML/Utils/DescribedEnumParser.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned using System; using System.Collections.Generic; using System.ComponentModel; diff --git a/ClosedXML/Utils/OpenXmlColorTypeAdapters.cs b/ClosedXML/Utils/OpenXmlColorTypeAdapters.cs index 3d22df4c1..6bac4e0c9 100644 --- a/ClosedXML/Utils/OpenXmlColorTypeAdapters.cs +++ b/ClosedXML/Utils/OpenXmlColorTypeAdapters.cs @@ -1,4 +1,4 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Spreadsheet; using X14 = DocumentFormat.OpenXml.Office2010.Excel; @@ -7,11 +7,11 @@ namespace ClosedXML.Utils { internal interface IColorTypeAdapter { - public BooleanValue Auto { get; set; } - public UInt32Value Indexed { get; set; } - public HexBinaryValue Rgb { get; set; } - public UInt32Value Theme { get; set; } - public DoubleValue Tint { get; set; } + public BooleanValue? Auto { get; set; } + public UInt32Value? Indexed { get; set; } + public HexBinaryValue? Rgb { get; set; } + public UInt32Value? Theme { get; set; } + public DoubleValue? Tint { get; set; } } internal class ColorTypeAdapter : IColorTypeAdapter @@ -27,11 +27,11 @@ public ColorTypeAdapter(ColorType colorType) #endregion ColorType - public BooleanValue Auto { get => ColorType.Auto; set => ColorType.Auto = value; } - public UInt32Value Indexed { get => ColorType.Indexed; set => ColorType.Indexed = value; } - public HexBinaryValue Rgb { get => ColorType.Rgb; set => ColorType.Rgb = value; } - public UInt32Value Theme { get => ColorType.Theme; set => ColorType.Theme = value; } - public DoubleValue Tint { get => ColorType.Tint; set => ColorType.Tint = value; } + public BooleanValue? Auto { get => ColorType.Auto; set => ColorType.Auto = value; } + public UInt32Value? Indexed { get => ColorType.Indexed; set => ColorType.Indexed = value; } + public HexBinaryValue? Rgb { get => ColorType.Rgb; set => ColorType.Rgb = value; } + public UInt32Value? Theme { get => ColorType.Theme; set => ColorType.Theme = value; } + public DoubleValue? Tint { get => ColorType.Tint; set => ColorType.Tint = value; } } internal class X14ColorTypeAdapter : IColorTypeAdapter @@ -47,10 +47,10 @@ public X14ColorTypeAdapter(X14.ColorType colorType) #endregion ColorType - public BooleanValue Auto { get => ColorType.Auto; set => ColorType.Auto = value; } - public UInt32Value Indexed { get => ColorType.Indexed; set => ColorType.Indexed = value; } - public HexBinaryValue Rgb { get => ColorType.Rgb; set => ColorType.Rgb = value; } - public UInt32Value Theme { get => ColorType.Theme; set => ColorType.Theme = value; } - public DoubleValue Tint { get => ColorType.Tint; set => ColorType.Tint = value; } + public BooleanValue? Auto { get => ColorType.Auto; set => ColorType.Auto = value; } + public UInt32Value? Indexed { get => ColorType.Indexed; set => ColorType.Indexed = value; } + public HexBinaryValue? Rgb { get => ColorType.Rgb; set => ColorType.Rgb = value; } + public UInt32Value? Theme { get => ColorType.Theme; set => ColorType.Theme = value; } + public DoubleValue? Tint { get => ColorType.Tint; set => ColorType.Tint = value; } } } diff --git a/ClosedXML/Utils/OpenXmlHelper.cs b/ClosedXML/Utils/OpenXmlHelper.cs index 3b75196e7..7062f038b 100644 --- a/ClosedXML/Utils/OpenXmlHelper.cs +++ b/ClosedXML/Utils/OpenXmlHelper.cs @@ -1,10 +1,9 @@ -// Keep this file CodeMaid organised and cleaned +// Keep this file CodeMaid organised and cleaned using ClosedXML.Excel; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Spreadsheet; using System; -using System.Collections.Generic; -using Drawing = System.Drawing; +using System.Linq; using X14 = DocumentFormat.OpenXml.Office2010.Excel; namespace ClosedXML.Utils @@ -47,12 +46,12 @@ public static T FromClosedXMLColor(this X14.ColorType openXMLColor, XLColor x return (T)adapter.ColorType; } - public static BooleanValue GetBooleanValue(bool value, bool defaultValue) + public static BooleanValue? GetBooleanValue(bool value, bool? defaultValue = null) { - return value == defaultValue ? null : new BooleanValue(value); + return (defaultValue.HasValue && value == defaultValue.Value) ? null : new BooleanValue(value); } - public static bool GetBooleanValueAsBool(BooleanValue value, bool defaultValue) + public static bool GetBooleanValueAsBool(BooleanValue? value, bool defaultValue) { return (value?.HasValue ?? false) ? value.Value : defaultValue; } @@ -61,22 +60,309 @@ public static bool GetBooleanValueAsBool(BooleanValue value, bool defaultValue) /// Convert color in OpenXML representation to ClosedXML type. /// /// Color in OpenXML format. - /// The dictionary containing parsed colors to optimize performance. /// The color in ClosedXML format. - public static XLColor ToClosedXMLColor(this ColorType openXMLColor, IDictionary colorCache = null) + public static XLColor ToClosedXMLColor(this ColorType openXMLColor) { - return ConvertToClosedXMLColor(new ColorTypeAdapter(openXMLColor), colorCache); + return ConvertToClosedXMLColor(new ColorTypeAdapter(openXMLColor)); } /// /// Convert color in OpenXML representation to ClosedXML type. /// /// Color in OpenXML format. - /// The dictionary containing parsed colors to optimize performance. /// The color in ClosedXML format. - public static XLColor ToClosedXMLColor(this X14.ColorType openXMLColor, IDictionary colorCache = null) + public static XLColor ToClosedXMLColor(this X14.ColorType openXMLColor) { - return ConvertToClosedXMLColor(new X14ColorTypeAdapter(openXMLColor), colorCache); + return ConvertToClosedXMLColor(new X14ColorTypeAdapter(openXMLColor)); + } + +#nullable disable + + internal static void LoadNumberFormat(NumberingFormat nfSource, IXLNumberFormat nf) + { + if (nfSource == null) return; + + if (nfSource.NumberFormatId != null && nfSource.NumberFormatId.Value < XLConstants.NumberOfBuiltInStyles) + nf.NumberFormatId = (Int32)nfSource.NumberFormatId.Value; + else if (nfSource.FormatCode != null) + nf.Format = nfSource.FormatCode.Value; + } + + internal static void LoadBorder(Border borderSource, IXLBorder border) + { + if (borderSource == null) return; + + LoadBorderValues(borderSource.DiagonalBorder, border.SetDiagonalBorder, border.SetDiagonalBorderColor); + + if (borderSource.DiagonalUp != null) + border.DiagonalUp = borderSource.DiagonalUp.Value; + if (borderSource.DiagonalDown != null) + border.DiagonalDown = borderSource.DiagonalDown.Value; + + LoadBorderValues(borderSource.LeftBorder, border.SetLeftBorder, border.SetLeftBorderColor); + LoadBorderValues(borderSource.RightBorder, border.SetRightBorder, border.SetRightBorderColor); + LoadBorderValues(borderSource.TopBorder, border.SetTopBorder, border.SetTopBorderColor); + LoadBorderValues(borderSource.BottomBorder, border.SetBottomBorder, border.SetBottomBorderColor); + } + + private static void LoadBorderValues(BorderPropertiesType source, Func setBorder, Func setColor) + { + if (source != null) + { + if (source.Style != null) + setBorder(source.Style.Value.ToClosedXml()); + if (source.Color != null) + setColor(source.Color.ToClosedXMLColor()); + } + } + + // Differential fills store the patterns differently than other fills + // Actually differential fills make more sense. bg is bg and fg is fg + // 'Other' fills store the bg color in the fg field when pattern type is solid + internal static void LoadFill(Fill openXMLFill, IXLFill closedXMLFill, Boolean differentialFillFormat) + { + if (openXMLFill == null || openXMLFill.PatternFill == null) return; + + if (openXMLFill.PatternFill.PatternType != null) + closedXMLFill.PatternType = openXMLFill.PatternFill.PatternType.Value.ToClosedXml(); + else + closedXMLFill.PatternType = XLFillPatternValues.Solid; + + switch (closedXMLFill.PatternType) + { + case XLFillPatternValues.None: + break; + + case XLFillPatternValues.Solid: + if (differentialFillFormat) + { + if (openXMLFill.PatternFill.BackgroundColor != null) + closedXMLFill.BackgroundColor = openXMLFill.PatternFill.BackgroundColor.ToClosedXMLColor(); + else + closedXMLFill.BackgroundColor = XLColor.FromIndex(64); + } + else + { + // yes, source is foreground! + if (openXMLFill.PatternFill.ForegroundColor != null) + closedXMLFill.BackgroundColor = openXMLFill.PatternFill.ForegroundColor.ToClosedXMLColor(); + else + closedXMLFill.BackgroundColor = XLColor.FromIndex(64); + } + break; + + default: + if (openXMLFill.PatternFill.ForegroundColor != null) + closedXMLFill.PatternColor = openXMLFill.PatternFill.ForegroundColor.ToClosedXMLColor(); + + if (openXMLFill.PatternFill.BackgroundColor != null) + closedXMLFill.BackgroundColor = openXMLFill.PatternFill.BackgroundColor.ToClosedXMLColor(); + else + closedXMLFill.BackgroundColor = XLColor.FromIndex(64); + break; + } + } + + internal static void LoadFont(OpenXmlElement fontSource, IXLFontBase fontBase) + { + if (fontSource == null) return; + + fontBase.Bold = GetBoolean(fontSource.Elements().FirstOrDefault()); + var fontColor = fontSource.Elements().FirstOrDefault(); + if (fontColor != null) + fontBase.FontColor = fontColor.ToClosedXMLColor(); + + var fontFamilyNumbering = + fontSource.Elements().FirstOrDefault(); + if (fontFamilyNumbering != null && fontFamilyNumbering.Val != null) + fontBase.FontFamilyNumbering = + (XLFontFamilyNumberingValues)Int32.Parse(fontFamilyNumbering.Val.ToString()); + var runFont = fontSource.Elements().FirstOrDefault(); + if (runFont != null) + { + if (runFont.Val != null) + fontBase.FontName = runFont.Val; + } + var fontSize = fontSource.Elements().FirstOrDefault(); + if (fontSize != null) + { + if ((fontSize).Val != null) + fontBase.FontSize = (fontSize).Val; + } + + fontBase.Italic = GetBoolean(fontSource.Elements().FirstOrDefault()); + fontBase.Shadow = GetBoolean(fontSource.Elements().FirstOrDefault()); + fontBase.Strikethrough = GetBoolean(fontSource.Elements().FirstOrDefault()); + + var underline = fontSource.Elements().FirstOrDefault(); + if (underline != null) + { + fontBase.Underline = underline.Val != null ? underline.Val.Value.ToClosedXml() : XLFontUnderlineValues.Single; + } + + var verticalTextAlignment = fontSource.Elements().FirstOrDefault(); + if (verticalTextAlignment is not null) + { + fontBase.VerticalAlignment = verticalTextAlignment.Val is not null ? verticalTextAlignment.Val.Value.ToClosedXml() : XLFontVerticalTextAlignmentValues.Baseline; + } + + var fontScheme = fontSource.Elements().FirstOrDefault(); + if (fontScheme is not null) + { + fontBase.FontScheme = fontScheme.Val is not null ? fontScheme.Val.Value.ToClosedXml() : XLFontScheme.None; + } + } + + internal static Boolean GetBoolean(BooleanPropertyType property) + { + if (property != null) + { + if (property.Val != null) + return property.Val; + return true; + } + + return false; + } + +#nullable enable + + public static XLAlignmentKey AlignmentToClosedXml(Alignment alignment, XLAlignmentKey defaultAlignment) + { + return new XLAlignmentKey + { + Indent = checked((int?)alignment.Indent?.Value) ?? defaultAlignment.Indent, + Horizontal = alignment.Horizontal?.Value.ToClosedXml() ?? defaultAlignment.Horizontal, + Vertical = alignment.Vertical?.Value.ToClosedXml() ?? defaultAlignment.Vertical, + ReadingOrder = alignment.ReadingOrder?.Value.ToClosedXml() ?? defaultAlignment.ReadingOrder, + WrapText = alignment.WrapText?.Value ?? defaultAlignment.WrapText, + TextRotation = alignment.TextRotation is not null + ? OpenXmlHelper.GetClosedXmlTextRotation(alignment) + : defaultAlignment.TextRotation, + ShrinkToFit = alignment.ShrinkToFit?.Value ?? defaultAlignment.ShrinkToFit, + RelativeIndent = alignment.RelativeIndent?.Value ?? defaultAlignment.RelativeIndent, + JustifyLastLine = alignment.JustifyLastLine?.Value ?? defaultAlignment.JustifyLastLine, + }; + } + + public static XLBorderKey BorderToClosedXml(Border b, XLBorderKey defaultBorder) + { + var nb = defaultBorder; + + var diagonalBorder = b.DiagonalBorder; + if (diagonalBorder is not null) + { + if (diagonalBorder.Style is not null) + nb = nb with { DiagonalBorder = diagonalBorder.Style.Value.ToClosedXml() }; + if (diagonalBorder.Color is not null) + nb = nb with { DiagonalBorderColor = diagonalBorder.Color.ToClosedXMLColor().Key }; + if (b.DiagonalUp is not null) + nb = nb with { DiagonalUp = b.DiagonalUp.Value }; + if (b.DiagonalDown is not null) + nb = nb with { DiagonalDown = b.DiagonalDown.Value }; + } + + var leftBorder = b.LeftBorder; + if (leftBorder is not null) + { + if (leftBorder.Style is not null) + nb = nb with { LeftBorder = leftBorder.Style.Value.ToClosedXml() }; + if (leftBorder.Color is not null) + nb = nb with { LeftBorderColor = leftBorder.Color.ToClosedXMLColor().Key }; + } + + var rightBorder = b.RightBorder; + if (rightBorder is not null) + { + if (rightBorder.Style is not null) + nb = nb with { RightBorder = rightBorder.Style.Value.ToClosedXml() }; + if (rightBorder.Color is not null) + nb = nb with { RightBorderColor = rightBorder.Color.ToClosedXMLColor().Key }; + } + + var topBorder = b.TopBorder; + if (topBorder is not null) + { + if (topBorder.Style is not null) + nb = nb with { TopBorder = topBorder.Style.Value.ToClosedXml() }; + if (topBorder.Color is not null) + nb = nb with { TopBorderColor = topBorder.Color.ToClosedXMLColor().Key }; + } + + var bottomBorder = b.BottomBorder; + if (bottomBorder is not null) + { + if (bottomBorder.Style is not null) + nb = nb with { BottomBorder = bottomBorder.Style.Value.ToClosedXml() }; + if (bottomBorder.Color is not null) + nb = nb with { BottomBorderColor = bottomBorder.Color.ToClosedXMLColor().Key }; + } + + return nb; + } + + public static XLFontKey FontToClosedXml(Font f, XLFontKey nf) + { + nf = nf with + { + Bold = GetBoolean(f.Bold), + Italic = GetBoolean(f.Italic), + Shadow = GetBoolean(f.Shadow), + Strikethrough = GetBoolean(f.Strike), + }; + + var underline = f.Underline; + if (underline is not null) + { + var value = underline.Val?.Value.ToClosedXml() ?? + XLFontUnderlineValues.Single; + nf = nf with { Underline = value }; + } + + var verticalTextAlignment = f.VerticalTextAlignment; + if (verticalTextAlignment is not null) + { + var value = verticalTextAlignment.Val?.Value.ToClosedXml() ?? + XLFontVerticalTextAlignmentValues.Baseline; + nf = nf with { VerticalAlignment = value }; + } + + var fontSize = f.FontSize?.Val; + if (fontSize is not null) + nf = nf with { FontSize = fontSize.Value }; + + var color = f.Color; + if (color is not null) + nf = nf with { FontColor = color.ToClosedXMLColor().Key }; + + var fontName = f.FontName?.Val?.Value ?? string.Empty; + if (!string.IsNullOrEmpty(fontName)) + nf = nf with { FontName = fontName }; + + var fontFamilyNumbering = f.FontFamilyNumbering?.Val?.Value; + if (fontFamilyNumbering is not null) + nf = nf with { FontFamilyNumbering = (XLFontFamilyNumberingValues)fontFamilyNumbering }; + + var fontCharSet = f.FontCharSet?.Val?.Value; + if (fontCharSet is not null) + nf = nf with { FontCharSet = (XLFontCharSet)fontCharSet }; + + var fontScheme = f.FontScheme; + if (fontScheme is not null) + nf = nf with { FontScheme = fontScheme?.Val?.Value.ToClosedXml() ?? XLFontScheme.None }; + return nf; + } + + public static XLProtectionKey ProtectionToClosedXml(Protection protection, XLProtectionKey p) + { + // OI29500, hidden default is false, locked default is true. + if (protection.Hidden is not null) + p = p with { Hidden = protection.Hidden.Value }; + + if (protection.Locked is not null) + p = p with { Locked = protection.Locked.Value }; + + return p; } #endregion Public Methods @@ -86,43 +372,33 @@ public static XLColor ToClosedXMLColor(this X14.ColorType openXMLColor, IDiction /// /// Here we perform the actual conversion from OpenXML color to ClosedXML color. /// - /// OpenXML color. Must be either or . + /// OpenXML color. Must be either or . /// Since these types do not implement a common interface we use dynamic. - /// The dictionary containing parsed colors to optimize performance. /// The color in ClosedXML format. - private static XLColor ConvertToClosedXMLColor(IColorTypeAdapter openXMLColor, IDictionary colorCache ) + private static XLColor ConvertToClosedXMLColor(IColorTypeAdapter openXMLColor) { - XLColor retVal = null; - if (openXMLColor != null) + XLColor? retVal = null; + if (openXMLColor.Rgb?.Value is not null) { - if (openXMLColor.Rgb != null) - { - String htmlColor = "#" + openXMLColor.Rgb.Value; - if (colorCache == null || !colorCache.TryGetValue(htmlColor, out Drawing.Color thisColor)) - { - thisColor = ColorStringParser.ParseFromArgb(htmlColor); - colorCache?.Add(htmlColor, thisColor); - } - - retVal = XLColor.FromColor(thisColor); - } - else if (openXMLColor.Indexed != null && openXMLColor.Indexed <= 64) - retVal = XLColor.FromIndex((Int32)openXMLColor.Indexed.Value); - else if (openXMLColor.Theme != null) - { - retVal = openXMLColor.Tint != null - ? XLColor.FromTheme((XLThemeColor)openXMLColor.Theme.Value, openXMLColor.Tint.Value) - : XLColor.FromTheme((XLThemeColor)openXMLColor.Theme.Value); - } + var thisColor = ColorStringParser.ParseFromArgb(openXMLColor.Rgb.Value.AsSpan()); + retVal = XLColor.FromColor(thisColor); + } + else if (openXMLColor.Indexed is not null && openXMLColor.Indexed <= 64) + retVal = XLColor.FromIndex((Int32)openXMLColor.Indexed.Value); + else if (openXMLColor.Theme is not null) + { + retVal = openXMLColor.Tint is not null + ? XLColor.FromTheme((XLThemeColor)openXMLColor.Theme.Value, openXMLColor.Tint.Value) + : XLColor.FromTheme((XLThemeColor)openXMLColor.Theme.Value); } return retVal ?? XLColor.NoColor; } /// - /// Initialize properties of the existing instance of the color in OpenXML format basing on properties of the color + /// Initialize properties of the existing instance of the color in OpenXML format basing on properties of the color /// in ClosedXML format. /// - /// OpenXML color. Must be either or . + /// OpenXML color. Must be either or . /// Since these types do not implement a common interface we use dynamic. /// Color in ClosedXML format. /// Flag specifying that the color should be saved in @@ -156,6 +432,20 @@ private static void FillFromClosedXMLColor(IColorTypeAdapter openXMLColor, XLCol } } + internal static int GetClosedXmlTextRotation(Alignment alignment) + { + if (alignment.TextRotation is null) + return 0; + + var textRotation = (int)alignment.TextRotation.Value; + return textRotation switch + { + 255 => 255, + > 90 => 90 - textRotation, + _ => textRotation + }; + } + #endregion Private Methods } } diff --git a/ClosedXML/Utils/StreamExtensions.cs b/ClosedXML/Utils/StreamExtensions.cs index 0d6cca710..ed19d0543 100644 --- a/ClosedXML/Utils/StreamExtensions.cs +++ b/ClosedXML/Utils/StreamExtensions.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.IO; namespace ClosedXML.Utils @@ -14,13 +16,6 @@ public static int ReadS32LE(this Stream stream) return b4 << 24 | b3 << 16 | b2 << 8 | b1; } - public static short ReadS16BE(this Stream stream) - { - var b1 = stream.ReadU8(); - var b2 = stream.ReadU8(); - return (short)((b1 << 8) | b2); - } - public static short ReadS16LE(this Stream stream) { var b1 = stream.ReadU8(); @@ -28,15 +23,6 @@ public static short ReadS16LE(this Stream stream) return (short)((b2 << 8) | b1); } - public static int ReadS32BE(this Stream stream) - { - var b1 = stream.ReadU8(); - var b2 = stream.ReadU8(); - var b3 = stream.ReadU8(); - var b4 = stream.ReadU8(); - return b1 << 24 | b2 << 16 | b3 << 8 | b4; - } - public static ushort ReadU16BE(this Stream stream) { if (!TryReadU16BE(stream, out var number)) @@ -87,6 +73,22 @@ public static bool TryReadU16LE(this Stream stream, out ushort number) return true; } + public static int ReadU24BE(this Stream stream) + { + if (!TryReadBE(stream, 3, out var result)) + throw EndOfStreamException(); + + return result; + } + + public static int ReadU24LE(this Stream stream) + { + if (!TryReadLE(stream, 3, out var result)) + throw EndOfStreamException(); + + return result; + } + public static byte ReadU8(this Stream stream) { var b = stream.ReadByte(); @@ -108,8 +110,7 @@ public static bool TryReadU32BE(this Stream stream, out uint number) public static bool TryReadU16BE(this Stream stream, out ushort number) { - int readNumber; - if (TryReadBE(stream, 2, out readNumber)) + if (TryReadBE(stream, 2, out var readNumber)) { number = (ushort)readNumber; return true; diff --git a/ClosedXML/Utils/XmlEncoder.cs b/ClosedXML/Utils/XmlEncoder.cs index 866852ffa..d198b1910 100644 --- a/ClosedXML/Utils/XmlEncoder.cs +++ b/ClosedXML/Utils/XmlEncoder.cs @@ -4,44 +4,35 @@ namespace ClosedXML.Utils { - public static class XmlEncoder + internal static class XmlEncoder { private static readonly Regex xHHHHRegex = new Regex("_(x[\\dA-Fa-f]{4})_", RegexOptions.Compiled); - private static readonly Regex Uppercase_X_HHHHRegex = new Regex("_(X[\\dA-Fa-f]{4})_", RegexOptions.Compiled); public static string EncodeString(string encodeStr) { - if (encodeStr == null) return null; - encodeStr = xHHHHRegex.Replace(encodeStr, "_x005F_$1_"); var sb = new StringBuilder(encodeStr.Length); - - foreach (var ch in encodeStr) + var len = encodeStr.Length; + for (var i = 0; i < len; ++i) { - if (XmlConvert.IsXmlChar(ch)) + var currentChar = encodeStr[i]; + if (XmlConvert.IsXmlChar(currentChar)) { - sb.Append(ch); + sb.Append(currentChar); + } + else if (i + 1 < len && XmlConvert.IsXmlSurrogatePair(encodeStr[i + 1], currentChar)) + { + sb.Append(currentChar); + sb.Append(encodeStr[++i]); } else { - sb.Append(XmlConvert.EncodeName(ch.ToString())); + sb.Append(XmlConvert.EncodeName(currentChar.ToString())); } } return sb.ToString(); } - - public static string DecodeString(string decodeStr) - { - if (string.IsNullOrEmpty(decodeStr)) return string.Empty; - - // Strings "escaped" with _X (capital X) should not be treated as escaped - // Example: _Xceed_Something - // https://github.com/ClosedXML/ClosedXML/issues/1154 - decodeStr = Uppercase_X_HHHHRegex.Replace(decodeStr, "_x005F_$1_"); - - return XmlConvert.DecodeName(decodeStr); - } } } diff --git a/ClosedXML/XLHelper.cs b/ClosedXML/XLHelper.cs index 4bdbcfcc9..47d5ff378 100644 --- a/ClosedXML/XLHelper.cs +++ b/ClosedXML/XLHelper.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text; using System.Text.RegularExpressions; namespace ClosedXML.Excel @@ -18,11 +19,43 @@ public static partial class XLHelper public const String MaxColumnLetter = "XFD"; public const Double Epsilon = 1e-10; + /// + /// Maximum number of code units that can be stored in a cell. + /// + internal const int CellTextLimit = 32767; + + /// + /// 1900 calendar serial date upper limit (exclusive). + /// + internal const int Calendar1900UpperLimit = 2958466; + + /// + /// 1904 calendar serial date upper limit (exclusive). + /// + internal const int Calendar1904UpperLimit = 2957004; + + public static Encoding NoBomUTF8 = new UTF8Encoding(false); + public static String LastCell { get { return $"{MaxColumnLetter}{MaxRowNumber}"; } } internal static readonly NumberStyles NumberStyle = NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign | NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite | NumberStyles.AllowExponent; internal static readonly CultureInfo ParseCulture = CultureInfo.InvariantCulture; + /// + /// Comparer used to compare sheet names. + /// + internal static readonly StringComparer SheetComparer = StringComparer.OrdinalIgnoreCase; + + /// + /// Comparer used to compare defined names. + /// + internal static readonly StringComparer NameComparer = StringComparer.OrdinalIgnoreCase; + + /// + /// Comparer of function names. + /// + internal static readonly StringComparer FunctionComparer = StringComparer.OrdinalIgnoreCase; + internal static readonly Regex RCSimpleRegex = new Regex( @"^(r(((-\d)?\d*)|\[(-\d)?\d*\]))?(c(((-\d)?\d*)|\[(-\d)?\d*\]))?$" , RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -103,7 +136,6 @@ public static int GetColumnNumberFromLetter(string columnLetter) /// /// The column number to translate into a column letter. /// if set to true the column letter will be restricted to the allowed range. - /// public static string GetColumnLetterFromNumber(int columnNumber, bool trimToAllowed = false) { if (trimToAllowed) columnNumber = TrimColumnNumber(columnNumber); @@ -190,6 +222,9 @@ public static bool IsValidRCAddress(string address) public static Boolean IsValidRangeAddress(String rangeAddress) { + if (String.IsNullOrWhiteSpace(rangeAddress)) + return false; + return A1SimpleRegex.IsMatch(rangeAddress); } @@ -221,18 +256,13 @@ internal static IXLTableRows InsertRowsWithoutEvents(Func rows.Add(new XLTableRow(tableRange, r as XLRangeRow))); + inserted.ForEach(r => rows.Add(new XLTableRow(tableRange, (XLRangeRow)r))); if (expandTable) tableRange.Table.ExpandTableRows(numberOfRows); - ws.EventTrackingEnabled = tracking; - return rows; } @@ -303,34 +333,54 @@ internal static bool IsValidOADateNumber(this double d) } /// - /// A backward compatible version of that returns a value - /// rounded to milliseconds. In .Net Core 3.0 the behavior has changed and timespan includes microseconds - /// as well. As a result, the value 1:12:30 saved on one machine could become 1:12:29.999999 on another. + /// + /// An alternative to . In NetFx, it returned a value + /// rounded to milliseconds. In .Net Core 3.0 the behavior has changed and conversion doesn't + /// round at all (=precision down to ticks). To avoid problems with a different behavior on + /// NetFx and Core (saving value 1:12:30 on NetFx machine could become 1:12:29.999999 on Core + /// one machine), we use instead this method for both runtimes (behaves as on Core). + /// + /// + /// TimeSpan has a resolution of 0.1 us (1.15e-12 as a serial date). ~12 digits of precision + /// are needed to accurately represent one day as a serial date time in that resolution. Double + /// has ~15 digits of precision, so it should be able to represent up to ~100 days in a ticks + /// precision. + /// /// internal static TimeSpan GetTimeSpan(double totalDays) { - var timeSpan = TimeSpan.FromDays(totalDays); - var roundedMilliseconds = Math.Round(timeSpan.TotalMilliseconds); - return TimeSpan.FromMilliseconds(roundedMilliseconds); + var ticks = Math.Round(totalDays * TimeSpan.TicksPerDay, MidpointRounding.AwayFromZero); + if (ticks is > long.MaxValue or < long.MinValue) + throw new OverflowException("The serial date time value is too large to be represented in a TimeSpan."); + + return TimeSpan.FromTicks(checked((long)ticks)); } - public static Boolean ValidateName(String objectType, String newName, String oldName, IEnumerable existingNames, out String message) + internal static Boolean ValidateName(String objectType, String newName, String oldName, IEnumerable existingNames, out String message) { - message = ""; - if (String.IsNullOrWhiteSpace(newName)) - { - message = $"The {objectType} name '{newName}' is invalid"; + if (!ValidateName(objectType, newName, out message)) return false; - } // Table names are case insensitive - if (!oldName.Equals(newName, StringComparison.OrdinalIgnoreCase) + if (!string.Equals(oldName, newName, StringComparison.OrdinalIgnoreCase) && existingNames.Contains(newName, StringComparer.OrdinalIgnoreCase)) { message = $"There is already a {objectType} named '{newName}'"; return false; } + return true; + } + + internal static Boolean ValidateName(String objectType, String newName, out String message) + { + message = ""; + if (String.IsNullOrWhiteSpace(newName)) + { + message = $"The {objectType} name '{newName}' is invalid"; + return false; + } + var allowedFirstCharacters = new[] { '_', '\\' }; if (!allowedFirstCharacters.Contains(newName[0]) && !char.IsLetter(newName[0])) { @@ -356,5 +406,108 @@ public static Boolean ValidateName(String objectType, String newName, String old internal static double PixelsToPoints(double pixels, double dpi) => pixels * 72d / dpi; internal static double PointsToPixels(double points, double dpi) => points * dpi / 72d; + + /// + /// Convert size in pixels to a size in NoC (number of characters). + /// + /// Size in pixels. + /// Size of maximum digit width in pixels. + /// Size in NoC. + internal static double PixelToNoC(int px, int mdw) + { + // Pixel padding. Each side should have 2px for Calibri at 11pt plus 1 pixel for the grid line. + var pp = 2 * (int)Math.Ceiling(mdw / 4.0) + 1; + + // NoC scales linearly with MDW, if size is at least 1 char (+padding) + if (px >= (mdw + pp)) + return (px - pp) / (double)mdw; + + // smaller sizes are scaled to the 1 NoC size + return px / (double)(mdw + pp); + } + + /// + /// Convert size in NoC to size in pixels. + /// + /// Size in number of characters. + /// Maximum digit width in pixels. + /// Size in pixels (not rounded). + internal static double NoCToPixels(double noc, int mdw) + { + var pp = 2 * (int)Math.Ceiling(mdw / 4.0) + 1; + if (noc < 1) + return noc * (mdw + pp); + + return noc * mdw + pp; + } + + /// + /// Convert size in number of characters to pixels. + /// + /// Width + /// Font used to determine mdw. + /// Workbook for dpi and graphic engine. + /// Width in pixels. + internal static int NoCToPixels(double noc, IXLFont font, XLWorkbook workbook) + { + var mdw = workbook.GraphicEngine.GetMaxDigitWidth(font, workbook.DpiX).RoundToInt(); + return NoCToPixels(noc, mdw).RoundToInt(); + } + + /// + /// Convert width to pixels. + /// + /// Width from the source file, not NoC that is displayed in Excel as a width. + /// + /// Number of pixels. + internal static int WidthToPixels(double width, int mdw) + { + return (width * mdw).RoundToInt(); + } + + internal static double PixelsToWidth(double width, int mdw) + { + return Math.Truncate(width * mdw * 256) / 256d; + } + + /// + /// Convert width (as a multiple of MDWs) into a NoCs (number displayed in Excel). + /// + /// Width in MDWs to convert. + /// Font used to determine MDW. + /// Workbook + /// Width as a number of NoC. + internal static double ConvertWidthToNoC(double width, IXLFont font, XLWorkbook workbook) + { + var mdw = workbook.GraphicEngine.GetMaxDigitWidth(font, workbook.DpiX).RoundToInt(); + var pixelsWidth = WidthToPixels(width, mdw); + var columnWidth = PixelToNoC(pixelsWidth, mdw); + return columnWidth; + } + + /// + /// Convert degrees to radians. + /// + internal static double DegToRad(double angle) => Math.PI * angle / 180.0; + + /// + /// Calculate expected column width as a number displayed in the column in Excel from + /// number of characters that should fit into the width and a font. + /// + internal static double CalculateColumnWidth(double charWidth, IXLFont font, XLWorkbook workbook) + { + // Convert width as a number of characters and translate it into a given number of pixels. + int mdw = workbook.GraphicEngine.GetMaxDigitWidth(font, workbook.DpiX).RoundToInt(); + int defaultColWidthPx = NoCToPixels(charWidth, mdw).RoundToInt(); + + // Excel then rounds this number up to the nearest multiple of 8 pixels, so that + // scrolling across columns and rows is faster. + int roundUpToMultiple = defaultColWidthPx + (8 - defaultColWidthPx % 8); + + // and last convert the width in pixels to width displayed in Excel. Shouldn't round the number, because + // it causes inconsistency with conversion to other units, but other places in ClosedXML do = keep for now. + double defaultColumnWidth = PixelToNoC(roundUpToMultiple, mdw).Round(2); + return defaultColumnWidth; + } } } diff --git a/ClosedXML/XLHelper_ASF.cs b/ClosedXML/XLHelper_ASF.cs index 7037c8218..c62f01315 100644 --- a/ClosedXML/XLHelper_ASF.cs +++ b/ClosedXML/XLHelper_ASF.cs @@ -1,4 +1,6 @@ -// Keep this file CodeMaid organised and cleaned +#nullable disable + +// Keep this file CodeMaid organised and cleaned /* ==================================================================== Licensed to the Apache Software Foundation (ASF) under one or more diff --git a/Directory.Build.props b/Directory.Build.props index a2dbf4f9a..f92ccf745 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,22 +5,21 @@ - 0.97.0 + 0.105.0-rc ClosedXML - Francois Botha, Jan Havlíček, Aleksei Pankratev, Manuel de Leon, Amir Ghezelbash - Francois Botha, Jan Havlíček, Aleksei Pankratev - + Jan Havlíček, Francois Botha, Aleksei Pankratev, Manuel de Leon, Amir Ghezelbash + Jan Havlíček, Francois Botha, Aleksei Pankratev - MIT MIT https://github.com/ClosedXML/ClosedXML nuget-logo.png Excel OpenXml xlsx + See release notes https://github.com/ClosedXML/ClosedXML/releases/tag/$(productVersion) ClosedXML is a .NET library for reading, manipulating and writing Excel 2007+ (.xlsx, .xlsm) files. It aims to provide an intuitive and user-friendly interface to dealing with the underlying OpenXML API. See https://github.com/ClosedXML/ClosedXML/releases/tag/$(productVersion) https://github.com/ClosedXML/ClosedXML git @@ -28,16 +27,28 @@ true true - true + snupkg + + true + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb 9.0 true $(NoWarn);NU1605;CS1591 true + true $(SourceRoot)/ClosedXML.snk + + + true + + $(AppVeyor) diff --git a/README.md b/README.md index 7c46beaca..0578ec3c3 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,55 @@ ![ClosedXML](https://github.com/ClosedXML/ClosedXML/blob/develop/resources/logo/readme.png) -[![Release](https://img.shields.io/badge/release-0.95.4-blue.svg)](https://github.com/ClosedXML/ClosedXML/releases/latest) [![NuGet Badge](https://buildstats.info/nuget/ClosedXML)](https://www.nuget.org/packages/ClosedXML/) [![.NET Framework](https://img.shields.io/badge/.NET%20Framework-%3E%3D%204.0-red.svg)](#) [![.NET Standard](https://img.shields.io/badge/.NET%20Standard-%3E%3D%202.0-red.svg)](#) [![Build status](https://ci.appveyor.com/api/projects/status/wobbmnlbukxejjgb?svg=true)](https://ci.appveyor.com/project/ClosedXML/ClosedXML/branch/develop/artifacts) +[![Release](https://img.shields.io/badge/release-0.95.4-blue.svg)](https://github.com/ClosedXML/ClosedXML/releases/latest) [![NuGet version (ClosedXML)](https://img.shields.io/nuget/v/ClosedXML.svg?style=flat)](https://www.nuget.org/packages/ClosedXML/) [![.NET Framework](https://img.shields.io/badge/.NET%20Framework-%3E%3D%204.0-red.svg)](#) [![.NET Standard](https://img.shields.io/badge/.NET%20Standard-%3E%3D%202.0-red.svg)](#) [![Build status](https://ci.appveyor.com/api/projects/status/wobbmnlbukxejjgb?svg=true)](https://ci.appveyor.com/project/ClosedXML/ClosedXML/branch/develop/artifacts) [![Open Source Helpers](https://www.codetriage.com/closedxml/closedxml/badges/users.svg)](https://www.codetriage.com/closedxml/closedxml) [💾 Download unstable CI build](https://ci.appveyor.com/project/ClosedXML/ClosedXML/branch/develop/artifacts) ClosedXML is a .NET library for reading, manipulating and writing Excel 2007+ (.xlsx, .xlsm) files. It aims to provide an intuitive and user-friendly interface to dealing with the underlying [OpenXML](https://github.com/OfficeDev/Open-XML-SDK) API. -[For more information see the wiki](https://github.com/closedxml/closedxml/wiki) +For more information see [the documentation](https://closedxml.readthedocs.io/) or [the wiki](https://github.com/closedxml/closedxml/wiki). + +### Release notes & migration guide + +The public API is still not stable and it is a very good idea to **read release notes** and **migration guide** before each update. +* [Release notes for 0.100](https://github.com/ClosedXML/ClosedXML/releases/tag/0.100.0) +* [Migration guide for 0.100](https://closedxml.readthedocs.io/en/latest/migrations/migrate-to-0.100.html) +* [Release notes for 0.97](https://github.com/ClosedXML/ClosedXML/releases/tag/0.97.0) + +### Performance + +Performance matters mostly for large files. For small files, few ms here or there doesn't matter. The presented data are from generally develop branch (currently [0.103-beta](https://github.com/ClosedXML/ClosedXML/commit/5f7c0d9461352a6a468e5299bfef6eaf82bf37da)). + +
+ Runtime details +``` +BenchmarkDotNet v0.13.8, Windows 11 (10.0.22621.2283/22H2/2022Update/SunValley2) +AMD Ryzen 5 5500U with Radeon Graphics, 1 CPU, 12 logical and 6 physical cores +.NET SDK 8.0.100-rc.1.23463.5 + [Host] : .NET 7.0.11 (7.0.1123.42427), X64 RyuJIT AVX2 +``` +
+ +#### Save + +| Description | Rows | Columns | Time/Memory to insert data | Save workbook | Total time/memory | +|------------------------------|-----------|------------------------|----------------------------|------------------------------|---| +| Mixed (text/number) workbook.
[Gist](https://gist.github.com/jahav/bdc5fe3c90f25544ca6ae1394bbe3561) | 250 000 | 15 | 1.619 sec / 117 MiB | 6.343 sec | 7.962 sec / 477 MiB | +| Text only workbook. [Gist](https://gist.github.com/jahav/257bb2ffd5ab7adfded7e669290d8151) | 1 000 000 | 10 | 6.302 sec / 402 MiB | 17.134 sec | 23.436 sec / 1880 MiB | + +#### Load + +| Description | Rows | Columns | Time to load data | Used memory | +|--------------------------------------------------------------------|-----------|---------|-------------------|-------------| +| Load mixed workbook (10 text/5 number columns). | 250 000 | 15 | 15.648 sec | 236 MiB | +| Text only workbook. | 1 000 000 | 10 | 49.046 sec | 801 MiB | + +Load tests used files created during save test. + +### Frequent answers +- If you get an exception `Unable to find font font name or fallback font fallback font name. Install missing fonts or specify a different fallback font through ‘LoadOptions.DefaultGraphicEngine = new DefaultGraphicEngine(“Fallback font name”)’`, see help page about [missing fonts](https://closedxml.readthedocs.io/en/latest/tips/missing-font.html). +- ClosedXML is not thread-safe. There is no guarantee that [parallel operations](https://github.com/ClosedXML/ClosedXML/issues/1662) will work. The underlying OpenXML library is also not thread-safe. +- If you get an exception `The type initializer for 'Gdip' threw an exception.` on Linux, you have to upgrade to 0.97+. ### Install ClosedXML via NuGet @@ -34,10 +76,6 @@ using (var workbook = new XLWorkbook()) } ``` -### Frequent answers -- ClosedXML is not thread-safe. There is no guarantee that [parallel operations](https://github.com/ClosedXML/ClosedXML/issues/1662) will work. The underlying OpenXML library is also not thread-safe. -- If you get an exception `The type initializer for 'Gdip' threw an exception.` on Linux, try [these](https://stackoverflow.com/a/67092403/179494) [solutions](https://github.com/dotnet/runtime/issues/27200#issuecomment-415327256). - ### Extensions Be sure to check out our `ClosedXML` extension projects - https://github.com/ClosedXML/ClosedXML.Report @@ -46,7 +84,7 @@ Be sure to check out our `ClosedXML` extension projects - https://github.com/ClosedXML/ClosedXML.Extensions.WebApi ## Developer guidelines -The [OpenXML specification](https://www.ecma-international.org/publications/standards/Ecma-376.htm) is a large and complicated beast. In order for ClosedXML, the wrapper around OpenXML, to support all the features, we rely on community contributions. Before opening an issue to request a new feature, we'd like to urge you to try to implement it yourself and log a pull request. +The [OpenXML specification](https://ecma-international.org/publications-and-standards/standards/ecma-376/) is a large and complicated beast. In order for ClosedXML, the wrapper around OpenXML, to support all the features, we rely on community contributions. Before opening an issue to request a new feature, we'd like to urge you to try to implement it yourself and log a pull request. Please read the [full developer guidelines](CONTRIBUTING.md). @@ -56,3 +94,7 @@ Please read the [full developer guidelines](CONTRIBUTING.md). * Former maintainer and lead developer: [Francois Botha](https://github.com/igitur) * Master of Computing Patterns: [Aleksei Pankratev](https://github.com/Pankraty) * Logo design by [@Tobaloidee](https://github.com/Tobaloidee) + +Thanks to JetBrains for providing development tools through their [Open Source Program](https://www.jetbrains.com/community/opensource/) + +[JetBrains logo.](https://www.jetbrains.com/) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..e215f9691 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security Policy + +## Supported Versions + +Only the latest version without a label (e.g. rc, beta) released through [NuGet.org](https://www.nuget.org/packages/ClosedXML) is supported. + +## NETStandard dependencies + +ClosedXML directly or indirectly depends on [NETStandard.Library](https://www.nuget.org/packages/NETStandard.Library). +The netStandard package references some other packages with minimal versions that might be marked as vulnerable. + +One of them is `System.Text.RegularExpressions@4.3.0` or `System.Net.Http@4.3.0`, generally through `Irony.NetCore` package. + +* ClosedXML@0.100.3 > XLParser@1.5.2 > Irony.NetCore@1.0.11 > NETStandard.Library@1.6.1 > System.Xml.XDocument@4.3.0 > System.Xml.ReaderWriter@4.3.0 > System.Text.RegularExpressions@4.3.0 +* ClosedXML@0.100.3 > XLParser@1.5.2 > Irony.NetCore@1.0.11 > NETStandard.Library@1.6.1 > System.Net.Http@4.3.0 + +**These reports are false positives.** + +Netstandard is only a specification of API, not an implementation and it defers to actual implementation that is being maintained. +* For .NET Framework, NETStandard uses a facade dll to forward types of the installed .NET Framework. +* For .NET Core, NETStandard uses the implementation from the selected .NET Core version of the application (e.g. 7 for net7). + +The vulnerability through NETStandard reference is a security issue only if the application is running an obsolete framework/core (e.g. 4.5 or core2). + +That is also position of the team that maintained .NET Standard: https://github.com/dotnet/standard/issues/1786 + +> System.Text.RegularExpressions never applies a vulnerable binary on .NETFramework. It applies a facade dll that typeforwards to System.dll where all this code lives. The facade dll is not vulnerable as it does not contain the code. System.Text.RegularExpressions also does not apply its binary on .NETCore2.0 and later. There the implementation is provided by the shared framework. This package only exists for delivering the implementation to older frameworks (.netcore1.x), which are now out of support. +> In general we don't churn the entire package ecosystem when a single package is updated. If you'd like to update your package reference to suppress this false positive from a validation tool you may. This wouldn't be much different than if we shipped a new version of NETStandard.Library, you'd still need all the packages that referenced the old version to update to a new one. + +## Reporting a Vulnerability + +Please file an issue with a *SECURITY* as the first word in the title of the issue. \ No newline at end of file diff --git a/appveyor.yml b/appveyor.yml index c093727b6..bab94350a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,7 +1,6 @@ -version: 0.97.0.{build} +version: 0.104.0.{build} -os: Visual Studio 2019 -image: Visual Studio 2019 +image: Visual Studio 2022 environment: AppVeyor: APPVEYOR @@ -54,8 +53,7 @@ build: artifacts: - path: ClosedXML/bin/%CONFIGURATION%/*/ClosedXML.dll - path: ClosedXML/bin/%CONFIGURATION%/*.nupkg - - path: ClosedXML.SixLabors/bin/%CONFIGURATION%/*/ClosedXML.SixLabors.dll - - path: ClosedXML.SixLabors/bin/%CONFIGURATION%/*.nupkg + - path: ClosedXML/bin/%CONFIGURATION%/*.snupkg nuget: project_feed: true diff --git a/docs/Doxyfile b/docs/Doxyfile new file mode 100644 index 000000000..f24e27f90 --- /dev/null +++ b/docs/Doxyfile @@ -0,0 +1,2521 @@ +# Doxyfile 1.8.15 + +# This file describes the settings to be used by the documentation system +# doxygen (www.doxygen.org) for a project. +# +# All text after a double hash (##) is considered a comment and is placed in +# front of the TAG it is preceding. +# +# All text after a single hash (#) is considered a comment and will be ignored. +# The format is: +# TAG = value [value, ...] +# For lists, items can also be appended using: +# TAG += value [value, ...] +# Values that contain spaces should be placed between quotes (\" \"). + +#--------------------------------------------------------------------------- +# Project related configuration options +#--------------------------------------------------------------------------- + +# This tag specifies the encoding used for all characters in the configuration +# file that follow. The default is UTF-8 which is also the encoding used for all +# text before the first occurrence of this tag. Doxygen uses libiconv (or the +# iconv built into libc) for the transcoding. See +# https://www.gnu.org/software/libiconv/ for the list of possible encodings. +# The default value is: UTF-8. + +DOXYFILE_ENCODING = UTF-8 + +# The PROJECT_NAME tag is a single word (or a sequence of words surrounded by +# double-quotes, unless you are using Doxywizard) that should identify the +# project for which the documentation is generated. This name is used in the +# title of most generated pages and in a few other places. +# The default value is: My Project. + +PROJECT_NAME = "ClosedXML" + +# The PROJECT_NUMBER tag can be used to enter a project or revision number. This +# could be handy for archiving the generated documentation or if some version +# control system is used. + +PROJECT_NUMBER = + +# Using the PROJECT_BRIEF tag one can provide an optional one line description +# for a project that appears at the top of each page and should give viewer a +# quick idea about the purpose of the project. Keep the description short. + +PROJECT_BRIEF = + +# With the PROJECT_LOGO tag one can specify a logo or an icon that is included +# in the documentation. The maximum height of the logo should not exceed 55 +# pixels and the maximum width should not exceed 200 pixels. Doxygen will copy +# the logo to the output directory. + +PROJECT_LOGO = + +# The OUTPUT_DIRECTORY tag is used to specify the (relative or absolute) path +# into which the generated documentation will be written. If a relative path is +# entered, it will be relative to the location where doxygen was started. If +# left blank the current directory will be used. + +OUTPUT_DIRECTORY = + +# If the CREATE_SUBDIRS tag is set to YES then doxygen will create 4096 sub- +# directories (in 2 levels) under the output directory of each output format and +# will distribute the generated files over these directories. Enabling this +# option can be useful when feeding doxygen a huge amount of source files, where +# putting all generated files in the same directory would otherwise causes +# performance problems for the file system. +# The default value is: NO. + +CREATE_SUBDIRS = NO + +# If the ALLOW_UNICODE_NAMES tag is set to YES, doxygen will allow non-ASCII +# characters to appear in the names of generated files. If set to NO, non-ASCII +# characters will be escaped, for example _xE3_x81_x84 will be used for Unicode +# U+3044. +# The default value is: NO. + +ALLOW_UNICODE_NAMES = NO + +# The OUTPUT_LANGUAGE tag is used to specify the language in which all +# documentation generated by doxygen is written. Doxygen will use this +# information to generate all constant output in the proper language. +# Possible values are: Afrikaans, Arabic, Armenian, Brazilian, Catalan, Chinese, +# Chinese-Traditional, Croatian, Czech, Danish, Dutch, English (United States), +# Esperanto, Farsi (Persian), Finnish, French, German, Greek, Hungarian, +# Indonesian, Italian, Japanese, Japanese-en (Japanese with English messages), +# Korean, Korean-en (Korean with English messages), Latvian, Lithuanian, +# Macedonian, Norwegian, Persian (Farsi), Polish, Portuguese, Romanian, Russian, +# Serbian, Serbian-Cyrillic, Slovak, Slovene, Spanish, Swedish, Turkish, +# Ukrainian and Vietnamese. +# The default value is: English. + +OUTPUT_LANGUAGE = English + +# The OUTPUT_TEXT_DIRECTION tag is used to specify the direction in which all +# documentation generated by doxygen is written. Doxygen will use this +# information to generate all generated output in the proper direction. +# Possible values are: None, LTR, RTL and Context. +# The default value is: None. + +OUTPUT_TEXT_DIRECTION = None + +# If the BRIEF_MEMBER_DESC tag is set to YES, doxygen will include brief member +# descriptions after the members that are listed in the file and class +# documentation (similar to Javadoc). Set to NO to disable this. +# The default value is: YES. + +BRIEF_MEMBER_DESC = YES + +# If the REPEAT_BRIEF tag is set to YES, doxygen will prepend the brief +# description of a member or function before the detailed description +# +# Note: If both HIDE_UNDOC_MEMBERS and BRIEF_MEMBER_DESC are set to NO, the +# brief descriptions will be completely suppressed. +# The default value is: YES. + +REPEAT_BRIEF = YES + +# This tag implements a quasi-intelligent brief description abbreviator that is +# used to form the text in various listings. Each string in this list, if found +# as the leading text of the brief description, will be stripped from the text +# and the result, after processing the whole list, is used as the annotated +# text. Otherwise, the brief description is used as-is. If left blank, the +# following values are used ($name is automatically replaced with the name of +# the entity):The $name class, The $name widget, The $name file, is, provides, +# specifies, contains, represents, a, an and the. + +ABBREVIATE_BRIEF = "The $name class" \ + "The $name widget" \ + "The $name file" \ + is \ + provides \ + specifies \ + contains \ + represents \ + a \ + an \ + the + +# If the ALWAYS_DETAILED_SEC and REPEAT_BRIEF tags are both set to YES then +# doxygen will generate a detailed section even if there is only a brief +# description. +# The default value is: NO. + +ALWAYS_DETAILED_SEC = NO + +# If the INLINE_INHERITED_MEMB tag is set to YES, doxygen will show all +# inherited members of a class in the documentation of that class as if those +# members were ordinary class members. Constructors, destructors and assignment +# operators of the base classes will not be shown. +# The default value is: NO. + +INLINE_INHERITED_MEMB = NO + +# If the FULL_PATH_NAMES tag is set to YES, doxygen will prepend the full path +# before files name in the file list and in the header files. If set to NO the +# shortest path that makes the file name unique will be used +# The default value is: YES. + +FULL_PATH_NAMES = NO + +# The STRIP_FROM_PATH tag can be used to strip a user-defined part of the path. +# Stripping is only done if one of the specified strings matches the left-hand +# part of the path. The tag can be used to show relative paths in the file list. +# If left blank the directory from which doxygen is run is used as the path to +# strip. +# +# Note that you can specify absolute paths here, but also relative paths, which +# will be relative from the directory where doxygen is started. +# This tag requires that the tag FULL_PATH_NAMES is set to YES. + +STRIP_FROM_PATH = + +# The STRIP_FROM_INC_PATH tag can be used to strip a user-defined part of the +# path mentioned in the documentation of a class, which tells the reader which +# header file to include in order to use a class. If left blank only the name of +# the header file containing the class definition is used. Otherwise one should +# specify the list of include paths that are normally passed to the compiler +# using the -I flag. + +STRIP_FROM_INC_PATH = + +# If the SHORT_NAMES tag is set to YES, doxygen will generate much shorter (but +# less readable) file names. This can be useful is your file systems doesn't +# support long names like on DOS, Mac, or CD-ROM. +# The default value is: NO. + +SHORT_NAMES = NO + +# If the JAVADOC_AUTOBRIEF tag is set to YES then doxygen will interpret the +# first line (until the first dot) of a Javadoc-style comment as the brief +# description. If set to NO, the Javadoc-style will behave just like regular Qt- +# style comments (thus requiring an explicit @brief command for a brief +# description.) +# The default value is: NO. + +JAVADOC_AUTOBRIEF = NO + +# If the QT_AUTOBRIEF tag is set to YES then doxygen will interpret the first +# line (until the first dot) of a Qt-style comment as the brief description. If +# set to NO, the Qt-style will behave just like regular Qt-style comments (thus +# requiring an explicit \brief command for a brief description.) +# The default value is: NO. + +QT_AUTOBRIEF = NO + +# The MULTILINE_CPP_IS_BRIEF tag can be set to YES to make doxygen treat a +# multi-line C++ special comment block (i.e. a block of //! or /// comments) as +# a brief description. This used to be the default behavior. The new default is +# to treat a multi-line C++ comment block as a detailed description. Set this +# tag to YES if you prefer the old behavior instead. +# +# Note that setting this tag to YES also means that rational rose comments are +# not recognized any more. +# The default value is: NO. + +MULTILINE_CPP_IS_BRIEF = NO + +# If the INHERIT_DOCS tag is set to YES then an undocumented member inherits the +# documentation from any documented member that it re-implements. +# The default value is: YES. + +INHERIT_DOCS = YES + +# If the SEPARATE_MEMBER_PAGES tag is set to YES then doxygen will produce a new +# page for each member. If set to NO, the documentation of a member will be part +# of the file/class/namespace that contains it. +# The default value is: NO. + +SEPARATE_MEMBER_PAGES = NO + +# The TAB_SIZE tag can be used to set the number of spaces in a tab. Doxygen +# uses this value to replace tabs by spaces in code fragments. +# Minimum value: 1, maximum value: 16, default value: 4. + +TAB_SIZE = 4 + +# This tag can be used to specify a number of aliases that act as commands in +# the documentation. An alias has the form: +# name=value +# For example adding +# "sideeffect=@par Side Effects:\n" +# will allow you to put the command \sideeffect (or @sideeffect) in the +# documentation, which will result in a user-defined paragraph with heading +# "Side Effects:". You can put \n's in the value part of an alias to insert +# newlines (in the resulting output). You can put ^^ in the value part of an +# alias to insert a newline as if a physical newline was in the original file. +# When you need a literal { or } or , in the value part of an alias you have to +# escape them by means of a backslash (\), this can lead to conflicts with the +# commands \{ and \} for these it is advised to use the version @{ and @} or use +# a double escape (\\{ and \\}) + +ALIASES = + +# This tag can be used to specify a number of word-keyword mappings (TCL only). +# A mapping has the form "name=value". For example adding "class=itcl::class" +# will allow you to use the command class in the itcl::class meaning. + +TCL_SUBST = + +# Set the OPTIMIZE_OUTPUT_FOR_C tag to YES if your project consists of C sources +# only. Doxygen will then generate output that is more tailored for C. For +# instance, some of the names that are used will be different. The list of all +# members will be omitted, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_FOR_C = NO + +# Set the OPTIMIZE_OUTPUT_JAVA tag to YES if your project consists of Java or +# Python sources only. Doxygen will then generate output that is more tailored +# for that language. For instance, namespaces will be presented as packages, +# qualified scopes will look different, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_JAVA = NO + +# Set the OPTIMIZE_FOR_FORTRAN tag to YES if your project consists of Fortran +# sources. Doxygen will then generate output that is tailored for Fortran. +# The default value is: NO. + +OPTIMIZE_FOR_FORTRAN = NO + +# Set the OPTIMIZE_OUTPUT_VHDL tag to YES if your project consists of VHDL +# sources. Doxygen will then generate output that is tailored for VHDL. +# The default value is: NO. + +OPTIMIZE_OUTPUT_VHDL = NO + +# Set the OPTIMIZE_OUTPUT_SLICE tag to YES if your project consists of Slice +# sources only. Doxygen will then generate output that is more tailored for that +# language. For instance, namespaces will be presented as modules, types will be +# separated into more groups, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_SLICE = NO + +# Doxygen selects the parser to use depending on the extension of the files it +# parses. With this tag you can assign which parser to use for a given +# extension. Doxygen has a built-in mapping, but you can override or extend it +# using this tag. The format is ext=language, where ext is a file extension, and +# language is one of the parsers supported by doxygen: IDL, Java, Javascript, +# Csharp (C#), C, C++, D, PHP, md (Markdown), Objective-C, Python, Slice, +# Fortran (fixed format Fortran: FortranFixed, free formatted Fortran: +# FortranFree, unknown formatted Fortran: Fortran. In the later case the parser +# tries to guess whether the code is fixed or free formatted code, this is the +# default for Fortran type files), VHDL, tcl. For instance to make doxygen treat +# .inc files as Fortran files (default is PHP), and .f files as C (default is +# Fortran), use: inc=Fortran f=C. +# +# Note: For files without extension you can use no_extension as a placeholder. +# +# Note that for custom extensions you also need to set FILE_PATTERNS otherwise +# the files are not read by doxygen. + +EXTENSION_MAPPING = + +# If the MARKDOWN_SUPPORT tag is enabled then doxygen pre-processes all comments +# according to the Markdown format, which allows for more readable +# documentation. See https://daringfireball.net/projects/markdown/ for details. +# The output of markdown processing is further processed by doxygen, so you can +# mix doxygen, HTML, and XML commands with Markdown formatting. Disable only in +# case of backward compatibilities issues. +# The default value is: YES. + +MARKDOWN_SUPPORT = NO + +# When the TOC_INCLUDE_HEADINGS tag is set to a non-zero value, all headings up +# to that level are automatically included in the table of contents, even if +# they do not have an id attribute. +# Note: This feature currently applies only to Markdown headings. +# Minimum value: 0, maximum value: 99, default value: 0. +# This tag requires that the tag MARKDOWN_SUPPORT is set to YES. + +TOC_INCLUDE_HEADINGS = 0 + +# When enabled doxygen tries to link words that correspond to documented +# classes, or namespaces to their corresponding documentation. Such a link can +# be prevented in individual cases by putting a % sign in front of the word or +# globally by setting AUTOLINK_SUPPORT to NO. +# The default value is: YES. + +AUTOLINK_SUPPORT = YES + +# If you use STL classes (i.e. std::string, std::vector, etc.) but do not want +# to include (a tag file for) the STL sources as input, then you should set this +# tag to YES in order to let doxygen match functions declarations and +# definitions whose arguments contain STL classes (e.g. func(std::string); +# versus func(std::string) {}). This also make the inheritance and collaboration +# diagrams that involve STL classes more complete and accurate. +# The default value is: NO. + +BUILTIN_STL_SUPPORT = NO + +# If you use Microsoft's C++/CLI language, you should set this option to YES to +# enable parsing support. +# The default value is: NO. + +CPP_CLI_SUPPORT = NO + +# Set the SIP_SUPPORT tag to YES if your project consists of sip (see: +# https://www.riverbankcomputing.com/software/sip/intro) sources only. Doxygen +# will parse them like normal C++ but will assume all classes use public instead +# of private inheritance when no explicit protection keyword is present. +# The default value is: NO. + +SIP_SUPPORT = NO + +# For Microsoft's IDL there are propget and propput attributes to indicate +# getter and setter methods for a property. Setting this option to YES will make +# doxygen to replace the get and set methods by a property in the documentation. +# This will only work if the methods are indeed getting or setting a simple +# type. If this is not the case, or you want to show the methods anyway, you +# should set this option to NO. +# The default value is: YES. + +IDL_PROPERTY_SUPPORT = YES + +# If member grouping is used in the documentation and the DISTRIBUTE_GROUP_DOC +# tag is set to YES then doxygen will reuse the documentation of the first +# member in the group (if any) for the other members of the group. By default +# all members of a group must be documented explicitly. +# The default value is: NO. + +DISTRIBUTE_GROUP_DOC = NO + +# If one adds a struct or class to a group and this option is enabled, then also +# any nested class or struct is added to the same group. By default this option +# is disabled and one has to add nested compounds explicitly via \ingroup. +# The default value is: NO. + +GROUP_NESTED_COMPOUNDS = NO + +# Set the SUBGROUPING tag to YES to allow class member groups of the same type +# (for instance a group of public functions) to be put as a subgroup of that +# type (e.g. under the Public Functions section). Set it to NO to prevent +# subgrouping. Alternatively, this can be done per class using the +# \nosubgrouping command. +# The default value is: YES. + +SUBGROUPING = YES + +# When the INLINE_GROUPED_CLASSES tag is set to YES, classes, structs and unions +# are shown inside the group in which they are included (e.g. using \ingroup) +# instead of on a separate page (for HTML and Man pages) or section (for LaTeX +# and RTF). +# +# Note that this feature does not work in combination with +# SEPARATE_MEMBER_PAGES. +# The default value is: NO. + +INLINE_GROUPED_CLASSES = NO + +# When the INLINE_SIMPLE_STRUCTS tag is set to YES, structs, classes, and unions +# with only public data fields or simple typedef fields will be shown inline in +# the documentation of the scope in which they are defined (i.e. file, +# namespace, or group documentation), provided this scope is documented. If set +# to NO, structs, classes, and unions are shown on a separate page (for HTML and +# Man pages) or section (for LaTeX and RTF). +# The default value is: NO. + +INLINE_SIMPLE_STRUCTS = NO + +# When TYPEDEF_HIDES_STRUCT tag is enabled, a typedef of a struct, union, or +# enum is documented as struct, union, or enum with the name of the typedef. So +# typedef struct TypeS {} TypeT, will appear in the documentation as a struct +# with name TypeT. When disabled the typedef will appear as a member of a file, +# namespace, or class. And the struct will be named TypeS. This can typically be +# useful for C code in case the coding convention dictates that all compound +# types are typedef'ed and only the typedef is referenced, never the tag name. +# The default value is: NO. + +TYPEDEF_HIDES_STRUCT = NO + +# The size of the symbol lookup cache can be set using LOOKUP_CACHE_SIZE. This +# cache is used to resolve symbols given their name and scope. Since this can be +# an expensive process and often the same symbol appears multiple times in the +# code, doxygen keeps a cache of pre-resolved symbols. If the cache is too small +# doxygen will become slower. If the cache is too large, memory is wasted. The +# cache size is given by this formula: 2^(16+LOOKUP_CACHE_SIZE). The valid range +# is 0..9, the default is 0, corresponding to a cache size of 2^16=65536 +# symbols. At the end of a run doxygen will report the cache usage and suggest +# the optimal cache size from a speed point of view. +# Minimum value: 0, maximum value: 9, default value: 0. + +LOOKUP_CACHE_SIZE = 0 + +#--------------------------------------------------------------------------- +# Build related configuration options +#--------------------------------------------------------------------------- + +# If the EXTRACT_ALL tag is set to YES, doxygen will assume all entities in +# documentation are documented, even if no documentation was available. Private +# class members and static file members will be hidden unless the +# EXTRACT_PRIVATE respectively EXTRACT_STATIC tags are set to YES. +# Note: This will also disable the warnings about undocumented members that are +# normally produced when WARNINGS is set to YES. +# The default value is: NO. + +EXTRACT_ALL = NO + +# If the EXTRACT_PRIVATE tag is set to YES, all private members of a class will +# be included in the documentation. +# The default value is: NO. + +EXTRACT_PRIVATE = NO + +# If the EXTRACT_PACKAGE tag is set to YES, all members with package or internal +# scope will be included in the documentation. +# The default value is: NO. + +EXTRACT_PACKAGE = NO + +# If the EXTRACT_STATIC tag is set to YES, all static members of a file will be +# included in the documentation. +# The default value is: NO. + +EXTRACT_STATIC = NO + +# If the EXTRACT_LOCAL_CLASSES tag is set to YES, classes (and structs) defined +# locally in source files will be included in the documentation. If set to NO, +# only classes defined in header files are included. Does not have any effect +# for Java sources. +# The default value is: YES. + +EXTRACT_LOCAL_CLASSES = YES + +# This flag is only useful for Objective-C code. If set to YES, local methods, +# which are defined in the implementation section but not in the interface are +# included in the documentation. If set to NO, only methods in the interface are +# included. +# The default value is: NO. + +EXTRACT_LOCAL_METHODS = NO + +# If this flag is set to YES, the members of anonymous namespaces will be +# extracted and appear in the documentation as a namespace called +# 'anonymous_namespace{file}', where file will be replaced with the base name of +# the file that contains the anonymous namespace. By default anonymous namespace +# are hidden. +# The default value is: NO. + +EXTRACT_ANON_NSPACES = NO + +# If the HIDE_UNDOC_MEMBERS tag is set to YES, doxygen will hide all +# undocumented members inside documented classes or files. If set to NO these +# members will be included in the various overviews, but no documentation +# section is generated. This option has no effect if EXTRACT_ALL is enabled. +# The default value is: NO. + +HIDE_UNDOC_MEMBERS = NO + +# If the HIDE_UNDOC_CLASSES tag is set to YES, doxygen will hide all +# undocumented classes that are normally visible in the class hierarchy. If set +# to NO, these classes will be included in the various overviews. This option +# has no effect if EXTRACT_ALL is enabled. +# The default value is: NO. + +HIDE_UNDOC_CLASSES = NO + +# If the HIDE_FRIEND_COMPOUNDS tag is set to YES, doxygen will hide all friend +# (class|struct|union) declarations. If set to NO, these declarations will be +# included in the documentation. +# The default value is: NO. + +HIDE_FRIEND_COMPOUNDS = NO + +# If the HIDE_IN_BODY_DOCS tag is set to YES, doxygen will hide any +# documentation blocks found inside the body of a function. If set to NO, these +# blocks will be appended to the function's detailed documentation block. +# The default value is: NO. + +HIDE_IN_BODY_DOCS = NO + +# The INTERNAL_DOCS tag determines if documentation that is typed after a +# \internal command is included. If the tag is set to NO then the documentation +# will be excluded. Set it to YES to include the internal documentation. +# The default value is: NO. + +INTERNAL_DOCS = NO + +# If the CASE_SENSE_NAMES tag is set to NO then doxygen will only generate file +# names in lower-case letters. If set to YES, upper-case letters are also +# allowed. This is useful if you have classes or files whose names only differ +# in case and if your file system supports case sensitive file names. Windows +# and Mac users are advised to set this option to NO. +# The default value is: system dependent. + +CASE_SENSE_NAMES = NO + +# If the HIDE_SCOPE_NAMES tag is set to NO then doxygen will show members with +# their full class and namespace scopes in the documentation. If set to YES, the +# scope will be hidden. +# The default value is: NO. + +HIDE_SCOPE_NAMES = YES + +# If the HIDE_COMPOUND_REFERENCE tag is set to NO (default) then doxygen will +# append additional text to a page's title, such as Class Reference. If set to +# YES the compound reference will be hidden. +# The default value is: NO. + +HIDE_COMPOUND_REFERENCE= NO + +# If the SHOW_INCLUDE_FILES tag is set to YES then doxygen will put a list of +# the files that are included by a file in the documentation of that file. +# The default value is: YES. + +SHOW_INCLUDE_FILES = YES + +# If the SHOW_GROUPED_MEMB_INC tag is set to YES then Doxygen will add for each +# grouped member an include statement to the documentation, telling the reader +# which file to include in order to use the member. +# The default value is: NO. + +SHOW_GROUPED_MEMB_INC = NO + +# If the FORCE_LOCAL_INCLUDES tag is set to YES then doxygen will list include +# files with double quotes in the documentation rather than with sharp brackets. +# The default value is: NO. + +FORCE_LOCAL_INCLUDES = NO + +# If the INLINE_INFO tag is set to YES then a tag [inline] is inserted in the +# documentation for inline members. +# The default value is: YES. + +INLINE_INFO = YES + +# If the SORT_MEMBER_DOCS tag is set to YES then doxygen will sort the +# (detailed) documentation of file and class members alphabetically by member +# name. If set to NO, the members will appear in declaration order. +# The default value is: YES. + +SORT_MEMBER_DOCS = YES + +# If the SORT_BRIEF_DOCS tag is set to YES then doxygen will sort the brief +# descriptions of file, namespace and class members alphabetically by member +# name. If set to NO, the members will appear in declaration order. Note that +# this will also influence the order of the classes in the class list. +# The default value is: NO. + +SORT_BRIEF_DOCS = NO + +# If the SORT_MEMBERS_CTORS_1ST tag is set to YES then doxygen will sort the +# (brief and detailed) documentation of class members so that constructors and +# destructors are listed first. If set to NO the constructors will appear in the +# respective orders defined by SORT_BRIEF_DOCS and SORT_MEMBER_DOCS. +# Note: If SORT_BRIEF_DOCS is set to NO this option is ignored for sorting brief +# member documentation. +# Note: If SORT_MEMBER_DOCS is set to NO this option is ignored for sorting +# detailed member documentation. +# The default value is: NO. + +SORT_MEMBERS_CTORS_1ST = NO + +# If the SORT_GROUP_NAMES tag is set to YES then doxygen will sort the hierarchy +# of group names into alphabetical order. If set to NO the group names will +# appear in their defined order. +# The default value is: NO. + +SORT_GROUP_NAMES = NO + +# If the SORT_BY_SCOPE_NAME tag is set to YES, the class list will be sorted by +# fully-qualified names, including namespaces. If set to NO, the class list will +# be sorted only by class name, not including the namespace part. +# Note: This option is not very useful if HIDE_SCOPE_NAMES is set to YES. +# Note: This option applies only to the class list, not to the alphabetical +# list. +# The default value is: NO. + +SORT_BY_SCOPE_NAME = NO + +# If the STRICT_PROTO_MATCHING option is enabled and doxygen fails to do proper +# type resolution of all parameters of a function it will reject a match between +# the prototype and the implementation of a member function even if there is +# only one candidate or it is obvious which candidate to choose by doing a +# simple string match. By disabling STRICT_PROTO_MATCHING doxygen will still +# accept a match between prototype and implementation in such cases. +# The default value is: NO. + +STRICT_PROTO_MATCHING = NO + +# The GENERATE_TODOLIST tag can be used to enable (YES) or disable (NO) the todo +# list. This list is created by putting \todo commands in the documentation. +# The default value is: YES. + +GENERATE_TODOLIST = YES + +# The GENERATE_TESTLIST tag can be used to enable (YES) or disable (NO) the test +# list. This list is created by putting \test commands in the documentation. +# The default value is: YES. + +GENERATE_TESTLIST = YES + +# The GENERATE_BUGLIST tag can be used to enable (YES) or disable (NO) the bug +# list. This list is created by putting \bug commands in the documentation. +# The default value is: YES. + +GENERATE_BUGLIST = YES + +# The GENERATE_DEPRECATEDLIST tag can be used to enable (YES) or disable (NO) +# the deprecated list. This list is created by putting \deprecated commands in +# the documentation. +# The default value is: YES. + +GENERATE_DEPRECATEDLIST= YES + +# The ENABLED_SECTIONS tag can be used to enable conditional documentation +# sections, marked by \if ... \endif and \cond +# ... \endcond blocks. + +ENABLED_SECTIONS = + +# The MAX_INITIALIZER_LINES tag determines the maximum number of lines that the +# initial value of a variable or macro / define can have for it to appear in the +# documentation. If the initializer consists of more lines than specified here +# it will be hidden. Use a value of 0 to hide initializers completely. The +# appearance of the value of individual variables and macros / defines can be +# controlled using \showinitializer or \hideinitializer command in the +# documentation regardless of this setting. +# Minimum value: 0, maximum value: 10000, default value: 30. + +MAX_INITIALIZER_LINES = 30 + +# Set the SHOW_USED_FILES tag to NO to disable the list of files generated at +# the bottom of the documentation of classes and structs. If set to YES, the +# list will mention the files that were used to generate the documentation. +# The default value is: YES. + +SHOW_USED_FILES = YES + +# Set the SHOW_FILES tag to NO to disable the generation of the Files page. This +# will remove the Files entry from the Quick Index and from the Folder Tree View +# (if specified). +# The default value is: YES. + +SHOW_FILES = YES + +# Set the SHOW_NAMESPACES tag to NO to disable the generation of the Namespaces +# page. This will remove the Namespaces entry from the Quick Index and from the +# Folder Tree View (if specified). +# The default value is: YES. + +SHOW_NAMESPACES = YES + +# The FILE_VERSION_FILTER tag can be used to specify a program or script that +# doxygen should invoke to get the current version for each file (typically from +# the version control system). Doxygen will invoke the program by executing (via +# popen()) the command command input-file, where command is the value of the +# FILE_VERSION_FILTER tag, and input-file is the name of an input file provided +# by doxygen. Whatever the program writes to standard output is used as the file +# version. For an example see the documentation. + +FILE_VERSION_FILTER = + +# The LAYOUT_FILE tag can be used to specify a layout file which will be parsed +# by doxygen. The layout file controls the global structure of the generated +# output files in an output format independent way. To create the layout file +# that represents doxygen's defaults, run doxygen with the -l option. You can +# optionally specify a file name after the option, if omitted DoxygenLayout.xml +# will be used as the name of the layout file. +# +# Note that if you run doxygen from a directory containing a file called +# DoxygenLayout.xml, doxygen will parse it automatically even if the LAYOUT_FILE +# tag is left empty. + +LAYOUT_FILE = + +# The CITE_BIB_FILES tag can be used to specify one or more bib files containing +# the reference definitions. This must be a list of .bib files. The .bib +# extension is automatically appended if omitted. This requires the bibtex tool +# to be installed. See also https://en.wikipedia.org/wiki/BibTeX for more info. +# For LaTeX the style of the bibliography can be controlled using +# LATEX_BIB_STYLE. To use this feature you need bibtex and perl available in the +# search path. See also \cite for info how to create references. + +CITE_BIB_FILES = + +#--------------------------------------------------------------------------- +# Configuration options related to warning and progress messages +#--------------------------------------------------------------------------- + +# The QUIET tag can be used to turn on/off the messages that are generated to +# standard output by doxygen. If QUIET is set to YES this implies that the +# messages are off. +# The default value is: NO. + +QUIET = NO + +# The WARNINGS tag can be used to turn on/off the warning messages that are +# generated to standard error (stderr) by doxygen. If WARNINGS is set to YES +# this implies that the warnings are on. +# +# Tip: Turn warnings on while writing the documentation. +# The default value is: YES. + +WARNINGS = YES + +# If the WARN_IF_UNDOCUMENTED tag is set to YES then doxygen will generate +# warnings for undocumented members. If EXTRACT_ALL is set to YES then this flag +# will automatically be disabled. +# The default value is: YES. + +WARN_IF_UNDOCUMENTED = NO + +# If the WARN_IF_DOC_ERROR tag is set to YES, doxygen will generate warnings for +# potential errors in the documentation, such as not documenting some parameters +# in a documented function, or documenting parameters that don't exist or using +# markup commands wrongly. +# The default value is: YES. + +WARN_IF_DOC_ERROR = YES + +# This WARN_NO_PARAMDOC option can be enabled to get warnings for functions that +# are documented, but have no documentation for their parameters or return +# value. If set to NO, doxygen will only warn about wrong or incomplete +# parameter documentation, but not about the absence of documentation. If +# EXTRACT_ALL is set to YES then this flag will automatically be disabled. +# The default value is: NO. + +WARN_NO_PARAMDOC = NO + +# If the WARN_AS_ERROR tag is set to YES then doxygen will immediately stop when +# a warning is encountered. +# The default value is: NO. + +WARN_AS_ERROR = NO + +# The WARN_FORMAT tag determines the format of the warning messages that doxygen +# can produce. The string should contain the $file, $line, and $text tags, which +# will be replaced by the file and line number from which the warning originated +# and the warning text. Optionally the format may contain $version, which will +# be replaced by the version of the file (if it could be obtained via +# FILE_VERSION_FILTER) +# The default value is: $file:$line: $text. + +WARN_FORMAT = "$file:$line: $text" + +# The WARN_LOGFILE tag can be used to specify a file to which warning and error +# messages should be written. If left blank the output is written to standard +# error (stderr). + +WARN_LOGFILE = + +#--------------------------------------------------------------------------- +# Configuration options related to the input files +#--------------------------------------------------------------------------- + +# The INPUT tag is used to specify the files and/or directories that contain +# documented source files. You may enter file names like myfile.cpp or +# directories like /usr/src/myproject. Separate the files or directories with +# spaces. See also FILE_PATTERNS and EXTENSION_MAPPING +# Note: If this tag is empty the current directory is searched. + +INPUT = "../ClosedXML" + +# This tag can be used to specify the character encoding of the source files +# that doxygen parses. Internally doxygen uses the UTF-8 encoding. Doxygen uses +# libiconv (or the iconv built into libc) for the transcoding. See the libiconv +# documentation (see: https://www.gnu.org/software/libiconv/) for the list of +# possible encodings. +# The default value is: UTF-8. + +INPUT_ENCODING = UTF-8 + +# If the value of the INPUT tag contains directories, you can use the +# FILE_PATTERNS tag to specify one or more wildcard patterns (like *.cpp and +# *.h) to filter out the source-files in the directories. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# read by doxygen. +# +# If left blank the following patterns are tested:*.c, *.cc, *.cxx, *.cpp, +# *.c++, *.java, *.ii, *.ixx, *.ipp, *.i++, *.inl, *.idl, *.ddl, *.odl, *.h, +# *.hh, *.hxx, *.hpp, *.h++, *.cs, *.d, *.php, *.php4, *.php5, *.phtml, *.inc, +# *.m, *.markdown, *.md, *.mm, *.dox, *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, +# *.f, *.for, *.tcl, *.vhd, *.vhdl, *.ucf, *.qsf and *.ice. + +FILE_PATTERNS = *.cs \ + +# The RECURSIVE tag can be used to specify whether or not subdirectories should +# be searched for input files as well. +# The default value is: NO. + +RECURSIVE = YES + +# The EXCLUDE tag can be used to specify files and/or directories that should be +# excluded from the INPUT source files. This way you can easily exclude a +# subdirectory from a directory tree whose root is specified with the INPUT tag. +# +# Note that relative paths are relative to the directory from which doxygen is +# run. + +EXCLUDE = + +# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or +# directories that are symbolic links (a Unix file system feature) are excluded +# from the input. +# The default value is: NO. + +EXCLUDE_SYMLINKS = NO + +# If the value of the INPUT tag contains directories, you can use the +# EXCLUDE_PATTERNS tag to specify one or more wildcard patterns to exclude +# certain files from those directories. +# +# Note that the wildcards are matched against the file with absolute path, so to +# exclude all test directories for example use the pattern */test/* + +EXCLUDE_PATTERNS = + +# The EXCLUDE_SYMBOLS tag can be used to specify one or more symbol names +# (namespaces, classes, functions, etc.) that should be excluded from the +# output. The symbol name can be a fully qualified name, a word, or if the +# wildcard * is used, a substring. Examples: ANamespace, AClass, +# AClass::ANamespace, ANamespace::*Test +# +# Note that the wildcards are matched against the file with absolute path, so to +# exclude all test directories use the pattern */test/* + +EXCLUDE_SYMBOLS = + +# The EXAMPLE_PATH tag can be used to specify one or more files or directories +# that contain example code fragments that are included (see the \include +# command). + +EXAMPLE_PATH = + +# If the value of the EXAMPLE_PATH tag contains directories, you can use the +# EXAMPLE_PATTERNS tag to specify one or more wildcard pattern (like *.cpp and +# *.h) to filter out the source-files in the directories. If left blank all +# files are included. + +EXAMPLE_PATTERNS = * + +# If the EXAMPLE_RECURSIVE tag is set to YES then subdirectories will be +# searched for input files to be used with the \include or \dontinclude commands +# irrespective of the value of the RECURSIVE tag. +# The default value is: NO. + +EXAMPLE_RECURSIVE = NO + +# The IMAGE_PATH tag can be used to specify one or more files or directories +# that contain images that are to be included in the documentation (see the +# \image command). + +IMAGE_PATH = + +# The INPUT_FILTER tag can be used to specify a program that doxygen should +# invoke to filter for each input file. Doxygen will invoke the filter program +# by executing (via popen()) the command: +# +# +# +# where is the value of the INPUT_FILTER tag, and is the +# name of an input file. Doxygen will then use the output that the filter +# program writes to standard output. If FILTER_PATTERNS is specified, this tag +# will be ignored. +# +# Note that the filter must not add or remove lines; it is applied before the +# code is scanned, but not when the output code is generated. If lines are added +# or removed, the anchors will not be placed correctly. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# properly processed by doxygen. + +INPUT_FILTER = + +# The FILTER_PATTERNS tag can be used to specify filters on a per file pattern +# basis. Doxygen will compare the file name with each pattern and apply the +# filter if there is a match. The filters are a list of the form: pattern=filter +# (like *.cpp=my_cpp_filter). See INPUT_FILTER for further information on how +# filters are used. If the FILTER_PATTERNS tag is empty or if none of the +# patterns match the file name, INPUT_FILTER is applied. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# properly processed by doxygen. + +FILTER_PATTERNS = + +# If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using +# INPUT_FILTER) will also be used to filter the input files that are used for +# producing the source files to browse (i.e. when SOURCE_BROWSER is set to YES). +# The default value is: NO. + +FILTER_SOURCE_FILES = NO + +# The FILTER_SOURCE_PATTERNS tag can be used to specify source filters per file +# pattern. A pattern will override the setting for FILTER_PATTERN (if any) and +# it is also possible to disable source filtering for a specific pattern using +# *.ext= (so without naming a filter). +# This tag requires that the tag FILTER_SOURCE_FILES is set to YES. + +FILTER_SOURCE_PATTERNS = + +# If the USE_MDFILE_AS_MAINPAGE tag refers to the name of a markdown file that +# is part of the input, its contents will be placed on the main page +# (index.html). This can be useful if you have a project on for instance GitHub +# and want to reuse the introduction page also for the doxygen output. + +USE_MDFILE_AS_MAINPAGE = + +#--------------------------------------------------------------------------- +# Configuration options related to source browsing +#--------------------------------------------------------------------------- + +# If the SOURCE_BROWSER tag is set to YES then a list of source files will be +# generated. Documented entities will be cross-referenced with these sources. +# +# Note: To get rid of all source code in the generated output, make sure that +# also VERBATIM_HEADERS is set to NO. +# The default value is: NO. + +SOURCE_BROWSER = NO + +# Setting the INLINE_SOURCES tag to YES will include the body of functions, +# classes and enums directly into the documentation. +# The default value is: NO. + +INLINE_SOURCES = NO + +# Setting the STRIP_CODE_COMMENTS tag to YES will instruct doxygen to hide any +# special comment blocks from generated source code fragments. Normal C, C++ and +# Fortran comments will always remain visible. +# The default value is: YES. + +STRIP_CODE_COMMENTS = YES + +# If the REFERENCED_BY_RELATION tag is set to YES then for each documented +# entity all documented functions referencing it will be listed. +# The default value is: NO. + +REFERENCED_BY_RELATION = NO + +# If the REFERENCES_RELATION tag is set to YES then for each documented function +# all documented entities called/used by that function will be listed. +# The default value is: NO. + +REFERENCES_RELATION = NO + +# If the REFERENCES_LINK_SOURCE tag is set to YES and SOURCE_BROWSER tag is set +# to YES then the hyperlinks from functions in REFERENCES_RELATION and +# REFERENCED_BY_RELATION lists will link to the source code. Otherwise they will +# link to the documentation. +# The default value is: YES. + +REFERENCES_LINK_SOURCE = YES + +# If SOURCE_TOOLTIPS is enabled (the default) then hovering a hyperlink in the +# source code will show a tooltip with additional information such as prototype, +# brief description and links to the definition and documentation. Since this +# will make the HTML file larger and loading of large files a bit slower, you +# can opt to disable this feature. +# The default value is: YES. +# This tag requires that the tag SOURCE_BROWSER is set to YES. + +SOURCE_TOOLTIPS = YES + +# If the USE_HTAGS tag is set to YES then the references to source code will +# point to the HTML generated by the htags(1) tool instead of doxygen built-in +# source browser. The htags tool is part of GNU's global source tagging system +# (see https://www.gnu.org/software/global/global.html). You will need version +# 4.8.6 or higher. +# +# To use it do the following: +# - Install the latest version of global +# - Enable SOURCE_BROWSER and USE_HTAGS in the configuration file +# - Make sure the INPUT points to the root of the source tree +# - Run doxygen as normal +# +# Doxygen will invoke htags (and that will in turn invoke gtags), so these +# tools must be available from the command line (i.e. in the search path). +# +# The result: instead of the source browser generated by doxygen, the links to +# source code will now point to the output of htags. +# The default value is: NO. +# This tag requires that the tag SOURCE_BROWSER is set to YES. + +USE_HTAGS = NO + +# If the VERBATIM_HEADERS tag is set the YES then doxygen will generate a +# verbatim copy of the header file for each class for which an include is +# specified. Set to NO to disable this. +# See also: Section \class. +# The default value is: YES. + +VERBATIM_HEADERS = YES + +# If the CLANG_ASSISTED_PARSING tag is set to YES then doxygen will use the +# clang parser (see: http://clang.llvm.org/) for more accurate parsing at the +# cost of reduced performance. This can be particularly helpful with template +# rich C++ code for which doxygen's built-in parser lacks the necessary type +# information. +# Note: The availability of this option depends on whether or not doxygen was +# generated with the -Duse_libclang=ON option for CMake. +# The default value is: NO. + +CLANG_ASSISTED_PARSING = NO + +# If clang assisted parsing is enabled you can provide the compiler with command +# line options that you would normally use when invoking the compiler. Note that +# the include paths will already be set by doxygen for the files and directories +# specified with INPUT and INCLUDE_PATH. +# This tag requires that the tag CLANG_ASSISTED_PARSING is set to YES. + +CLANG_OPTIONS = + +# If clang assisted parsing is enabled you can provide the clang parser with the +# path to the compilation database (see: +# http://clang.llvm.org/docs/HowToSetupToolingForLLVM.html) used when the files +# were built. This is equivalent to specifying the "-p" option to a clang tool, +# such as clang-check. These options will then be passed to the parser. +# Note: The availability of this option depends on whether or not doxygen was +# generated with the -Duse_libclang=ON option for CMake. + +CLANG_DATABASE_PATH = + +#--------------------------------------------------------------------------- +# Configuration options related to the alphabetical class index +#--------------------------------------------------------------------------- + +# If the ALPHABETICAL_INDEX tag is set to YES, an alphabetical index of all +# compounds will be generated. Enable this if the project contains a lot of +# classes, structs, unions or interfaces. +# The default value is: YES. + +ALPHABETICAL_INDEX = YES + +# The COLS_IN_ALPHA_INDEX tag can be used to specify the number of columns in +# which the alphabetical index list will be split. +# Minimum value: 1, maximum value: 20, default value: 5. +# This tag requires that the tag ALPHABETICAL_INDEX is set to YES. + +COLS_IN_ALPHA_INDEX = 5 + +# In case all classes in a project start with a common prefix, all classes will +# be put under the same header in the alphabetical index. The IGNORE_PREFIX tag +# can be used to specify a prefix (or a list of prefixes) that should be ignored +# while generating the index headers. +# This tag requires that the tag ALPHABETICAL_INDEX is set to YES. + +IGNORE_PREFIX = + +#--------------------------------------------------------------------------- +# Configuration options related to the HTML output +#--------------------------------------------------------------------------- + +# If the GENERATE_HTML tag is set to YES, doxygen will generate HTML output +# The default value is: YES. + +GENERATE_HTML = YES + +# The HTML_OUTPUT tag is used to specify where the HTML docs will be put. If a +# relative path is entered the value of OUTPUT_DIRECTORY will be put in front of +# it. +# The default directory is: html. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_OUTPUT = html + +# The HTML_FILE_EXTENSION tag can be used to specify the file extension for each +# generated HTML page (for example: .htm, .php, .asp). +# The default value is: .html. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FILE_EXTENSION = .html + +# The HTML_HEADER tag can be used to specify a user-defined HTML header file for +# each generated HTML page. If the tag is left blank doxygen will generate a +# standard header. +# +# To get valid HTML the header file that includes any scripts and style sheets +# that doxygen needs, which is dependent on the configuration options used (e.g. +# the setting GENERATE_TREEVIEW). It is highly recommended to start with a +# default header using +# doxygen -w html new_header.html new_footer.html new_stylesheet.css +# YourConfigFile +# and then modify the file new_header.html. See also section "Doxygen usage" +# for information on how to generate the default header that doxygen normally +# uses. +# Note: The header is subject to change so you typically have to regenerate the +# default header when upgrading to a newer version of doxygen. For a description +# of the possible markers and block names see the documentation. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_HEADER = + +# The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each +# generated HTML page. If the tag is left blank doxygen will generate a standard +# footer. See HTML_HEADER for more information on how to generate a default +# footer and what special commands can be used inside the footer. See also +# section "Doxygen usage" for information on how to generate the default footer +# that doxygen normally uses. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FOOTER = + +# The HTML_STYLESHEET tag can be used to specify a user-defined cascading style +# sheet that is used by each HTML page. It can be used to fine-tune the look of +# the HTML output. If left blank doxygen will generate a default style sheet. +# See also section "Doxygen usage" for information on how to generate the style +# sheet that doxygen normally uses. +# Note: It is recommended to use HTML_EXTRA_STYLESHEET instead of this tag, as +# it is more robust and this tag (HTML_STYLESHEET) will in the future become +# obsolete. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_STYLESHEET = + +# The HTML_EXTRA_STYLESHEET tag can be used to specify additional user-defined +# cascading style sheets that are included after the standard style sheets +# created by doxygen. Using this option one can overrule certain style aspects. +# This is preferred over using HTML_STYLESHEET since it does not replace the +# standard style sheet and is therefore more robust against future updates. +# Doxygen will copy the style sheet files to the output directory. +# Note: The order of the extra style sheet files is of importance (e.g. the last +# style sheet in the list overrules the setting of the previous ones in the +# list). For an example see the documentation. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_EXTRA_STYLESHEET = + +# The HTML_EXTRA_FILES tag can be used to specify one or more extra images or +# other source files which should be copied to the HTML output directory. Note +# that these files will be copied to the base HTML output directory. Use the +# $relpath^ marker in the HTML_HEADER and/or HTML_FOOTER files to load these +# files. In the HTML_STYLESHEET file, use the file name only. Also note that the +# files will be copied as-is; there are no commands or markers available. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_EXTRA_FILES = + +# The HTML_COLORSTYLE_HUE tag controls the color of the HTML output. Doxygen +# will adjust the colors in the style sheet and background images according to +# this color. Hue is specified as an angle on a colorwheel, see +# https://en.wikipedia.org/wiki/Hue for more information. For instance the value +# 0 represents red, 60 is yellow, 120 is green, 180 is cyan, 240 is blue, 300 +# purple, and 360 is red again. +# Minimum value: 0, maximum value: 359, default value: 220. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_HUE = 220 + +# The HTML_COLORSTYLE_SAT tag controls the purity (or saturation) of the colors +# in the HTML output. For a value of 0 the output will use grayscales only. A +# value of 255 will produce the most vivid colors. +# Minimum value: 0, maximum value: 255, default value: 100. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_SAT = 100 + +# The HTML_COLORSTYLE_GAMMA tag controls the gamma correction applied to the +# luminance component of the colors in the HTML output. Values below 100 +# gradually make the output lighter, whereas values above 100 make the output +# darker. The value divided by 100 is the actual gamma applied, so 80 represents +# a gamma of 0.8, The value 220 represents a gamma of 2.2, and 100 does not +# change the gamma. +# Minimum value: 40, maximum value: 240, default value: 80. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_GAMMA = 80 + +# If the HTML_TIMESTAMP tag is set to YES then the footer of each generated HTML +# page will contain the date and time when the page was generated. Setting this +# to YES can help to show when doxygen was last run and thus if the +# documentation is up to date. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_TIMESTAMP = NO + +# If the HTML_DYNAMIC_MENUS tag is set to YES then the generated HTML +# documentation will contain a main index with vertical navigation menus that +# are dynamically created via Javascript. If disabled, the navigation index will +# consists of multiple levels of tabs that are statically embedded in every HTML +# page. Disable this option to support browsers that do not have Javascript, +# like the Qt help browser. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_DYNAMIC_MENUS = YES + +# If the HTML_DYNAMIC_SECTIONS tag is set to YES then the generated HTML +# documentation will contain sections that can be hidden and shown after the +# page has loaded. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_DYNAMIC_SECTIONS = NO + +# With HTML_INDEX_NUM_ENTRIES one can control the preferred number of entries +# shown in the various tree structured indices initially; the user can expand +# and collapse entries dynamically later on. Doxygen will expand the tree to +# such a level that at most the specified number of entries are visible (unless +# a fully collapsed tree already exceeds this amount). So setting the number of +# entries 1 will produce a full collapsed tree by default. 0 is a special value +# representing an infinite number of entries and will result in a full expanded +# tree by default. +# Minimum value: 0, maximum value: 9999, default value: 100. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_INDEX_NUM_ENTRIES = 100 + +# If the GENERATE_DOCSET tag is set to YES, additional index files will be +# generated that can be used as input for Apple's Xcode 3 integrated development +# environment (see: https://developer.apple.com/xcode/), introduced with OSX +# 10.5 (Leopard). To create a documentation set, doxygen will generate a +# Makefile in the HTML output directory. Running make will produce the docset in +# that directory and running make install will install the docset in +# ~/Library/Developer/Shared/Documentation/DocSets so that Xcode will find it at +# startup. See https://developer.apple.com/library/archive/featuredarticles/Doxy +# genXcode/_index.html for more information. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_DOCSET = NO + +# This tag determines the name of the docset feed. A documentation feed provides +# an umbrella under which multiple documentation sets from a single provider +# (such as a company or product suite) can be grouped. +# The default value is: Doxygen generated docs. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_FEEDNAME = "Doxygen generated docs" + +# This tag specifies a string that should uniquely identify the documentation +# set bundle. This should be a reverse domain-name style string, e.g. +# com.mycompany.MyDocSet. Doxygen will append .docset to the name. +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_BUNDLE_ID = org.doxygen.Project + +# The DOCSET_PUBLISHER_ID tag specifies a string that should uniquely identify +# the documentation publisher. This should be a reverse domain-name style +# string, e.g. com.mycompany.MyDocSet.documentation. +# The default value is: org.doxygen.Publisher. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_PUBLISHER_ID = org.doxygen.Publisher + +# The DOCSET_PUBLISHER_NAME tag identifies the documentation publisher. +# The default value is: Publisher. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_PUBLISHER_NAME = Publisher + +# If the GENERATE_HTMLHELP tag is set to YES then doxygen generates three +# additional HTML index files: index.hhp, index.hhc, and index.hhk. The +# index.hhp is a project file that can be read by Microsoft's HTML Help Workshop +# (see: https://www.microsoft.com/en-us/download/details.aspx?id=21138) on +# Windows. +# +# The HTML Help Workshop contains a compiler that can convert all HTML output +# generated by doxygen into a single compiled HTML file (.chm). Compiled HTML +# files are now used as the Windows 98 help format, and will replace the old +# Windows help format (.hlp) on all Windows platforms in the future. Compressed +# HTML files also contain an index, a table of contents, and you can search for +# words in the documentation. The HTML workshop also contains a viewer for +# compressed HTML files. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_HTMLHELP = NO + +# The CHM_FILE tag can be used to specify the file name of the resulting .chm +# file. You can add a path in front of the file if the result should not be +# written to the html output directory. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +CHM_FILE = + +# The HHC_LOCATION tag can be used to specify the location (absolute path +# including file name) of the HTML help compiler (hhc.exe). If non-empty, +# doxygen will try to run the HTML help compiler on the generated index.hhp. +# The file has to be specified with full path. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +HHC_LOCATION = + +# The GENERATE_CHI flag controls if a separate .chi index file is generated +# (YES) or that it should be included in the master .chm file (NO). +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +GENERATE_CHI = NO + +# The CHM_INDEX_ENCODING is used to encode HtmlHelp index (hhk), content (hhc) +# and project file content. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +CHM_INDEX_ENCODING = + +# The BINARY_TOC flag controls whether a binary table of contents is generated +# (YES) or a normal table of contents (NO) in the .chm file. Furthermore it +# enables the Previous and Next buttons. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +BINARY_TOC = NO + +# The TOC_EXPAND flag can be set to YES to add extra items for group members to +# the table of contents of the HTML help documentation and to the tree view. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +TOC_EXPAND = NO + +# If the GENERATE_QHP tag is set to YES and both QHP_NAMESPACE and +# QHP_VIRTUAL_FOLDER are set, an additional index file will be generated that +# can be used as input for Qt's qhelpgenerator to generate a Qt Compressed Help +# (.qch) of the generated HTML documentation. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_QHP = NO + +# If the QHG_LOCATION tag is specified, the QCH_FILE tag can be used to specify +# the file name of the resulting .qch file. The path specified is relative to +# the HTML output folder. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QCH_FILE = + +# The QHP_NAMESPACE tag specifies the namespace to use when generating Qt Help +# Project output. For more information please see Qt Help Project / Namespace +# (see: http://doc.qt.io/archives/qt-4.8/qthelpproject.html#namespace). +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_NAMESPACE = org.doxygen.Project + +# The QHP_VIRTUAL_FOLDER tag specifies the namespace to use when generating Qt +# Help Project output. For more information please see Qt Help Project / Virtual +# Folders (see: http://doc.qt.io/archives/qt-4.8/qthelpproject.html#virtual- +# folders). +# The default value is: doc. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_VIRTUAL_FOLDER = doc + +# If the QHP_CUST_FILTER_NAME tag is set, it specifies the name of a custom +# filter to add. For more information please see Qt Help Project / Custom +# Filters (see: http://doc.qt.io/archives/qt-4.8/qthelpproject.html#custom- +# filters). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_CUST_FILTER_NAME = + +# The QHP_CUST_FILTER_ATTRS tag specifies the list of the attributes of the +# custom filter to add. For more information please see Qt Help Project / Custom +# Filters (see: http://doc.qt.io/archives/qt-4.8/qthelpproject.html#custom- +# filters). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_CUST_FILTER_ATTRS = + +# The QHP_SECT_FILTER_ATTRS tag specifies the list of the attributes this +# project's filter section matches. Qt Help Project / Filter Attributes (see: +# http://doc.qt.io/archives/qt-4.8/qthelpproject.html#filter-attributes). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_SECT_FILTER_ATTRS = + +# The QHG_LOCATION tag can be used to specify the location of Qt's +# qhelpgenerator. If non-empty doxygen will try to run qhelpgenerator on the +# generated .qhp file. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHG_LOCATION = + +# If the GENERATE_ECLIPSEHELP tag is set to YES, additional index files will be +# generated, together with the HTML files, they form an Eclipse help plugin. To +# install this plugin and make it available under the help contents menu in +# Eclipse, the contents of the directory containing the HTML and XML files needs +# to be copied into the plugins directory of eclipse. The name of the directory +# within the plugins directory should be the same as the ECLIPSE_DOC_ID value. +# After copying Eclipse needs to be restarted before the help appears. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_ECLIPSEHELP = NO + +# A unique identifier for the Eclipse help plugin. When installing the plugin +# the directory name containing the HTML and XML files should also have this +# name. Each documentation set should have its own identifier. +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_ECLIPSEHELP is set to YES. + +ECLIPSE_DOC_ID = org.doxygen.Project + +# If you want full control over the layout of the generated HTML pages it might +# be necessary to disable the index and replace it with your own. The +# DISABLE_INDEX tag can be used to turn on/off the condensed index (tabs) at top +# of each HTML page. A value of NO enables the index and the value YES disables +# it. Since the tabs in the index contain the same information as the navigation +# tree, you can set this option to YES if you also set GENERATE_TREEVIEW to YES. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +DISABLE_INDEX = NO + +# The GENERATE_TREEVIEW tag is used to specify whether a tree-like index +# structure should be generated to display hierarchical information. If the tag +# value is set to YES, a side panel will be generated containing a tree-like +# index structure (just like the one that is generated for HTML Help). For this +# to work a browser that supports JavaScript, DHTML, CSS and frames is required +# (i.e. any modern browser). Windows users are probably better off using the +# HTML help feature. Via custom style sheets (see HTML_EXTRA_STYLESHEET) one can +# further fine-tune the look of the index. As an example, the default style +# sheet generated by doxygen has an example that shows how to put an image at +# the root of the tree instead of the PROJECT_NAME. Since the tree basically has +# the same information as the tab index, you could consider setting +# DISABLE_INDEX to YES when enabling this option. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_TREEVIEW = NO + +# The ENUM_VALUES_PER_LINE tag can be used to set the number of enum values that +# doxygen will group on one line in the generated HTML documentation. +# +# Note that a value of 0 will completely suppress the enum values from appearing +# in the overview section. +# Minimum value: 0, maximum value: 20, default value: 4. +# This tag requires that the tag GENERATE_HTML is set to YES. + +ENUM_VALUES_PER_LINE = 4 + +# If the treeview is enabled (see GENERATE_TREEVIEW) then this tag can be used +# to set the initial width (in pixels) of the frame in which the tree is shown. +# Minimum value: 0, maximum value: 1500, default value: 250. +# This tag requires that the tag GENERATE_HTML is set to YES. + +TREEVIEW_WIDTH = 250 + +# If the EXT_LINKS_IN_WINDOW option is set to YES, doxygen will open links to +# external symbols imported via tag files in a separate window. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +EXT_LINKS_IN_WINDOW = NO + +# Use this tag to change the font size of LaTeX formulas included as images in +# the HTML documentation. When you change the font size after a successful +# doxygen run you need to manually remove any form_*.png images from the HTML +# output directory to force them to be regenerated. +# Minimum value: 8, maximum value: 50, default value: 10. +# This tag requires that the tag GENERATE_HTML is set to YES. + +FORMULA_FONTSIZE = 10 + +# Use the FORMULA_TRANSPARENT tag to determine whether or not the images +# generated for formulas are transparent PNGs. Transparent PNGs are not +# supported properly for IE 6.0, but are supported on all modern browsers. +# +# Note that when changing this option you need to delete any form_*.png files in +# the HTML output directory before the changes have effect. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +FORMULA_TRANSPARENT = YES + +# Enable the USE_MATHJAX option to render LaTeX formulas using MathJax (see +# https://www.mathjax.org) which uses client side Javascript for the rendering +# instead of using pre-rendered bitmaps. Use this if you do not have LaTeX +# installed or if you want to formulas look prettier in the HTML output. When +# enabled you may also need to install MathJax separately and configure the path +# to it using the MATHJAX_RELPATH option. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +USE_MATHJAX = NO + +# When MathJax is enabled you can set the default output format to be used for +# the MathJax output. See the MathJax site (see: +# http://docs.mathjax.org/en/latest/output.html) for more details. +# Possible values are: HTML-CSS (which is slower, but has the best +# compatibility), NativeMML (i.e. MathML) and SVG. +# The default value is: HTML-CSS. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_FORMAT = HTML-CSS + +# When MathJax is enabled you need to specify the location relative to the HTML +# output directory using the MATHJAX_RELPATH option. The destination directory +# should contain the MathJax.js script. For instance, if the mathjax directory +# is located at the same level as the HTML output directory, then +# MATHJAX_RELPATH should be ../mathjax. The default value points to the MathJax +# Content Delivery Network so you can quickly see the result without installing +# MathJax. However, it is strongly recommended to install a local copy of +# MathJax from https://www.mathjax.org before deployment. +# The default value is: https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_RELPATH = https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/ + +# The MATHJAX_EXTENSIONS tag can be used to specify one or more MathJax +# extension names that should be enabled during MathJax rendering. For example +# MATHJAX_EXTENSIONS = TeX/AMSmath TeX/AMSsymbols +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_EXTENSIONS = + +# The MATHJAX_CODEFILE tag can be used to specify a file with javascript pieces +# of code that will be used on startup of the MathJax code. See the MathJax site +# (see: http://docs.mathjax.org/en/latest/output.html) for more details. For an +# example see the documentation. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_CODEFILE = + +# When the SEARCHENGINE tag is enabled doxygen will generate a search box for +# the HTML output. The underlying search engine uses javascript and DHTML and +# should work on any modern browser. Note that when using HTML help +# (GENERATE_HTMLHELP), Qt help (GENERATE_QHP), or docsets (GENERATE_DOCSET) +# there is already a search function so this one should typically be disabled. +# For large projects the javascript based search engine can be slow, then +# enabling SERVER_BASED_SEARCH may provide a better solution. It is possible to +# search using the keyboard; to jump to the search box use + S +# (what the is depends on the OS and browser, but it is typically +# , /