diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 8e3293e..dd9b828 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -19,10 +19,14 @@ jobs: with: nuget-projects: >- SW.ExportToExcel/SW.ExportToExcel.csproj - major-version: '8' + major-version: '10' minor-version: '0' - dotnet-version: '8.0.x' - run-tests: false + # Both SDKs: the package and its tests target net10.0 and net8.0, and the + # net8.0 tests need the 8.0 runtime on the runner. + dotnet-version: | + 8.0.x + 10.0.x + run-tests: true test-projects: '**/*UnitTests/*.csproj' secrets: nuget-api-key: ${{ secrets.SWNUGETKEY }} diff --git a/README.md b/README.md index 76a42d3..8e1a4ac 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,149 @@ - [![GitHub Actions](https://github.com/simplify9/SW-ExportToExcel/actions/workflows/nuget-publish.yml/badge.svg)](https://github.com/simplify9/SW-ExportToExcel/actions/workflows/nuget-publish.yml) [![NuGet](https://img.shields.io/nuget/v/SimplyWorks.ExportToExcel?style=for-the-badge)](https://www.nuget.org/packages/SimplyWorks.ExportToExcel/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE) -| **Package** | **Version** | -| :----------------:|:----------------------:| -| SimplyWorks.ExportToExcel | [![NuGet](https://img.shields.io/nuget/v/SimplyWorks.ExportToExcel?style=for-the-badge)](https://www.nuget.org/packages/SimplyWorks.ExportToExcel/) | +# SimplyWorks.ExportToExcel + +Extension methods that write any `IEnumerable` (or `IAsyncEnumerable`) to an `.xlsx` file. + +- Rows are streamed straight into the file, so memory stays flat no matter how many rows you export. +- Targets .NET 10 and .NET 8. + +```bash +dotnet add package SimplyWorks.ExportToExcel +``` + +## Quick start + +```csharp +using SW.ExportToExcel; + +using var stream = new MemoryStream(); +await orders.WriteExcelAsync(stream); +``` + +This writes every readable property of a supported type, in declaration order, with the property name as the header. To choose the columns, rename them or put them in a different order, pass a map from property name to header: + +```csharp +await orders.WriteExcelAsync(stream, new Dictionary +{ + ["Total"] = "Total (€)", + ["Customer"] = "Customer", + ["PlacedOn"] = "Date", +}); +``` + +Columns come out in the order you list them. Names that don't match a property are skipped. + +### Computed columns + +```csharp +await orders.WriteExcelAsync(stream, new[] +{ + new ExcelColumn("Customer", o => o.Customer.Name), + new ExcelColumn("City", o => o.Customer.Address.City), + new ExcelColumn("Total incl. VAT", o => o.Total * 1.2m) { Width = 18 }, +}); +``` + +### Dictionary and dynamic rows + +Rows of type `IDictionary` (including `ExpandoObject`) or `IDictionary` are exported by key. + +- **Headers:** the keys found in the first rows that are read ahead (100 by default; see `AutoFitSampleRows` below). +- **Later rows:** a key a row doesn't have leaves its cell empty. +- **Choosing columns:** pass a column map to set which keys appear and in what order. + +```csharp +var rows = new List> +{ + new() { ["Sku"] = "A-1", ["Qty"] = 3 }, + new() { ["Sku"] = "B-2", ["Qty"] = 1, ["Note"] = "fragile" }, +}; +await rows.WriteExcelAsync(stream); +``` + +### Streaming from a database + +Pass an `IAsyncEnumerable`, such as EF Core's `AsAsyncEnumerable()`, and rows are written as they arrive, without loading the whole result first: + +```csharp +await db.Orders.Where(o => o.Year == 2026).AsAsyncEnumerable() + .WriteExcelAsync(stream, cancellationToken: ct); +``` + +## Cell types + +| Value | Excel cell | +| --- | --- | +| `int`, `long`, `decimal`, `double` and the other numeric types | number | +| `bool` | `TRUE` / `FALSE` | +| `DateTime`, `DateTimeOffset` | date and time (a `DateTimeOffset` keeps its own clock time) | +| `DateOnly` | date | +| `string`, `char`, `Guid`, enums (by name), `TimeSpan`, `TimeOnly` | text | +| `null`, empty string, dates before year 100 | empty cell | +| `NaN`, `Infinity` | the text `NaN` / `Infinity` | + +Properties of any other type, such as nested objects and collections, are skipped. Computed columns can return anything; values that aren't listed above are written as text. + +Text is cleaned so that it always produces a file Excel can open: characters that XML can't carry (control characters, unpaired surrogates) are removed, and text over Excel's 32,767-character cell limit is truncated. + +## Options + +```csharp +await orders.WriteExcelAsync(stream, new ExcelExportOptions +{ + SheetName = "Orders", // invalid characters become '_', truncated to 31 + FreezeHeaderRow = true, // default true + BoldHeader = true, // default true + AutoFilter = true, // filter drop-downs on the header, default false + AutoFitColumns = true, // size columns from the header and the first rows, default true + AutoFitSampleRows = 100, // rows read ahead for sizing and dictionary keys + MaxColumnWidth = 60, + DateTimeFormat = "yyyy-mm-dd hh:mm", // default: Excel's built-in short date and time + DateFormat = "yyyy-mm-dd", // for DateOnly; default: Excel's built-in short date +}); +``` + +## Writing to a web response + +The file is written synchronously. ASP.NET Core rejects synchronous writes to a response body, so write to a `MemoryStream` (or a file) first, then return that: + +```csharp +using var stream = new MemoryStream(); +await rows.WriteExcelAsync(stream); +return File(stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "export.xlsx"); +``` + +## The original API +`WriteExcel(...)`, `ExportToExcel(...)` and `GenerateExcel(...)` are unchanged and produce the same sheets as before: +- Columns always follow the class's property declaration order. A column map only selects and renames columns; it doesn't reorder them. +- Booleans are written as the text `true` / `false`. +- Only `string`, `bool`, `DateTime` and numeric properties are exported. +- Every cell is written, including empty ones. -## Introduction -*ExportToExcel* is a library that provides extensions to [IEnumerable Interface](https://docs.microsoft.com/en-us/dotnet/api/system.collections.ienumerable?view=netcore-3.1) extensions. +The original methods run on the same streaming writer, so they get the same speed and memory gains and the same fixes: -## Getting Started -*ExportToExcel* is available as a package on [NuGet](https://www.nuget.org/packages/SimplyWorks.ExportToExcel/). +- Characters XML can't carry, `NaN` and `Infinity`, over-long text, and types with nothing to export no longer break the export or the file. +- The header row is now actually frozen; in earlier versions the freeze setting was misplaced and Excel ignored it. +- The first column is no longer forced to a width of 4. +- Dictionary rows are matched by key instead of by position. +- An empty dictionary list gives an empty sheet instead of throwing. -To use *ExportToExcel*, you will require the [`Documentformat.OpenXml`](https://www.nuget.org/packages/DocumentFormat.OpenXml/) package. +To get ordered columns and typed cells, switch to `WriteExcelAsync`. The two differ only as described above. -## Functions Available -1. *ExportToExcel* +## Limits -ExportToExcel has 3 overloads. You can pass it an enumerable of data, where the column names are generated from TEntity's properties. One where you can pass both an enumerable of data and an enumerable of string, where the column names will be taken from this enumerable. And finally, one where you can pass it a dictionary that will be used to generate the column names. +An Excel sheet holds at most 1,048,576 rows and 16,384 columns. Larger exports are still written, but Excel won't open them; other readers may. -2. *WriteExcel* +## Upgrading from 8.x / 2.x -WriteExcel has 6 overloads, taking in column names from a dictionary, as well as some data in an IEnumberable array. It also takes in a file stream to start writing onto that very file with the data it's collected. +- **Frameworks:** the package now targets `net10.0` and `net8.0`. Projects on older frameworks can stay on 2.0.5 (`netstandard2.0`). +- **OpenXml 3:** the library no longer uses `DocumentFormat.OpenXml` itself, but still depends on it, now at 3.x instead of 2.13, so projects that get it through this package keep compiling. If you use OpenXml's API directly, check its [3.0 breaking changes](https://github.com/dotnet/Open-XML-SDK/blob/main/CHANGELOG.md). +- **Output changes:** the original methods' output changes only where it was broken before, as listed under [The original API](#the-original-api). ## Getting support 👷 -If you encounter any bugs, don't hesitate to submit an [issue](https://github.com/simplify9/ExportToExcel/issues). We'll get back to you promptly! +If you encounter any bugs, don't hesitate to submit an [issue](https://github.com/simplify9/SW-ExportToExcel/issues). We'll get back to you promptly! diff --git a/SW.ExportToExcel.UnitTests/ExcelExportTests.cs b/SW.ExportToExcel.UnitTests/ExcelExportTests.cs new file mode 100644 index 0000000..e453816 --- /dev/null +++ b/SW.ExportToExcel.UnitTests/ExcelExportTests.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using DocumentFormat.OpenXml.Spreadsheet; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using XlsxWriter = SW.ExportToExcel.Internal.XlsxWriter; + +namespace SW.ExportToExcel.UnitTests +{ + [TestClass] + public class ExcelExportTests + { + private static readonly DateTime When = new DateTime(2026, 10, 12, 9, 30, 0); + + private static async Task Write(Func write) + { + using var stream = new MemoryStream(); + await write(stream); + return new Sheet(stream.ToArray()); + } + + [TestMethod] + public async Task Columns_come_out_in_the_order_given() + { + var sheet = await Write(s => new[] { new Order { Id = 1, Customer = "c", Total = 9.5m } }.WriteExcelAsync(s, + new Dictionary { ["Total"] = "Total €", ["Customer"] = "Customer", ["Nope"] = "x" })); + + CollectionAssert.AreEqual(new[] { "Total €", "Customer" }, sheet.Row(1, 2)); + CollectionAssert.AreEqual(new[] { "9.5", "c" }, sheet.Row(2, 2)); + Assert.IsFalse(sheet.Has("C1")); + } + + [TestMethod] + public async Task All_supported_properties_by_default_with_typed_cells() + { + var sheet = await Write(s => new[] + { + new Order { Id = 1, Customer = "c", Paid = true, Placed = When, Due = new DateOnly(2026, 11, 1), Status = DayOfWeek.Friday, Ref = Guid.Empty }, + }.WriteExcelAsync(s)); + + CollectionAssert.AreEqual(new[] { "Id", "Customer", "Total", "Paid", "Placed", "Due", "Status", "Ref", "Note" }, sheet.Row(1, 9)); + Assert.AreEqual("b", sheet.Type("D2")); + Assert.AreEqual("1", sheet.Text("D2")); + Assert.AreEqual(22u, sheet.NumberFormat("E2")); + Assert.AreEqual(14u, sheet.NumberFormat("F2")); + Assert.AreEqual(new DateTime(2026, 11, 1).ToOADate().ToString(System.Globalization.CultureInfo.InvariantCulture), sheet.Text("F2")); + Assert.AreEqual("Friday", sheet.Text("G2")); + Assert.AreEqual(Guid.Empty.ToString(), sheet.Text("H2")); + Assert.IsFalse(sheet.Has("J1"), "the List property is not exportable"); + } + + [TestMethod] + public async Task Empty_values_leave_the_cell_out_and_later_cells_keep_their_column() + { + var sheet = await Write(s => new[] { new Order { Id = 3, Customer = null, Note = "n" } }.WriteExcelAsync(s)); + + Assert.IsFalse(sheet.Has("B2")); + Assert.IsFalse(sheet.Has("E2")); + Assert.AreEqual("n", sheet.Text("I2")); + Assert.AreEqual("3", sheet.Text("A2")); + } + + [TestMethod] + public async Task Computed_columns() + { + var sheet = await Write(s => new[] { new Order { Id = 4, Customer = "acme", Total = 10m } }.WriteExcelAsync(s, new[] + { + new ExcelColumn("Upper", o => o.Customer.ToUpperInvariant()), + new ExcelColumn("With tax", o => o.Total * 1.2m) { Width = 30 } + })); + + CollectionAssert.AreEqual(new[] { "ACME", "12.0" }, sheet.Row(2, 2)); + var widths = sheet.Worksheet.Descendants().ToList(); + Assert.AreEqual(30d, widths.Single(c => c.Min == 2).Width.Value); + } + + [TestMethod] + public async Task Dictionary_and_expando_rows() + { + var rows = new List> + { + new Dictionary { ["a"] = 1, ["b"] = "x" }, + new Dictionary { ["b"] = "y", ["c"] = true } + }; + var sheet = await Write(s => rows.WriteExcelAsync(s)); + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, sheet.Row(1, 3)); + CollectionAssert.AreEqual(new[] { null, "y", "1" }, sheet.Row(3, 3)); + + dynamic expando = new ExpandoObject(); + expando.Name = "dyn"; + expando.Count = 2; + var dynamicSheet = await Write(s => new List { expando }.WriteExcelAsync(s, + new Dictionary { ["Count"] = "How many", ["Name"] = "Name" })); + CollectionAssert.AreEqual(new[] { "How many", "Name" }, dynamicSheet.Row(1, 2)); + CollectionAssert.AreEqual(new[] { "2", "dyn" }, dynamicSheet.Row(2, 2)); + } + + [TestMethod] + public async Task Async_enumerable_source() + { + var sheet = await Write(s => Orders(5).WriteExcelAsync(s)); + + Assert.AreEqual(6, sheet.RowCount); + Assert.AreEqual("4", sheet.Text("A6")); + } + + [TestMethod] + public async Task Options_shape_the_sheet() + { + var sheet = await Write(s => new[] { new Order { Placed = When } }.WriteExcelAsync(s, new ExcelExportOptions + { + SheetName = "Orders: 2026/10 [final] and a very long tail", + AutoFilter = true, + FreezeHeaderRow = false, + AutoFitColumns = false, + DateTimeFormat = "yyyy-mm-dd hh:mm" + })); + + Assert.AreEqual("Orders_ 2026_10 _final_ and a v", sheet.Name); + Assert.AreEqual("A1:I2", sheet.Worksheet.Descendants().Single().Reference.Value); + Assert.AreEqual("_xlnm._FilterDatabase", sheet.Workbook.DefinedNames.Elements().Single().Name.Value); + Assert.IsFalse(sheet.Worksheet.Descendants().Any()); + Assert.IsFalse(sheet.Worksheet.Descendants().Any()); + Assert.AreEqual(164u, sheet.NumberFormat("E2")); + } + + [TestMethod] + public async Task Auto_fit_sizes_from_header_and_sample_and_respects_the_cap() + { + var sheet = await Write(s => new[] { new Order { Customer = new string('w', 500) } }.WriteExcelAsync(s)); + + var widths = sheet.Worksheet.Descendants().ToDictionary(c => c.Min.Value, c => c.Width.Value); + Assert.AreEqual(60d, widths[2]); + Assert.IsTrue(widths[1] >= 6 && widths[1] < 10); + Assert.AreNotEqual(0u, sheet.Style("A1"), "header carries the bold style"); + } + + [TestMethod] + public async Task Cancellation_is_honoured() + { + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + await Assert.ThrowsAsync(() => + Enumerable.Range(0, 10).Select(i => new Order { Id = i }).WriteExcelAsync(new MemoryStream(), cancellationToken: cancelled.Token)); + await Assert.ThrowsAsync(() => + Orders(10).WriteExcelAsync(new MemoryStream(), cancellationToken: cancelled.Token)); + } + + [TestMethod] + public async Task Large_exports_stream() + { + const int count = 100_000; + var sheet = await Write(s => Enumerable.Range(0, count) + .Select(i => new Order { Id = i, Customer = "c" + i, Total = i, Placed = When }).WriteExcelAsync(s)); + + Assert.AreEqual(count + 1, sheet.RowCount); + Assert.AreEqual("c99999", sheet.Text("B100001")); + } + + [TestMethod] + [DataRow(0, "A")] + [DataRow(25, "Z")] + [DataRow(26, "AA")] + [DataRow(676, "ZA")] + [DataRow(701, "ZZ")] + [DataRow(702, "AAA")] + [DataRow(16383, "XFD")] + public void Column_names(int index, string expected) => Assert.AreEqual(expected, XlsxWriter.ColumnName(index)); + + private static async IAsyncEnumerable Orders(int count, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + for (var i = 0; i < count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return new Order { Id = i }; + } + } + + private class Order + { + public int Id { get; set; } + public string Customer { get; set; } + public decimal Total { get; set; } + public bool Paid { get; set; } + public DateTime? Placed { get; set; } + public DateOnly? Due { get; set; } + public DayOfWeek? Status { get; set; } + public Guid? Ref { get; set; } + public string Note { get; set; } + public List Tags { get; set; } = new List(); + } + } +} diff --git a/SW.ExportToExcel.UnitTests/LegacyApiTests.cs b/SW.ExportToExcel.UnitTests/LegacyApiTests.cs new file mode 100644 index 0000000..ae0aced --- /dev/null +++ b/SW.ExportToExcel.UnitTests/LegacyApiTests.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DocumentFormat.OpenXml.Spreadsheet; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace SW.ExportToExcel.UnitTests +{ + // The original API. These pin the output other applications depend on, plus the cases + // that used to throw or produce a file Excel refuses to open. + [TestClass] + public class LegacyApiTests + { + private static async Task Write(IEnumerable data, IDictionary columns = null) + { + using var stream = new MemoryStream(); + if (columns == null) + await data.WriteExcel(stream); + else + await data.WriteExcel(stream, columns); + return new Sheet(stream.ToArray()); + } + + [TestMethod] + public async Task Columns_follow_declaration_order_and_the_map_only_filters_and_renames() + { + var sheet = await Write(new[] { new Person { Id = 1, Name = "a" } }, + new Dictionary { ["Name"] = "Full name", ["Id"] = "Number", ["Missing"] = "x" }); + + CollectionAssert.AreEqual(new[] { "Number", "Full name" }, sheet.Row(1, 2)); + CollectionAssert.AreEqual(new[] { "1", "a" }, sheet.Row(2, 2)); + Assert.IsFalse(sheet.Has("C1")); + } + + [TestMethod] + public async Task Cell_types_match_the_original_output() + { + var when = new DateTime(2026, 10, 12, 9, 30, 0); + var sheet = await Write(new[] + { + new Mixed { Text = "t", Flag = true, Number = 5, Amount = 1.25m, Ratio = 0.5, When = when }, + new Mixed() + }); + + Assert.AreEqual("inlineStr", sheet.Type("B2")); + Assert.AreEqual("true", sheet.Text("B2")); + Assert.AreEqual("5", sheet.Text("D2")); + Assert.AreEqual("1.25", sheet.Text("E2")); + Assert.AreEqual(when.ToOADate().ToString("R", System.Globalization.CultureInfo.InvariantCulture), sheet.Text("G2")); + Assert.AreEqual(22u, sheet.NumberFormat("G2")); + + // Nulls keep a placeholder cell, so nothing shifts left. + Assert.AreEqual(string.Empty, sheet.Text("A3")); + Assert.AreEqual("false", sheet.Text("B3")); + Assert.AreEqual(string.Empty, sheet.Text("C3")); + Assert.AreEqual(string.Empty, sheet.Text("D3")); + Assert.AreEqual(string.Empty, sheet.Text("G3")); + Assert.AreEqual(string.Empty, sheet.Text("H3")); + } + + [TestMethod] + public async Task Dates_before_year_100_are_written_empty() + { + var sheet = await Write(new[] { new Mixed { When = new DateTime(50, 1, 1) } }); + + Assert.AreEqual(string.Empty, sheet.Text("G2")); + Assert.AreEqual(0u, sheet.Style("G2")); + } + + [TestMethod] + public async Task Only_the_original_types_are_exported() + { + var sheet = await Write(new[] { new Unsupported() }); + + CollectionAssert.AreEqual(new[] { "S" }, sheet.Row(1, 1)); + Assert.IsFalse(sheet.Has("B1")); + } + + [TestMethod] + public async Task Header_row_is_frozen_and_no_column_widths_are_forced() + { + var sheet = await Write(new[] { new Person() }); + + var pane = sheet.Worksheet.Descendants().Single(); + Assert.AreEqual(PaneStateValues.Frozen, pane.State.Value); + Assert.IsInstanceOfType(pane.Parent); + Assert.IsFalse(sheet.Worksheet.Descendants().Any()); + Assert.AreEqual("Exported", sheet.Name); + } + + [TestMethod] + public async Task Empty_string_is_an_empty_cell_not_whitespace() + { + var sheet = await Write(new[] { new Person { Name = "" } }); + + Assert.AreEqual(string.Empty, sheet.Text("B2")); + } + + [TestMethod] + public async Task Characters_xml_cannot_carry_are_removed() + { + var sheet = await Write(new[] { new Person { Name = "a\u0001b\u000Bc￾d\uD800e😀" } }); + + Assert.AreEqual("abcde😀", sheet.Text("B2")); + } + + [TestMethod] + public async Task Strings_longer_than_excel_allows_are_truncated() + { + var sheet = await Write(new[] { new Person { Name = new string('x', 40000) } }); + + Assert.AreEqual(32767, sheet.Text("B2").Length); + } + + [TestMethod] + public async Task Leading_and_trailing_spaces_are_preserved() + { + var sheet = await Write(new[] { new Person { Name = " padded " } }); + + Assert.AreEqual(" padded ", sheet.Text("B2")); + } + + [TestMethod] + public async Task Non_finite_numbers_are_written_as_text() + { + var sheet = await Write(new[] + { + new Mixed { Ratio = double.NaN }, new Mixed { Ratio = double.PositiveInfinity }, + new Mixed { Ratio = double.NegativeInfinity } + }); + + Assert.AreEqual("NaN", sheet.Text("F2")); + Assert.AreEqual("Infinity", sheet.Text("F3")); + Assert.AreEqual("-Infinity", sheet.Text("F4")); + Assert.AreEqual("inlineStr", sheet.Type("F2")); + } + + [TestMethod] + public async Task Indexers_and_write_only_properties_are_skipped() + { + var sheet = await Write(new[] { new Awkward() }); + + CollectionAssert.AreEqual(new[] { "A" }, sheet.Row(1, 1)); + Assert.IsFalse(sheet.Has("B1")); + } + + [TestMethod] + public async Task A_type_with_nothing_to_export_still_gives_a_valid_file() + { + var sheet = await Write(new object[] { new Person() }); + + Assert.AreEqual(2, sheet.RowCount); + Assert.IsFalse(sheet.Has("A1")); + } + + [TestMethod] + public async Task Dictionary_rows_are_matched_by_key_not_position() + { + var rows = new List> + { + new Dictionary { ["name"] = "Joe", ["age"] = "42" }, + new Dictionary { ["age"] = "20", ["name"] = "Ann" }, + new Dictionary { ["name"] = "Bob" } + }; + using var stream = new MemoryStream(); + await rows.WriteExcel(stream); + var sheet = new Sheet(stream.ToArray()); + + Assert.AreEqual("Sheet 1", sheet.Name); + CollectionAssert.AreEqual(new[] { "name", "age" }, sheet.Row(1, 2)); + CollectionAssert.AreEqual(new[] { "Ann", "20" }, sheet.Row(3, 2)); + CollectionAssert.AreEqual(new[] { "Bob", "" }, sheet.Row(4, 2)); + } + + [TestMethod] + public async Task No_dictionary_rows_gives_an_empty_workbook() + { + using var stream = new MemoryStream(); + await new List>().WriteExcel(stream); + + Assert.AreEqual(1, new Sheet(stream.ToArray()).RowCount); + } + + [TestMethod] + public void GenerateExcel_writes_text_by_position() + { + var bytes = IEnumerableExtensions.GenerateExcel(new[] { "h1", "h2" }, + new[] { new List { "a", null }, new List { "b", "c", "extra" } }); + var sheet = new Sheet(bytes); + + CollectionAssert.AreEqual(new[] { "h1", "h2", null }, sheet.Row(1, 3)); + CollectionAssert.AreEqual(new[] { "a", "", "" }, sheet.Row(2, 3)); + CollectionAssert.AreEqual(new[] { "b", "c", "extra" }, sheet.Row(3, 3)); + } + + [TestMethod] + public async Task ExportToExcel_and_file_overloads_write_the_same_sheet() + { + var data = new[] { new Person { Id = 7, Name = "x" } }; + var bytes = await data.ExportToExcel(new[] { "Name" }); + var path = Path.GetTempFileName(); + try + { + await data.WriteExcel(path, new[] { "Name" }); + CollectionAssert.AreEqual(new Sheet(bytes).Row(2, 1), new Sheet(File.ReadAllBytes(path)).Row(2, 1)); + CollectionAssert.AreEqual(new[] { "x" }, new Sheet(bytes).Row(2, 1)); + } + finally + { + File.Delete(path); + } + } + + [TestMethod] + public async Task Errors_surface_through_the_task() + { + var task = new[] { new Person() }.WriteExcel(new MemoryStream(), (IDictionary)null); + + await Assert.ThrowsExactlyAsync(() => task); + } + + private class Person + { + public int Id { get; set; } + public string Name { get; set; } + } + + private class Mixed + { + public string Text { get; set; } + public bool Flag { get; set; } + public bool? MaybeFlag { get; set; } + public int? Number { get; set; } + public decimal Amount { get; set; } + public double Ratio { get; set; } + public DateTime? When { get; set; } + public long? Big { get; set; } + } + + private class Unsupported + { + public string S { get; set; } = "s"; + public Guid G { get; set; } = Guid.NewGuid(); + public DayOfWeek E { get; set; } + public List L { get; set; } = new List(); + } + + private class Awkward + { + private string hidden; + public string A { get; set; } = "a"; + public string this[int i] => "indexer"; + public string WriteOnly { set => hidden = value; } + } + } +} diff --git a/SW.ExportToExcel.UnitTests/SW.ExportToExcel.UnitTests.csproj b/SW.ExportToExcel.UnitTests/SW.ExportToExcel.UnitTests.csproj index c7b5613..b47abec 100644 --- a/SW.ExportToExcel.UnitTests/SW.ExportToExcel.UnitTests.csproj +++ b/SW.ExportToExcel.UnitTests/SW.ExportToExcel.UnitTests.csproj @@ -1,15 +1,17 @@ - + - net8.0 - + net10.0;net8.0 + latest false - - - + + + + + diff --git a/SW.ExportToExcel.UnitTests/Sheet.cs b/SW.ExportToExcel.UnitTests/Sheet.cs new file mode 100644 index 0000000..58abd50 --- /dev/null +++ b/SW.ExportToExcel.UnitTests/Sheet.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using DocumentFormat.OpenXml.Validation; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace SW.ExportToExcel.UnitTests +{ + // A written workbook read back: validated against the OOXML schema, cells addressed like "B3". + internal sealed class Sheet + { + private readonly Dictionary cells = new Dictionary(); + + public Sheet(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + using var document = SpreadsheetDocument.Open(stream, false); + + var errors = new OpenXmlValidator().Validate(document) + .Select(e => $"{e.Path?.XPath}: {e.Description}").ToList(); + Assert.IsEmpty(errors, string.Join(Environment.NewLine, errors)); + + var workbookPart = document.WorkbookPart; + var sheet = workbookPart.Workbook.Sheets.Elements().Single(); + Name = sheet.Name; + var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id); + Worksheet = worksheetPart.Worksheet; + Workbook = workbookPart.Workbook; + NumberFormats = workbookPart.WorkbookStylesPart?.Stylesheet?.CellFormats?.Elements() + .Select(f => f.NumberFormatId?.Value ?? 0).ToList() ?? new List(); + + var rowNumber = 0; + foreach (var row in Worksheet.Descendants()) + { + rowNumber = (int?)row.RowIndex?.Value ?? rowNumber + 1; + var column = 0; + foreach (var cell in row.Elements()) + { + if (cell.CellReference?.Value is { } reference) + column = ColumnIndex(reference); + cells[ColumnName(column) + rowNumber] = cell; + column++; + } + + RowCount = rowNumber; + } + } + + public string Name { get; } + public Worksheet Worksheet { get; } + public Workbook Workbook { get; } + public List NumberFormats { get; } + public int RowCount { get; } + + public Cell this[string reference] => cells.TryGetValue(reference, out var cell) ? cell : null; + + public bool Has(string reference) => cells.ContainsKey(reference); + + public string Text(string reference) + { + var cell = this[reference]; + return cell?.InlineString != null + ? string.Concat(cell.InlineString.Elements().Select(t => t.Text)) + : cell?.CellValue?.Text; + } + + public string Type(string reference) => this[reference]?.DataType?.InnerText; + + public uint Style(string reference) => this[reference]?.StyleIndex?.Value ?? 0; + + public uint NumberFormat(string reference) => NumberFormats[(int)Style(reference)]; + + public List Row(int row, int width) => + Enumerable.Range(0, width).Select(c => Text(ColumnName(c) + row)).ToList(); + + private static int ColumnIndex(string reference) + { + var index = 0; + foreach (var c in reference.TakeWhile(char.IsLetter)) + index = index * 26 + (c - 'A' + 1); + return index - 1; + } + + private static string ColumnName(int index) + { + var name = string.Empty; + for (var n = index + 1; n > 0; n = (n - 1) / 26) + name = (char)('A' + (n - 1) % 26) + name; + return name; + } + } +} diff --git a/SW.ExportToExcel.UnitTests/UnitTest1.cs b/SW.ExportToExcel.UnitTests/UnitTest1.cs deleted file mode 100644 index 00c2cd3..0000000 --- a/SW.ExportToExcel.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using System.Buffers.Text; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; - -namespace SW.ExportToExcel.UnitTests -{ - [TestClass] - public class UnitTest1 - { - [TestMethod] - public async Task TestMethod1() - { - var mylist = new List - { - new Employee - { - Name = "samer", - DoB = DateTime.UtcNow, - Id = 12 - }, - new Employee - { - Name = "wael", - DoB = DateTime.UtcNow, - Id = 13 - } - }; - - var bytes = await mylist.ExportToExcel(); - } - - [TestMethod] - public async Task TestMethod2() - { - var dict = new List> - { - new Dictionary - { - ["name"] = "Wheeb", - ["age"] = "20" - }, - new Dictionary - { - ["name"] = "Joe", - ["age"] = "42" - } - }; - var stream = new MemoryStream(); - await dict.WriteExcel(stream); - Assert.IsNotNull(stream); - // var b = Convert.ToBase64String(stream.ToArray()); - // Console.WriteLine(b); - } - - private class Employee - { - public int Id { get; set; } - public string Name { get; set; } - public DateTime DoB { get; set; } - } - } -} \ No newline at end of file diff --git a/SW.ExportToExcel/ExcelColumn.cs b/SW.ExportToExcel/ExcelColumn.cs new file mode 100644 index 0000000..bbe5257 --- /dev/null +++ b/SW.ExportToExcel/ExcelColumn.cs @@ -0,0 +1,23 @@ +using System; + +namespace SW.ExportToExcel +{ + /// A computed column: a header and a function producing the cell value from a row. + /// new ExcelColumn<Shipment>("Receiver city", s => s.Receiver.City) + public sealed class ExcelColumn + { + public ExcelColumn(string header, Func value) + { + Header = header ?? throw new ArgumentNullException(nameof(header)); + Value = value ?? throw new ArgumentNullException(nameof(value)); + } + + public string Header { get; } + + /// The cell value. Numbers, booleans and dates keep their Excel type; anything else is written as text. + public Func Value { get; } + + /// Fixed width in characters. Overrides auto-fit for this column. + public double? Width { get; set; } + } +} diff --git a/SW.ExportToExcel/ExcelExportExtensions.cs b/SW.ExportToExcel/ExcelExportExtensions.cs new file mode 100644 index 0000000..4ac2048 --- /dev/null +++ b/SW.ExportToExcel/ExcelExportExtensions.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SW.ExportToExcel.Internal; + +namespace SW.ExportToExcel +{ + /// + /// Streaming export. Unlike the original WriteExcel methods, columns come out in the order given, + /// booleans are real Excel booleans, empty values leave the cell empty, and enums, Guid, + /// DateTimeOffset and DateOnly are exported. Rows can be IDictionary<string, object> + /// (including ExpandoObject) or IDictionary<string, string>, keyed by column. + /// + /// + /// The package is written synchronously, so the stream must allow synchronous writes: + /// write to a or a , not straight to an HTTP response body. + /// + public static class ExcelExportExtensions + { + /// Every readable property of a supported type, in declaration order, headed by its name. + public static Task WriteExcelAsync(this IEnumerable data, Stream stream, + ExcelExportOptions options = null, CancellationToken cancellationToken = default) + { + options ??= ExcelExportOptions.Default; + return Run(data, stream, options, ColumnPlanner.FromProperties, cancellationToken); + } + + /// + /// The given columns, in the given order: key is the property (or dictionary key), value the header. + /// Properties that don't exist or have an unsupported type are skipped. + /// + public static Task WriteExcelAsync(this IEnumerable data, Stream stream, + IEnumerable> columns, ExcelExportOptions options = null, + CancellationToken cancellationToken = default) + { + var planned = ColumnPlanner.FromNames(columns); + return Run(data, stream, options ?? ExcelExportOptions.Default, _ => planned, cancellationToken); + } + + /// Computed columns, in the given order. + public static Task WriteExcelAsync(this IEnumerable data, Stream stream, + IEnumerable> columns, ExcelExportOptions options = null, + CancellationToken cancellationToken = default) + { + var planned = ColumnPlanner.FromSelectors(columns); + return Run(data, stream, options ?? ExcelExportOptions.Default, _ => planned, cancellationToken); + } + + /// + public static Task WriteExcelAsync(this IAsyncEnumerable data, Stream stream, + ExcelExportOptions options = null, CancellationToken cancellationToken = default) + { + options ??= ExcelExportOptions.Default; + return RunAsync(data, stream, options, ColumnPlanner.FromProperties, cancellationToken); + } + + /// + public static Task WriteExcelAsync(this IAsyncEnumerable data, Stream stream, + IEnumerable> columns, ExcelExportOptions options = null, + CancellationToken cancellationToken = default) + { + var planned = ColumnPlanner.FromNames(columns); + return RunAsync(data, stream, options ?? ExcelExportOptions.Default, _ => planned, cancellationToken); + } + + /// + public static Task WriteExcelAsync(this IAsyncEnumerable data, Stream stream, + IEnumerable> columns, ExcelExportOptions options = null, + CancellationToken cancellationToken = default) + { + var planned = ColumnPlanner.FromSelectors(columns); + return RunAsync(data, stream, options ?? ExcelExportOptions.Default, _ => planned, cancellationToken); + } + + private static Task Run(IEnumerable data, Stream stream, ExcelExportOptions options, + Func, IReadOnlyList> plan, CancellationToken cancellationToken) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + ExportRunner.Run(data, stream, Layout(options), SampleSize(options), plan, Widths(options), + cancellationToken); + return Task.CompletedTask; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + catch (Exception e) + { + return Task.FromException(e); + } + } + + private static Task RunAsync(IAsyncEnumerable data, Stream stream, ExcelExportOptions options, + Func, IReadOnlyList> plan, CancellationToken cancellationToken) => + ExportRunner.RunAsync(data, stream, Layout(options), SampleSize(options), plan, Widths(options), + cancellationToken); + + private static SheetLayout Layout(ExcelExportOptions options) => new SheetLayout + { + SheetName = options.SafeSheetName(), + FreezeHeaderRow = options.FreezeHeaderRow, + BoldHeader = options.BoldHeader, + AutoFilter = options.AutoFilter, + DateTimeFormat = options.DateTimeFormat, + DateFormat = options.DateFormat + }; + + // Dictionary rows need at least one row read ahead to know their keys. + private static int SampleSize(ExcelExportOptions options) => + Math.Max(1, options.AutoFitColumns ? options.AutoFitSampleRows : 1); + + private static Func, IReadOnlyList, double?[]> Widths(ExcelExportOptions options) => + (columns, sample) => ColumnPlanner.Widths(columns, sample, options.AutoFitColumns, options.MaxColumnWidth); + } +} diff --git a/SW.ExportToExcel/ExcelExportOptions.cs b/SW.ExportToExcel/ExcelExportOptions.cs new file mode 100644 index 0000000..380c305 --- /dev/null +++ b/SW.ExportToExcel/ExcelExportOptions.cs @@ -0,0 +1,49 @@ +using System.Text; + +namespace SW.ExportToExcel +{ + /// Options for . Not used by the original WriteExcel/ExportToExcel methods. + public sealed class ExcelExportOptions + { + internal static readonly ExcelExportOptions Default = new ExcelExportOptions(); + + /// Worksheet name. Characters Excel forbids ([ ] : * ? / \) become _; longer than 31 characters is truncated. + public string SheetName { get; set; } = "Exported"; + + /// Keep the header row visible while scrolling. + public bool FreezeHeaderRow { get; set; } = true; + + public bool BoldHeader { get; set; } = true; + + /// Add Excel's filter drop-downs to the header row. + public bool AutoFilter { get; set; } + + /// Size each column to its header and the first rows. + public bool AutoFitColumns { get; set; } = true; + + /// + /// Rows read ahead before anything is written, for column sizing and for discovering the keys of + /// dictionary rows. Keys that first appear after these rows are not exported unless columns are given. + /// + public int AutoFitSampleRows { get; set; } = 100; + + public double MaxColumnWidth { get; set; } = 60; + + /// Excel number format for DateTime/DateTimeOffset cells, e.g. yyyy-mm-dd hh:mm. Null uses Excel's built-in short date and time. + public string DateTimeFormat { get; set; } + + /// Excel number format for DateOnly cells. Null uses Excel's built-in short date. + public string DateFormat { get; set; } + + internal string SafeSheetName() + { + var name = new StringBuilder(SheetName ?? string.Empty); + foreach (var c in "[]:*?/\\") + name.Replace(c, '_'); + var result = name.ToString().Trim('\''); + if (result.Length > 31) + result = result.Substring(0, 31); + return result.Length == 0 ? "Sheet1" : result; + } + } +} diff --git a/SW.ExportToExcel/IEnumerableExtensions.cs b/SW.ExportToExcel/IEnumerableExtensions.cs index 2e31973..b266ec9 100644 --- a/SW.ExportToExcel/IEnumerableExtensions.cs +++ b/SW.ExportToExcel/IEnumerableExtensions.cs @@ -1,59 +1,20 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; -using System.Xml.Linq; -using System.Text; -using System.Reflection; -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; using System.IO; +using System.Linq; using System.Threading.Tasks; -using System.Xml; -using DocumentFormat.OpenXml.Spreadsheet; +using SW.ExportToExcel.Internal; namespace SW.ExportToExcel { + /// + /// The original API, kept output-compatible: columns follow the type's property declaration order + /// (a column map filters and renames them but does not reorder), booleans are written as the text + /// "true"/"false", and only string, bool, DateTime and numeric properties are exported. + /// For ordered columns, typed cells and more, use . + /// public static class IEnumerableExtensions { - private static readonly XNamespace relationshipsNamespace = - "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; - - private static readonly XNamespace mainNamespace = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; - - private const string styleSheet = @" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - "; - public static async Task ExportToExcel(this IEnumerable data) { var dictionary = typeof(TEntity).GetProperties().ToDictionary(k => k.Name, v => v.Name); @@ -67,32 +28,38 @@ public static async Task ExportToExcel(this IEnumerable ExportToExcel(this IEnumerable data, + public static async Task ExportToExcel(this IEnumerable data, IDictionary columns) { - var tempFile = Path.GetTempFileName(); + using var stream = new MemoryStream(); + await WriteExcel(data, stream, columns); + return stream.ToArray(); + } + /// + /// Rows of string values keyed by header. Headers are the first row's keys, in that row's order; + /// keys missing from a later row leave its cell empty. + /// + public static Task WriteExcel(this IEnumerable> data, Stream stream) + { try { - await WriteExcel(data, tempFile, columns); - - return File.ReadAllBytes(tempFile); + ExportRunner.Run(data, stream, new SheetLayout { Legacy = true, SheetName = "Sheet 1" }, 1, + sample => sample.Count == 0 + ? Array.Empty() + : sample[0].Keys.Select(key => new Column(key, + row => ((Dictionary)row).TryGetValue(key, out var value) + ? value ?? string.Empty + : string.Empty)).ToList(), + null); + return Task.CompletedTask; } - finally + catch (Exception e) { - File.Delete(tempFile); + return Task.FromException(e); } } - public static async Task WriteExcel(this IEnumerable> data, Stream stream) - { - var headers = data.FirstOrDefault().Select(x => x.Key).ToList(); - var dataRows = data.Select(d => d.Select(p => p.Value).ToList()).ToList(); - var res = GenerateExcel(headers, dataRows); - await stream.WriteAsync(res, 0, res.Length); - } - - public static async Task WriteExcel(this IEnumerable data, string filePath) { var dictionary = typeof(TEntity).GetProperties().ToDictionary(k => k.Name, v => v.Name); @@ -109,10 +76,8 @@ public static async Task WriteExcel(this IEnumerable data, str public static async Task WriteExcel(this IEnumerable data, string filePath, IDictionary columns) { - using (var fileStream = File.Open(filePath, FileMode.Create, FileAccess.ReadWrite)) - { - await WriteExcel(data, fileStream, columns); - } + using var fileStream = File.Open(filePath, FileMode.Create, FileAccess.ReadWrite); + await WriteExcel(data, fileStream, columns); } public static async Task WriteExcel(this IEnumerable data, Stream stream) @@ -128,303 +93,38 @@ public static async Task WriteExcel(this IEnumerable data, Str await WriteExcel(data, stream, dictionary); } - public static async Task WriteExcel(this IEnumerable data, Stream stream, + public static Task WriteExcel(this IEnumerable data, Stream stream, IDictionary columns) { - using (var doc = SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook)) + try { - var workbookPart = doc.AddWorkbookPart(); - var worksheetPart = workbookPart.AddNewPart(); - var worksheetPartId = workbookPart.GetIdOfPart(worksheetPart); - var stylesheetPart = workbookPart.AddNewPart(); - - // create the string table - var sharedStringTablePart = workbookPart.AddNewPart(); - var xmlStringTable = new XElement(mainNamespace + "sst"); - await WriteXmlToPartAsync(sharedStringTablePart, xmlStringTable); - - var workbookElement = new XElement(mainNamespace + "workbook", - new XAttribute("xmlns", mainNamespace.NamespaceName), - new XAttribute(XNamespace.Xmlns + "r", relationshipsNamespace.NamespaceName), - new XElement("bookViews", - new XElement("workbookView")), - new XElement("sheets", - new XElement("sheet", - new XAttribute("name", "Exported"), - new XAttribute("sheetId", "1"), - new XAttribute(relationshipsNamespace + "id", worksheetPartId)))); - - foreach (var element in workbookElement.Descendants()) - - if (element.Name.Namespace == "") - { - element.Attributes("xmlns").Remove(); - element.Name = element.Parent.Name.Namespace + element.Name.LocalName; - } - - await WriteXmlToPartAsync(workbookPart, workbookElement); - - var propertyInfos = new List(); - foreach (var propertyInfo in typeof(TEntity).GetProperties()) - - switch (true) - { - case object _ when propertyInfo.PropertyType == typeof(string): - case object _ when propertyInfo.PropertyType == typeof(DateTime): - case object _ when propertyInfo.PropertyType == typeof(DateTime?): - case object _ when propertyInfo.PropertyType == typeof(int): - case object _ when propertyInfo.PropertyType == typeof(int?): - case object _ when propertyInfo.PropertyType == typeof(byte): - case object _ when propertyInfo.PropertyType == typeof(byte?): - case object _ when propertyInfo.PropertyType == typeof(short): - case object _ when propertyInfo.PropertyType == typeof(short?): - case object _ when propertyInfo.PropertyType == typeof(long): - case object _ when propertyInfo.PropertyType == typeof(long?): - case object _ when propertyInfo.PropertyType == typeof(float): - case object _ when propertyInfo.PropertyType == typeof(float?): - case object _ when propertyInfo.PropertyType == typeof(double): - case object _ when propertyInfo.PropertyType == typeof(double?): - case object _ when propertyInfo.PropertyType == typeof(bool): - case object _ when propertyInfo.PropertyType == typeof(bool?): - case object _ when propertyInfo.PropertyType == typeof(decimal): - case object _ when propertyInfo.PropertyType == typeof(decimal?): - if (columns.ContainsKey(propertyInfo.Name)) - propertyInfos.Add(propertyInfo); - break; - } - - var worksheetElement = new XElement(mainNamespace + "worksheet", - new XElement("sheetViews", - new XElement("sheetView", - new XAttribute("tabSelected", "1"), - new XAttribute("workbookViewId", "0")), - new XElement("pane", - new XAttribute("ySplit", "1"), - new XAttribute("topLeftCell", "A2"), - new XAttribute("activePane", "bottomLeft"), - new XAttribute("state", "frozen")), - new XElement("selection", - new XAttribute("pane", "bottomLeft"))), - new XElement("sheetFormatPr", - new XAttribute("defaultRowHeight", "15")), - new XElement("cols", GetCols(propertyInfos)), - new XElement("sheetData", GetRowValues(data, columns, propertyInfos))); - - foreach (var element in worksheetElement.Descendants()) - - if (element.Name.Namespace == "") - { - element.Attributes("xmlns").Remove(); - element.Name = element.Parent.Name.Namespace + element.Name.LocalName; - } - - await WriteXmlToPartAsync(worksheetPart, worksheetElement); - - var styleSheetElement = XElement.Parse(styleSheet); - - await WriteXmlToPartAsync(stylesheetPart, styleSheetElement); - - await stream.FlushAsync(); - //stream.Close(); + var planned = ColumnPlanner.Legacy(typeof(TEntity), columns); + ExportRunner.Run(data, stream, new SheetLayout { Legacy = true, FreezeHeaderRow = true }, 0, + _ => planned, null); + return Task.CompletedTask; } - } - - private static XElement[] GetRowValues(IEnumerable data, - IDictionary propertyDictionary, IReadOnlyCollection Props) - { - var result = new List(); - - var rowNames = new XElement("row", from p in Props - select new XElement("c", new XAttribute("t", "inlineStr"), - new XElement("is", new XElement("t", propertyDictionary[p.Name])))); - result.Add(rowNames); - - result.AddRange(data.Select(item => - new XElement("row", from _p in Props select BuildCell(_p, _p.GetValue(item, null))))); - return result.ToArray(); - } - - private static XElement[] GetCols(IEnumerable propertyInfos) - { - return propertyInfos.Select(p => new XElement("col", - new XAttribute("min", "1"), - new XAttribute("max", "1"), - new XAttribute("bestFit", "1"), - new XAttribute("width", "4"))).ToArray(); - } - - private static XElement BuildCell(PropertyInfo propertyInfo, object value) - { - XElement element; - - switch (true) + catch (Exception e) { - case object _ when propertyInfo.PropertyType == typeof(string): - case object _ when propertyInfo.PropertyType == typeof(Guid): - case object _ when propertyInfo.PropertyType == typeof(Guid?): - case object _ when propertyInfo.PropertyType == typeof(bool): - case object _ when propertyInfo.PropertyType == typeof(bool?): - - element = new XElement("c", - new XAttribute("t", "inlineStr"), - new XElement("is", - new XElement("t", value))); - - - return element; - - - case object _ when propertyInfo.PropertyType == typeof(DateTime): - case object _ when propertyInfo.PropertyType == typeof(DateTime): - - - if (Convert.ToDateTime(value).Year >= 100) - { - element = new XElement("c", - new XAttribute("s", "1"), - new XElement("v", Convert.ToDateTime(value).ToOADate())); - return element; - } - else - { - element = new XElement("c", - new XElement("v")); - - return element; - } - - - - - - case object _ when propertyInfo.PropertyType == typeof(DateTime?): - case object _ when propertyInfo.PropertyType == typeof(DateTime?): - - var dt = (DateTime?) value; - - if (dt.HasValue && dt.Value.Year >= 100) - { - element = new XElement("c", - new XAttribute("s", "1"), - new XElement("v", dt.Value.ToOADate())); - - return element; - } - else - { - element = new XElement("c", - new XElement("v")); - - return element; - } - - default: - - element = new XElement("c", - new XElement("v", value)); - - return element; - } - } - - private static string ColumnLetter(int intCol) - { - var intFirstLetter = intCol / 676 + 64; - var intSecondLetter = intCol % 676 / 26 + 64; - var intThirdLetter = intCol % 26 + 65; - - var firstLetter = intFirstLetter > 64 - ? (char) intFirstLetter - : ' '; - var secondLetter = intSecondLetter > 64 - ? (char) intSecondLetter - : ' '; - var thirdLetter = (char) intThirdLetter; - - return string.Concat(firstLetter, secondLetter, - thirdLetter).Trim(); - } - - private static Cell CreateTextCell(string header, uint index, - string text) - { - var cell = new Cell - { - DataType = CellValues.InlineString, - CellReference = header + index - }; - - var iString = new InlineString(); - var t = new Text {Text = text}; - iString.AppendChild(t); - cell.AppendChild(iString); - return cell; - } - - private static async Task WriteXmlToPartAsync(OpenXmlPart openXmlPart, XElement element) - { - using (var xmlTextWriter = new XmlTextWriter(openXmlPart.GetStream(), Encoding.UTF8)) - { - xmlTextWriter.Formatting = Formatting.Indented; - xmlTextWriter.WriteStartDocument(); - element.WriteTo(xmlTextWriter); - xmlTextWriter.WriteEndDocument(); - xmlTextWriter.Flush(); + return Task.FromException(e); } } + /// A sheet of text cells: one header row, then one row per list, values by position. public static byte[] GenerateExcel(IEnumerable headers, IEnumerable> dataRows) { - var stream = new MemoryStream(); - var document = SpreadsheetDocument - .Create(stream, SpreadsheetDocumentType.Workbook); - - var workbooks = document.AddWorkbookPart(); - workbooks.Workbook = new Workbook(); - var worksheetPart = workbooks.AddNewPart(); - var sheetData = new SheetData(); - - worksheetPart.Worksheet = new Worksheet(sheetData); - - var sheets = document.WorkbookPart.Workbook.AppendChild(new Sheets()); - - var sheet = new Sheet - { - Id = document.WorkbookPart - .GetIdOfPart(worksheetPart), - SheetId = 1, Name = "Sheet 1" - }; - sheets.AppendChild(sheet); - - uint rowIndex = 0; - var row = new Row {RowIndex = ++rowIndex}; - sheetData.AppendChild(row); - var cellIndex = 0; - - foreach (var header in headers) - { - row.AppendChild(CreateTextCell(ColumnLetter(cellIndex++), - rowIndex, header)); - } - - - foreach (var rowData in dataRows) - { - cellIndex = 0; - row = new Row {RowIndex = ++rowIndex}; - sheetData.AppendChild(row); - foreach (var cell in rowData.Select(callData => CreateTextCell(ColumnLetter(cellIndex++), - rowIndex, callData ?? string.Empty))) - { - row.AppendChild(cell); - } - } - - workbooks.Workbook.Save(); - document.Close(); - + var headerList = headers.ToList(); + var rows = dataRows.ToList(); + var width = rows.Select(r => r?.Count ?? 0).DefaultIfEmpty(0).Max(); + var columns = Enumerable.Range(0, Math.Max(headerList.Count, width)) + .Select(i => new Column(i < headerList.Count ? headerList[i] ?? string.Empty : null, + row => row is List values && i < values.Count ? values[i] ?? string.Empty : string.Empty)) + .ToList(); + + using var stream = new MemoryStream(); + ExportRunner.Run(rows, stream, new SheetLayout { Legacy = true, SheetName = "Sheet 1" }, 0, + _ => columns, null); return stream.ToArray(); } } -} \ No newline at end of file +} diff --git a/SW.ExportToExcel/Internal/ExportRunner.cs b/SW.ExportToExcel/Internal/ExportRunner.cs new file mode 100644 index 0000000..6321f0e --- /dev/null +++ b/SW.ExportToExcel/Internal/ExportRunner.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace SW.ExportToExcel.Internal +{ + internal static class ExportRunner + { + private const int CancellationCheckInterval = 1024; + + // Pulls up to `sampleSize` rows first (to discover dictionary keys and size columns), then streams the rest. + internal static void Run(IEnumerable data, Stream stream, SheetLayout layout, int sampleSize, + Func, IReadOnlyList> plan, Func, IReadOnlyList, double?[]> widths, + CancellationToken cancellationToken = default) + { + if (data == null) throw new ArgumentNullException(nameof(data)); + if (stream == null) throw new ArgumentNullException(nameof(stream)); + + using var enumerator = data.GetEnumerator(); + var sample = new List(); + while (sample.Count < sampleSize && enumerator.MoveNext()) + sample.Add(enumerator.Current); + + var columns = plan(sample); + using var writer = new XlsxWriter(stream, layout, columns); + writer.Begin(widths?.Invoke(columns, sample)); + + foreach (var item in sample) + writer.WriteRow(item); + + var count = 0; + while (enumerator.MoveNext()) + { + if (++count % CancellationCheckInterval == 0) + cancellationToken.ThrowIfCancellationRequested(); + writer.WriteRow(enumerator.Current); + } + + writer.Complete(); + } + + internal static async Task RunAsync(IAsyncEnumerable data, Stream stream, SheetLayout layout, int sampleSize, + Func, IReadOnlyList> plan, Func, IReadOnlyList, double?[]> widths, + CancellationToken cancellationToken) + { + if (data == null) throw new ArgumentNullException(nameof(data)); + if (stream == null) throw new ArgumentNullException(nameof(stream)); + + await using var enumerator = data.GetAsyncEnumerator(cancellationToken); + var sample = new List(); + while (sample.Count < sampleSize && await enumerator.MoveNextAsync().ConfigureAwait(false)) + sample.Add(enumerator.Current); + + var columns = plan(sample); + using var writer = new XlsxWriter(stream, layout, columns); + writer.Begin(widths?.Invoke(columns, sample)); + + foreach (var item in sample) + writer.WriteRow(item); + + var count = 0; + while (await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + if (++count % CancellationCheckInterval == 0) + cancellationToken.ThrowIfCancellationRequested(); + writer.WriteRow(enumerator.Current); + } + + writer.Complete(); + } + } + + internal static class ColumnPlanner + { + // The property types the 2.x/8.x API exported; anything else was silently left out. + private static readonly Dictionary LegacyTypes = new Dictionary + { + [typeof(string)] = LegacyKind.Text, + [typeof(bool)] = LegacyKind.Text, + [typeof(bool?)] = LegacyKind.Text, + [typeof(DateTime)] = LegacyKind.DateTime, + [typeof(DateTime?)] = LegacyKind.DateTime, + [typeof(int)] = LegacyKind.Number, + [typeof(int?)] = LegacyKind.Number, + [typeof(byte)] = LegacyKind.Number, + [typeof(byte?)] = LegacyKind.Number, + [typeof(short)] = LegacyKind.Number, + [typeof(short?)] = LegacyKind.Number, + [typeof(long)] = LegacyKind.Number, + [typeof(long?)] = LegacyKind.Number, + [typeof(float)] = LegacyKind.Number, + [typeof(float?)] = LegacyKind.Number, + [typeof(double)] = LegacyKind.Number, + [typeof(double?)] = LegacyKind.Number, + [typeof(decimal)] = LegacyKind.Number, + [typeof(decimal?)] = LegacyKind.Number, + }; + + // Legacy: declared property order, filtered by `columns`' keys, headers from its values. + internal static IReadOnlyList Legacy(Type type, IDictionary columns) + { + if (columns == null) throw new ArgumentNullException(nameof(columns)); + var result = new List(); + foreach (var property in type.GetProperties()) + { + if (!LegacyTypes.TryGetValue(property.PropertyType, out var kind) || !IsReadable(property) || + !columns.ContainsKey(property.Name)) + continue; + result.Add(new Column(columns[property.Name], Getter(property), kind)); + } + + return result; + } + + internal static IReadOnlyList FromProperties(IReadOnlyList sample) + { + var dictionaryGetter = DictionaryGetter(typeof(T)); + if (dictionaryGetter != null) + return DictionaryKeys(sample).Select(key => new Column(key, dictionaryGetter(key))).ToList(); + + return typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => IsReadable(p) && IsSupported(p.PropertyType)) + .Select(p => new Column(p.Name, Getter(p))) + .ToList(); + } + + internal static IReadOnlyList FromNames(IEnumerable> columns) + { + if (columns == null) throw new ArgumentNullException(nameof(columns)); + var dictionaryGetter = DictionaryGetter(typeof(T)); + var result = new List(); + foreach (var column in columns) + { + if (dictionaryGetter != null) + { + result.Add(new Column(column.Value ?? column.Key, dictionaryGetter(column.Key))); + continue; + } + + var property = typeof(T).GetProperty(column.Key, BindingFlags.Public | BindingFlags.Instance); + if (property != null && IsReadable(property) && IsSupported(property.PropertyType)) + result.Add(new Column(column.Value ?? column.Key, Getter(property))); + } + + return result; + } + + internal static IReadOnlyList FromSelectors(IEnumerable> columns) + { + if (columns == null) throw new ArgumentNullException(nameof(columns)); + return columns.Select(c => new Column(c.Header, item => c.Value((T)item), width: c.Width)).ToList(); + } + + internal static double?[] Widths(IReadOnlyList columns, IReadOnlyList sample, + bool autoFit, double maxWidth) + { + if (!autoFit && columns.All(c => c.Width == null)) + return null; + + var widths = new double?[columns.Count]; + for (var i = 0; i < columns.Count; i++) + { + if (columns[i].Width != null || !autoFit) + { + widths[i] = columns[i].Width; + continue; + } + + // Header is bold, which renders roughly 10% wider. + var longest = (columns[i].Header?.Length ?? 0) * 1.1; + foreach (var item in sample) + longest = Math.Max(longest, XlsxWriter.DisplayLength(columns[i].Getter(item))); + widths[i] = Math.Min(maxWidth, Math.Max(6, longest + 2)); + } + + return widths; + } + + private static IEnumerable DictionaryKeys(IReadOnlyList sample) + { + var seen = new HashSet(); + foreach (var row in sample) + foreach (var key in Keys(row)) + if (key != null && seen.Add(key)) + yield return key; + } + + private static IEnumerable Keys(object row) => row switch + { + IDictionary d => d.Keys, + IReadOnlyDictionary d => d.Keys, + IDictionary d => d.Keys, + IReadOnlyDictionary d => d.Keys, + _ => Enumerable.Empty() + }; + + private static Func> DictionaryGetter(Type type) + { + if (typeof(IDictionary).IsAssignableFrom(type)) + return key => row => row is IDictionary d && d.TryGetValue(key, out var v) ? v : null; + if (typeof(IReadOnlyDictionary).IsAssignableFrom(type)) + return key => row => row is IReadOnlyDictionary d && d.TryGetValue(key, out var v) ? v : null; + if (typeof(IDictionary).IsAssignableFrom(type)) + return key => row => row is IDictionary d && d.TryGetValue(key, out var v) ? v : null; + if (typeof(IReadOnlyDictionary).IsAssignableFrom(type)) + return key => row => row is IReadOnlyDictionary d && d.TryGetValue(key, out var v) ? v : null; + return null; + } + + private static bool IsReadable(PropertyInfo property) => + property.GetGetMethod() != null && property.GetIndexParameters().Length == 0; + + internal static bool IsSupported(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type.IsPrimitive && type != typeof(IntPtr) && type != typeof(UIntPtr) + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(Guid) + || type == typeof(TimeSpan) +#if NET6_0_OR_GREATER + || type == typeof(DateOnly) + || type == typeof(TimeOnly) +#endif + ; + } + + private static Func Getter(PropertyInfo property) => property.GetValue; + } +} diff --git a/SW.ExportToExcel/Internal/XlsxWriter.cs b/SW.ExportToExcel/Internal/XlsxWriter.cs new file mode 100644 index 0000000..460fdc2 --- /dev/null +++ b/SW.ExportToExcel/Internal/XlsxWriter.cs @@ -0,0 +1,663 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Xml; + +namespace SW.ExportToExcel.Internal +{ + // How the 2.x/8.x API typed a cell. Fixed per column from the property's declared type. + internal enum LegacyKind + { + Text, + Number, + DateTime + } + + internal sealed class Column + { + public Column(string header, Func getter, LegacyKind legacyKind = LegacyKind.Text, + double? width = null) + { + Header = header; + Getter = getter; + LegacyKind = legacyKind; + Width = width; + } + + public string Header { get; } + public Func Getter { get; } + public LegacyKind LegacyKind { get; } + public double? Width { get; } + } + + internal sealed class SheetLayout + { + public string SheetName { get; set; } = "Exported"; + + // Legacy: cells typed by LegacyKind, booleans as text, every cell written (no references), + // the original two-entry stylesheet. Otherwise cells are typed by their runtime value, + // empty cells are omitted and every cell carries its reference. + public bool Legacy { get; set; } + public bool FreezeHeaderRow { get; set; } + public bool BoldHeader { get; set; } + public bool AutoFilter { get; set; } + public string DateTimeFormat { get; set; } + public string DateFormat { get; set; } + } + + // Streams one worksheet straight into the package: rows are written as they are enumerated, + // so memory stays flat however many rows there are. + internal sealed class XlsxWriter : IDisposable + { + private const string MainNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private const string RelNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private const string PackageRelNs = "http://schemas.openxmlformats.org/package/2006/relationships"; + private const string ContentTypesNs = "http://schemas.openxmlformats.org/package/2006/content-types"; + private const string SheetRelId = "rId1"; + + private const int DateTimeStyle = 1; + private const int DateStyle = 2; + private const int HeaderStyle = 3; + + private static readonly XmlWriterSettings XmlSettings = new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + NewLineHandling = NewLineHandling.None, + CloseOutput = true + }; + + private readonly SheetLayout layout; + private readonly IReadOnlyList columns; + private readonly string[] columnNames; + private readonly ZipArchive zip; + + private XmlWriter xml; + private int rowNumber; + private int nextColumn; + private bool completed; + + public XlsxWriter(Stream stream, SheetLayout layout, IReadOnlyList columns) + { + this.layout = layout; + this.columns = columns; + columnNames = new string[columns.Count]; + for (var i = 0; i < columns.Count; i++) + columnNames[i] = ColumnName(i); + + // The parts are written straight into a create-mode zip, which compresses as it goes and keeps + // nothing in memory. (The OpenXml SDK only writes through a read-write package, which buffers + // every part uncompressed until it is disposed.) + zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true); + } + + public void Begin(IReadOnlyList widths) + { + xml = XmlWriter.Create(zip.CreateEntry("xl/worksheets/sheet1.xml").Open(), XmlSettings); + xml.WriteStartDocument(true); + xml.WriteStartElement("worksheet", MainNs); + + if (layout.FreezeHeaderRow) + { + xml.WriteStartElement("sheetViews"); + xml.WriteStartElement("sheetView"); + xml.WriteAttributeString("tabSelected", "1"); + xml.WriteAttributeString("workbookViewId", "0"); + xml.WriteStartElement("pane"); + xml.WriteAttributeString("ySplit", "1"); + xml.WriteAttributeString("topLeftCell", "A2"); + xml.WriteAttributeString("activePane", "bottomLeft"); + xml.WriteAttributeString("state", "frozen"); + xml.WriteEndElement(); + xml.WriteStartElement("selection"); + xml.WriteAttributeString("pane", "bottomLeft"); + xml.WriteAttributeString("activeCell", "A2"); + xml.WriteAttributeString("sqref", "A2"); + xml.WriteEndElement(); + xml.WriteEndElement(); + xml.WriteEndElement(); + } + + xml.WriteStartElement("sheetFormatPr"); + xml.WriteAttributeString("defaultRowHeight", "15"); + xml.WriteEndElement(); + + if (widths != null && widths.Any(w => w != null)) + { + xml.WriteStartElement("cols"); + for (var i = 0; i < widths.Count; i++) + { + if (widths[i] == null) + continue; + var index = (i + 1).ToString(CultureInfo.InvariantCulture); + xml.WriteStartElement("col"); + xml.WriteAttributeString("min", index); + xml.WriteAttributeString("max", index); + xml.WriteAttributeString("width", XmlConvert.ToString(Math.Round(widths[i].Value, 2))); + xml.WriteAttributeString("customWidth", "1"); + xml.WriteEndElement(); + } + + xml.WriteEndElement(); + } + + xml.WriteStartElement("sheetData"); + + StartRow(); + for (var i = 0; i < columns.Count; i++) + { + if (columns[i].Header == null) + continue; + WriteText(i, columns[i].Header, layout.BoldHeader ? HeaderStyle : 0); + } + + xml.WriteEndElement(); + } + + public void WriteRow(object item) + { + StartRow(); + for (var i = 0; i < columns.Count; i++) + { + var value = columns[i].Getter(item); + if (layout.Legacy) + WriteLegacyCell(i, columns[i].LegacyKind, value); + else + WriteCell(i, value); + } + + xml.WriteEndElement(); + } + + public void Complete() + { + xml.WriteEndElement(); // sheetData + + var autoFilter = layout.AutoFilter && columns.Count > 0; + var filterRange = autoFilter ? $"$A$1:${columnNames[columns.Count - 1]}${rowNumber}" : null; + if (autoFilter) + { + xml.WriteStartElement("autoFilter"); + xml.WriteAttributeString("ref", filterRange.Replace("$", "")); + xml.WriteEndElement(); + } + + xml.WriteEndElement(); // worksheet + xml.WriteEndDocument(); + xml.Dispose(); + xml = null; + + WritePart("xl/styles.xml", WriteStyles); + WritePart("xl/workbook.xml", w => WriteWorkbook(w, filterRange)); + WritePart("xl/_rels/workbook.xml.rels", w => WriteRelationships(w, + (SheetRelId, RelNs + "/worksheet", "worksheets/sheet1.xml"), + ("rId2", RelNs + "/styles", "styles.xml"))); + WritePart("_rels/.rels", w => WriteRelationships(w, + ("rId1", RelNs + "/officeDocument", "xl/workbook.xml"))); + WritePart("[Content_Types].xml", WriteContentTypes); + + completed = true; + zip.Dispose(); + } + + public void Dispose() + { + if (completed) + return; + xml?.Dispose(); + zip.Dispose(); + } + + private void StartRow() + { + rowNumber++; + nextColumn = 0; + xml.WriteStartElement("row"); + if (!layout.Legacy) + xml.WriteAttributeString("r", rowNumber.ToString(CultureInfo.InvariantCulture)); + } + + private void WriteLegacyCell(int column, LegacyKind kind, object value) + { + switch (kind) + { + case LegacyKind.Text: + WriteText(column, value is bool b ? XmlConvert.ToString(b) : (string)value ?? string.Empty, 0); + return; + + case LegacyKind.DateTime: + if (value is DateTime dateTime && dateTime.Year >= 100) + WriteNumber(column, XmlConvert.ToString(dateTime.ToOADate()), DateTimeStyle); + else + WriteNumber(column, string.Empty, 0); + return; + + default: + if (value == null) + WriteNumber(column, string.Empty, 0); + else if (TryFormatNumber(value, out var number)) + WriteNumber(column, number, 0); + else + WriteText(column, number, 0); // NaN / infinity: Excel cannot open a non-finite number + return; + } + } + + private void WriteCell(int column, object value) + { + switch (value) + { + case null: + return; + case string text: + if (text.Length > 0) + WriteText(column, text, 0); + return; + case bool boolean: + WriteBoolean(column, boolean); + return; + case DateTime dateTime: + WriteDate(column, dateTime, DateTimeStyle); + return; + case DateTimeOffset dateTimeOffset: + WriteDate(column, dateTimeOffset.DateTime, DateTimeStyle); + return; +#if NET6_0_OR_GREATER + case DateOnly date: + WriteDate(column, date.ToDateTime(TimeOnly.MinValue), DateStyle); + return; +#endif + case Enum enumValue: + WriteText(column, enumValue.ToString(), 0); + return; + } + + if (TryFormatNumber(value, out var number)) + WriteNumber(column, number, 0); + else if (number != null) + WriteText(column, number, 0); + else if (value is IFormattable formattable) + WriteText(column, formattable.ToString(null, CultureInfo.InvariantCulture), 0); + else + WriteText(column, value.ToString(), 0); + } + + private void WriteDate(int column, DateTime value, int style) + { + // DateTime.ToOADate cannot represent years below 100; such values (DateTime.MinValue in + // practice) mean "not set". + if (value.Year >= 100) + WriteNumber(column, XmlConvert.ToString(value.ToOADate()), style); + } + + private void WriteText(int column, string value, int style) + { + value = XmlText.Clean(value); + xml.WriteStartElement("c"); + WriteReference(column); + if (style != 0) + xml.WriteAttributeString("s", style.ToString(CultureInfo.InvariantCulture)); + xml.WriteAttributeString("t", "inlineStr"); + xml.WriteStartElement("is"); + xml.WriteStartElement("t"); + if (XmlText.NeedsPreserve(value)) + xml.WriteAttributeString("xml", "space", null, "preserve"); + xml.WriteString(value); + xml.WriteEndElement(); + xml.WriteEndElement(); + xml.WriteEndElement(); + } + + private void WriteNumber(int column, string value, int style) + { + xml.WriteStartElement("c"); + WriteReference(column); + if (style != 0) + xml.WriteAttributeString("s", style.ToString(CultureInfo.InvariantCulture)); + xml.WriteElementString("v", value); + xml.WriteEndElement(); + } + + private void WriteBoolean(int column, bool value) + { + xml.WriteStartElement("c"); + WriteReference(column); + xml.WriteAttributeString("t", "b"); + xml.WriteElementString("v", value ? "1" : "0"); + xml.WriteEndElement(); + } + + // Cells are positional, so a reference is only needed after an empty cell was skipped. + // Writing one on every cell made files about four times larger. + private void WriteReference(int column) + { + var positional = column == nextColumn; + nextColumn = column + 1; + if (layout.Legacy || positional) + return; + xml.WriteStartAttribute("r"); + xml.WriteString(columnNames[column]); + xml.WriteString(rowNumber.ToString(CultureInfo.InvariantCulture)); + xml.WriteEndAttribute(); + } + + // Formats a numeric value the way XElement did (XmlConvert, culture-invariant). A non-finite + // float/double returns false with its text in `text`; a non-numeric value returns false and null. + internal static bool TryFormatNumber(object value, out string text) + { + switch (value) + { + case int v: text = XmlConvert.ToString(v); return true; + case long v: text = XmlConvert.ToString(v); return true; + case decimal v: text = XmlConvert.ToString(v); return true; + case short v: text = XmlConvert.ToString(v); return true; + case byte v: text = XmlConvert.ToString(v); return true; + case sbyte v: text = XmlConvert.ToString(v); return true; + case ushort v: text = XmlConvert.ToString(v); return true; + case uint v: text = XmlConvert.ToString(v); return true; + case ulong v: text = XmlConvert.ToString(v); return true; + case double v: + if (double.IsNaN(v) || double.IsInfinity(v)) + { + text = NonFinite(v); + return false; + } + + text = XmlConvert.ToString(v); + return true; + case float v: + if (float.IsNaN(v) || float.IsInfinity(v)) + { + text = NonFinite(v); + return false; + } + + text = XmlConvert.ToString(v); + return true; + default: + text = null; + return false; + } + } + + private static string NonFinite(double value) => + double.IsNaN(value) ? "NaN" : value > 0 ? "Infinity" : "-Infinity"; + + private void WritePart(string path, Action write) + { + using var writer = XmlWriter.Create(zip.CreateEntry(path).Open(), XmlSettings); + writer.WriteStartDocument(true); + write(writer); + writer.WriteEndDocument(); + } + + private void WriteWorkbook(XmlWriter w, string filterRange) + { + w.WriteStartElement("workbook", MainNs); + w.WriteAttributeString("xmlns", "r", null, RelNs); + w.WriteStartElement("bookViews"); + w.WriteElementString("workbookView", null); + w.WriteEndElement(); + w.WriteStartElement("sheets"); + w.WriteStartElement("sheet"); + w.WriteAttributeString("name", layout.SheetName); + w.WriteAttributeString("sheetId", "1"); + w.WriteAttributeString("id", RelNs, SheetRelId); + w.WriteEndElement(); + w.WriteEndElement(); + if (filterRange != null) + { + w.WriteStartElement("definedNames"); + w.WriteStartElement("definedName"); + w.WriteAttributeString("name", "_xlnm._FilterDatabase"); + w.WriteAttributeString("localSheetId", "0"); + w.WriteAttributeString("hidden", "1"); + w.WriteString("'" + layout.SheetName.Replace("'", "''") + "'!" + filterRange); + w.WriteEndElement(); + w.WriteEndElement(); + } + + w.WriteEndElement(); + } + + private static void WriteRelationships(XmlWriter w, params (string id, string type, string target)[] relationships) + { + w.WriteStartElement("Relationships", PackageRelNs); + foreach (var (id, type, target) in relationships) + { + w.WriteStartElement("Relationship"); + w.WriteAttributeString("Id", id); + w.WriteAttributeString("Type", type); + w.WriteAttributeString("Target", target); + w.WriteEndElement(); + } + + w.WriteEndElement(); + } + + private static void WriteContentTypes(XmlWriter w) + { + const string ooxml = "application/vnd.openxmlformats-officedocument.spreadsheetml."; + w.WriteStartElement("Types", ContentTypesNs); + foreach (var (extension, type) in new[] + { + ("rels", "application/vnd.openxmlformats-package.relationships+xml"), + ("xml", "application/xml") + }) + { + w.WriteStartElement("Default"); + w.WriteAttributeString("Extension", extension); + w.WriteAttributeString("ContentType", type); + w.WriteEndElement(); + } + + foreach (var (part, type) in new[] + { + ("/xl/workbook.xml", ooxml + "sheet.main+xml"), + ("/xl/worksheets/sheet1.xml", ooxml + "worksheet+xml"), + ("/xl/styles.xml", ooxml + "styles+xml") + }) + { + w.WriteStartElement("Override"); + w.WriteAttributeString("PartName", part); + w.WriteAttributeString("ContentType", type); + w.WriteEndElement(); + } + + w.WriteEndElement(); + } + + private void WriteStyles(XmlWriter w) + { + var legacy = layout.Legacy; + var customFormats = new List<(int id, string code)>(); + var dateTimeFormatId = legacy ? 22 : FormatId(layout.DateTimeFormat, 22, customFormats); + var dateFormatId = FormatId(layout.DateFormat, 14, customFormats); + + w.WriteStartElement("styleSheet", MainNs); + + if (!legacy && customFormats.Count > 0) + { + w.WriteStartElement("numFmts"); + w.WriteAttributeString("count", customFormats.Count.ToString(CultureInfo.InvariantCulture)); + foreach (var (id, code) in customFormats) + { + w.WriteStartElement("numFmt"); + w.WriteAttributeString("numFmtId", id.ToString(CultureInfo.InvariantCulture)); + w.WriteAttributeString("formatCode", code); + w.WriteEndElement(); + } + + w.WriteEndElement(); + } + + w.WriteStartElement("fonts"); + w.WriteAttributeString("count", legacy ? "1" : "2"); + WriteFont(w, false); + if (!legacy) + WriteFont(w, true); + w.WriteEndElement(); + + w.WriteStartElement("fills"); + w.WriteAttributeString("count", "2"); + foreach (var pattern in new[] { "none", "gray125" }) + { + w.WriteStartElement("fill"); + w.WriteStartElement("patternFill"); + w.WriteAttributeString("patternType", pattern); + w.WriteEndElement(); + w.WriteEndElement(); + } + + w.WriteEndElement(); + + w.WriteStartElement("borders"); + w.WriteAttributeString("count", "1"); + w.WriteStartElement("border"); + foreach (var side in new[] { "left", "right", "top", "bottom", "diagonal" }) + w.WriteElementString(side, null); + w.WriteEndElement(); + w.WriteEndElement(); + + w.WriteStartElement("cellStyleXfs"); + w.WriteAttributeString("count", "1"); + WriteXf(w, 0, 0, false); + w.WriteEndElement(); + + w.WriteStartElement("cellXfs"); + w.WriteAttributeString("count", legacy ? "2" : "4"); + WriteXf(w, 0, 0, true); + WriteXf(w, dateTimeFormatId, 0, true); + if (!legacy) + { + WriteXf(w, dateFormatId, 0, true); + WriteXf(w, 0, 1, true); + } + + w.WriteEndElement(); + + w.WriteStartElement("cellStyles"); + w.WriteAttributeString("count", "1"); + w.WriteStartElement("cellStyle"); + w.WriteAttributeString("name", "Normal"); + w.WriteAttributeString("xfId", "0"); + w.WriteAttributeString("builtinId", "0"); + w.WriteEndElement(); + w.WriteEndElement(); + + w.WriteStartElement("dxfs"); + w.WriteAttributeString("count", "0"); + w.WriteEndElement(); + w.WriteStartElement("tableStyles"); + w.WriteAttributeString("count", "0"); + w.WriteAttributeString("defaultTableStyle", "TableStyleMedium9"); + w.WriteAttributeString("defaultPivotStyle", "PivotStyleLight16"); + w.WriteEndElement(); + + w.WriteEndElement(); + } + + private static int FormatId(string code, int builtIn, List<(int id, string code)> customFormats) + { + if (string.IsNullOrEmpty(code)) + return builtIn; + var id = 164 + customFormats.Count; + customFormats.Add((id, code)); + return id; + } + + private static void WriteFont(XmlWriter w, bool bold) + { + w.WriteStartElement("font"); + if (bold) + w.WriteElementString("b", null); + WriteVal(w, "sz", "11"); + w.WriteStartElement("color"); + w.WriteAttributeString("theme", "1"); + w.WriteEndElement(); + WriteVal(w, "name", "Calibri"); + WriteVal(w, "family", "2"); + WriteVal(w, "scheme", "minor"); + w.WriteEndElement(); + } + + private static void WriteVal(XmlWriter w, string element, string value) + { + w.WriteStartElement(element); + w.WriteAttributeString("val", value); + w.WriteEndElement(); + } + + private static void WriteXf(XmlWriter w, int numFmtId, int fontId, bool inCellXfs) + { + w.WriteStartElement("xf"); + w.WriteAttributeString("numFmtId", numFmtId.ToString(CultureInfo.InvariantCulture)); + w.WriteAttributeString("fontId", fontId.ToString(CultureInfo.InvariantCulture)); + w.WriteAttributeString("fillId", "0"); + w.WriteAttributeString("borderId", "0"); + if (inCellXfs) + { + w.WriteAttributeString("xfId", "0"); + if (numFmtId != 0) + w.WriteAttributeString("applyNumberFormat", "1"); + if (fontId != 0) + w.WriteAttributeString("applyFont", "1"); + } + + w.WriteEndElement(); + } + + // 0 → A, 25 → Z, 26 → AA, 701 → ZZ, 702 → AAA. + internal static string ColumnName(int index) + { + var name = string.Empty; + for (var n = index + 1; n > 0; n = (n - 1) / 26) + name = (char)('A' + (n - 1) % 26) + name; + return name; + } + + // Approximate display width, in characters, used to size columns from a sample of rows. + internal static int DisplayLength(object value) + { + switch (value) + { + case null: return 0; + case string text: return LongestLine(text); + case bool _: return 5; + case DateTime _: + case DateTimeOffset _: return 16; +#if NET6_0_OR_GREATER + case DateOnly _: return 10; +#endif + case Enum enumValue: return enumValue.ToString().Length; + } + + if (TryFormatNumber(value, out var number) || number != null) + return number.Length; + return value is IFormattable formattable + ? formattable.ToString(null, CultureInfo.InvariantCulture).Length + : value.ToString()?.Length ?? 0; + } + + private static int LongestLine(string text) + { + int longest = 0, current = 0; + foreach (var c in text) + { + if (c == '\n') + { + current = 0; + continue; + } + + if (++current > longest) + longest = current; + } + + return longest; + } + } +} diff --git a/SW.ExportToExcel/Internal/XmlText.cs b/SW.ExportToExcel/Internal/XmlText.cs new file mode 100644 index 0000000..ba2946c --- /dev/null +++ b/SW.ExportToExcel/Internal/XmlText.cs @@ -0,0 +1,75 @@ +using System.Text; + +namespace SW.ExportToExcel.Internal +{ + internal static class XmlText + { + // Excel refuses to open a file holding a longer cell. + internal const int MaxCellLength = 32767; + + // Removes characters XML 1.0 cannot carry (control characters, lone surrogates, U+FFFE/U+FFFF) + // and truncates to what Excel accepts. Either would otherwise abort the export or corrupt the file. + internal static string Clean(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + var firstInvalid = FirstInvalid(value); + if (firstInvalid >= 0) + { + var builder = new StringBuilder(value.Length); + builder.Append(value, 0, firstInvalid); + for (var i = firstInvalid; i < value.Length; i++) + { + var c = value[i]; + if (char.IsHighSurrogate(c)) + { + if (i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + builder.Append(c).Append(value[i + 1]); + i++; + } + } + else if (IsValid(c)) + builder.Append(c); + } + + value = builder.ToString(); + } + + if (value.Length > MaxCellLength) + { + var length = char.IsHighSurrogate(value[MaxCellLength - 1]) ? MaxCellLength - 1 : MaxCellLength; + value = value.Substring(0, length); + } + + return value; + } + + internal static bool NeedsPreserve(string value) => + value.Length > 0 && (IsXmlWhitespace(value[0]) || IsXmlWhitespace(value[value.Length - 1])); + + private static int FirstInvalid(string value) + { + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + i++; + continue; + } + + if (!IsValid(c)) + return i; + } + + return -1; + } + + private static bool IsValid(char c) => + (c >= 0x20 && c <= 0xD7FF) || c == '\t' || c == '\n' || c == '\r' || (c >= 0xE000 && c <= 0xFFFD); + + private static bool IsXmlWhitespace(char c) => c == ' ' || c == '\t' || c == '\n' || c == '\r'; + } +} diff --git a/SW.ExportToExcel/SW.ExportToExcel.csproj b/SW.ExportToExcel/SW.ExportToExcel.csproj index dd96b5c..4938131 100644 --- a/SW.ExportToExcel/SW.ExportToExcel.csproj +++ b/SW.ExportToExcel/SW.ExportToExcel.csproj @@ -1,17 +1,28 @@ - net8.0 - SimplyWorks.ExportToExcel - SimplyWorks.ExportToExcel - Simplify9 - MIT - icon.png - ExportToExcel provides IEnumerable extensions for exporting data to Excel using OpenXML. For full documentation, visit https://github.com/simplify9/SW-ExportToExcel + net10.0;net8.0 + latest + true + $(NoWarn);CS1591 + SimplyWorks.ExportToExcel + SimplyWorks.ExportToExcel + Simplify9 + MIT + icon.png + README.md + https://github.com/simplify9/SW-ExportToExcel + ExportToExcel provides IEnumerable extensions for exporting data to Excel (.xlsx), streamed row by row. For full documentation, visit https://github.com/simplify9/SW-ExportToExcel - + + + + + + @@ -19,6 +30,10 @@ True + + True + +