diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 0000000..6f1b78c --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,66 @@ +name: Publish NuGet package + +on: + pull_request: + branches: + - main + types: + - closed + +permissions: + contents: read + +concurrency: + group: nuget-publish + cancel-in-progress: false + +jobs: + publish: + name: Build, test, and publish + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'develop' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: nuget + permissions: + contents: read + id-token: write + + steps: + - name: Check out the merged commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore ExcelWizard.sln + + - name: Build + run: dotnet build ExcelWizard.sln --configuration Release --no-restore -p:ContinuousIntegrationBuild=true + + - name: Test + run: dotnet test ExcelWizard.sln --configuration Release --no-build --no-restore + + - name: Pack + run: dotnet pack ExcelWizard/ExcelWizard.csproj --configuration Release --no-build --no-restore --output artifacts -p:ContinuousIntegrationBuild=true + + # Request the one-hour credential immediately before publishing. + - name: Sign in to NuGet.org with OIDC + id: login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Publish package + run: >- + dotnet nuget push "artifacts/*.nupkg" + --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" + --source https://api.nuget.org/v3/index.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..77297b3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to ExcelWizard are documented in this file. + +## 3.1.0 - 2026-08-25 + +### Added + +- Added `WrapText` to `ExcelSheetColumnAttribute` for model-bound grid columns. +- Added `WrapText` to `ExcelTableColumnAttribute` for model-bound table columns. +- Added `CellStyle.WrapText` for manually built cells and retained `CellStyle.Wordwrap` as a compatible alias. +- Added automatic row-height expansion for wrapped content when no explicit row height is configured. +- Added a generated-workbook integration test project alongside the unit test suite. + +### Changed + +- Updated ClosedXML to 0.105.1 and BlazorDownloadFile to 2.4.0.2. +- Overrode BlazorDownloadFile's legacy JSON dependency chain with System.Text.Json 10.0.11 to remove a critical transitive security advisory. +- Updated the test stack while retaining FluentAssertions 7 under its Apache 2.0 license. +- Updated the API and Blazor sample applications, unit tests, and integration tests to .NET 10. +- Preserved the published library target at .NET Standard 2.1 for consumer compatibility. +- Updated cell value handling for the current ClosedXML typed-value API. + +### Fixed + +- Preserved explicit row heights when wrapped text is enabled. +- Corrected cell border style mapping to ClosedXML border values. +- Made cell-level styles consistently override row-level styles. +- Validated invalid number, currency, and date values with clear exceptions. + +### Testing + +- Added unit coverage for wrap defaults, the legacy alias, column attributes, and builder mapping. +- Added integration coverage for grid, table, and manual wrapping; automatic and explicit row heights; multiline and Persian/English text; cell content types; metadata; multiple sheets; merged cells; RTL layout; protection; fills; and borders. diff --git a/ExcelWizard.IntegrationTests/CellContentTypeWorkbookTests.cs b/ExcelWizard.IntegrationTests/CellContentTypeWorkbookTests.cs new file mode 100644 index 0000000..d371432 --- /dev/null +++ b/ExcelWizard.IntegrationTests/CellContentTypeWorkbookTests.cs @@ -0,0 +1,121 @@ +using ClosedXML.Excel; +using ExcelWizard.Models; +using ExcelWizard.Models.EWCell; +using ExcelWizard.Models.EWExcel; +using ExcelWizard.Models.EWRow; +using ExcelWizard.Models.EWSheet; + +namespace ExcelWizard.IntegrationTests; + +public class CellContentTypeWorkbookTests +{ + [Fact] + public void AllSupportedCellContentTypes_RoundTripWithExpectedExcelTypes() + { + var date = new DateTime(2026, 8, 25, 10, 30, 0, DateTimeKind.Unspecified); + var cells = new[] + { + CreateCell(1, "general", CellContentType.General), + CreateCell(2, 42.5m, CellContentType.Number), + CreateCell(3, 1234.6m, CellContentType.Currency), + CreateCell(4, date, CellContentType.GregorianDateTime), + CreateCell(5, 123, CellContentType.Text), + CreateCell(6, 25, CellContentType.Percentage), + CreateCell(7, "B1*2", CellContentType.Formula), + CreateCell(8, true, CellContentType.General), + CreateCell(9, null, CellContentType.General) + }; + + using var workbook = WorkbookTestHelper.GenerateWorkbook(BuildWorkbook(cells)); + var worksheet = workbook.Worksheet("Cell Types"); + + worksheet.Cell("A1").DataType.Should().Be(XLDataType.Text); + worksheet.Cell("A1").GetString().Should().Be("general"); + worksheet.Cell("B1").DataType.Should().Be(XLDataType.Number); + worksheet.Cell("B1").GetDouble().Should().Be(42.5); + worksheet.Cell("C1").DataType.Should().Be(XLDataType.Number); + worksheet.Cell("C1").GetDouble().Should().Be(1235); + worksheet.Cell("C1").Style.NumberFormat.Format.Should().Be("#,##0"); + worksheet.Cell("D1").DataType.Should().Be(XLDataType.DateTime); + worksheet.Cell("D1").GetDateTime().Should().Be(date); + worksheet.Cell("E1").DataType.Should().Be(XLDataType.Text); + worksheet.Cell("E1").GetString().Should().Be("123"); + worksheet.Cell("F1").DataType.Should().Be(XLDataType.Text); + worksheet.Cell("F1").GetString().Should().Be("25%"); + worksheet.Cell("G1").HasFormula.Should().BeTrue(); + worksheet.Cell("G1").FormulaA1.Should().Be("B1*2"); + worksheet.Cell("H1").DataType.Should().Be(XLDataType.Boolean); + worksheet.Cell("H1").GetBoolean().Should().BeTrue(); + worksheet.Cell("I1").IsEmpty().Should().BeTrue(); + } + + [Fact] + public void NumberContentType_ParsesNumericText() + { + var cell = CreateCell(1, "123.5", CellContentType.Number); + + using var workbook = WorkbookTestHelper.GenerateWorkbook(BuildWorkbook(new[] { cell })); + + workbook.Worksheet("Cell Types").Cell("A1").GetDouble().Should().Be(123.5); + } + + [Fact] + public void NumberContentType_RejectsNonNumericValues() + { + var cell = CreateCell(1, "not-a-number", CellContentType.Number); + + var action = () => WorkbookTestHelper.GenerateWorkbook(BuildWorkbook(new[] { cell })); + + action.Should().Throw().WithMessage("*Number CellType*"); + } + + [Fact] + public void CurrencyContentType_RejectsNonNumericValues() + { + var cell = CreateCell(1, "not-currency", CellContentType.Currency); + + var action = () => WorkbookTestHelper.GenerateWorkbook(BuildWorkbook(new[] { cell })); + + action.Should().Throw().WithMessage("*Currency CellType*"); + } + + [Fact] + public void GregorianDateTimeContentType_RejectsNonDateValues() + { + var cell = CreateCell(1, "2026-08-25", CellContentType.GregorianDateTime); + + var action = () => WorkbookTestHelper.GenerateWorkbook(BuildWorkbook(new[] { cell })); + + action.Should().Throw().WithMessage("*GregorianDateTime CellType*"); + } + + private static Cell CreateCell(int column, object? value, CellContentType contentType) + { + return CellBuilder.SetLocation(column, 1) + .SetValue(value) + .SetContentType(contentType) + .Build(); + } + + private static IExcelBuilder BuildWorkbook(IEnumerable cells) + { + var row = RowBuilder + .SetCells(cells.Cast().ToList()) + .RowHasNoMerging() + .RowHasNoCustomStyle() + .Build(); + var sheet = SheetBuilder + .SetName("Cell Types") + .SetRows(row) + .NoMoreTablesRowsOrCells() + .SheetHasNoCustomStyle() + .Build(); + + return ExcelBuilder + .SetGeneratedFileName("cell-types") + .CreateComplexLayoutExcel() + .SetSheets(sheet) + .SheetsHaveNoDefaultStyle() + .Build(); + } +} diff --git a/ExcelWizard.IntegrationTests/ExcelWizard.IntegrationTests.csproj b/ExcelWizard.IntegrationTests/ExcelWizard.IntegrationTests.csproj new file mode 100644 index 0000000..05c003a --- /dev/null +++ b/ExcelWizard.IntegrationTests/ExcelWizard.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + Exe + enable + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/ExcelWizard.IntegrationTests/Usings.cs b/ExcelWizard.IntegrationTests/Usings.cs new file mode 100644 index 0000000..7b25ddd --- /dev/null +++ b/ExcelWizard.IntegrationTests/Usings.cs @@ -0,0 +1,2 @@ +global using FluentAssertions; +global using Xunit; diff --git a/ExcelWizard.IntegrationTests/WorkbookRegressionTests.cs b/ExcelWizard.IntegrationTests/WorkbookRegressionTests.cs new file mode 100644 index 0000000..9571186 --- /dev/null +++ b/ExcelWizard.IntegrationTests/WorkbookRegressionTests.cs @@ -0,0 +1,97 @@ +using System.Drawing; +using ClosedXML.Excel; +using ExcelWizard.Models; +using ExcelWizard.Models.EWCell; +using ExcelWizard.Models.EWExcel; +using ExcelWizard.Models.EWMerge; +using ExcelWizard.Models.EWRow; +using ExcelWizard.Models.EWSheet; +using ExcelWizard.Models.EWStyles; +using ExcelWizard.Service; + +namespace ExcelWizard.IntegrationTests; + +public class WorkbookRegressionTests +{ + [Fact] + public void GeneratedFile_PreservesRequestedMetadataAndCanBeReopened() + { + var service = new ExcelWizardService(new FakeBlazorDownloadFileService()); + var file = service.GenerateExcel(BuildComplexWorkbook()); + + file.FileName.Should().Be("regression-workbook"); + file.Extension.Should().Be("xlsx"); + file.MimeType.Should().Be("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + file.Content.Should().NotBeNullOrEmpty(); + + using var workbook = new XLWorkbook(new MemoryStream(file.Content!)); + workbook.Worksheets.Count.Should().Be(2); + } + + [Fact] + public void ComplexWorkbook_RoundTripsMergesStylesDirectionAndProtection() + { + using var workbook = WorkbookTestHelper.GenerateWorkbook(BuildComplexWorkbook()); + var worksheet = workbook.Worksheet("Protected RTL"); + + worksheet.RightToLeft.Should().BeTrue(); + worksheet.Protection.IsProtected.Should().BeTrue(); + worksheet.Range("A1:B1").IsMerged().Should().BeTrue(); + worksheet.Cell("A1").GetString().Should().Be("Merged title"); + worksheet.Cell("A1").Style.Fill.BackgroundColor.Color.ToArgb().Should().Be(Color.LightBlue.ToArgb()); + worksheet.Cell("A2").Style.Border.LeftBorder.Should().Be(XLBorderStyleValues.Thick); + workbook.Worksheet("Second Sheet").Cell("A1").GetString().Should().Be("second"); + } + + private static IExcelBuilder BuildComplexWorkbook() + { + var titleRow = RowBuilder + .SetCells( + CellBuilder.SetLocation("A", 1).SetValue("Merged title").Build(), + CellBuilder.SetLocation("B", 1).Build()) + .RowHasNoMerging() + .SetRowStyle(new RowStyle { BackgroundColor = Color.LightBlue }) + .Build(); + var dataRow = RowBuilder + .SetCells( + CellBuilder.SetLocation("A", 2) + .SetValue("bordered") + .SetCellStyle(new CellStyle + { + CellBorder = new Border(LineStyle.Thick, Color.DarkBlue) + }) + .Build()) + .RowHasNoMerging() + .RowHasNoCustomStyle() + .Build(); + var merge = MergeBuilder + .SetMergingStartPoint("A", 1) + .SetMergingFinishPoint("B", 1) + .SetMergingAreaBackgroundColor(Color.LightBlue) + .Build(); + var protectedSheet = SheetBuilder + .SetName("Protected RTL") + .SetRows(titleRow, dataRow) + .NoMoreTablesRowsOrCells() + .SetSheetStyle(new SheetStyle { SheetDirection = SheetDirection.RightToLeft }) + .SetMergedCells(merge) + .SetSheetLocked(true) + .SetSheetProtected() + .SetProtectionLevel(new ProtectionLevel { Password = "test", HardProtect = true }) + .Build(); + var secondSheet = SheetBuilder + .SetName("Second Sheet") + .SetCells(CellBuilder.SetLocation("A", 1).SetValue("second").Build()) + .NoMoreTablesRowsOrCells() + .SheetHasNoCustomStyle() + .Build(); + + return ExcelBuilder + .SetGeneratedFileName("regression-workbook") + .CreateComplexLayoutExcel() + .SetSheets(protectedSheet, secondSheet) + .SheetsHaveNoDefaultStyle() + .SetDefaultLockedStatus(false) + .Build(); + } +} diff --git a/ExcelWizard.IntegrationTests/WorkbookTestHelper.cs b/ExcelWizard.IntegrationTests/WorkbookTestHelper.cs new file mode 100644 index 0000000..070d1d0 --- /dev/null +++ b/ExcelWizard.IntegrationTests/WorkbookTestHelper.cs @@ -0,0 +1,18 @@ +using ClosedXML.Excel; +using ExcelWizard.Models; +using ExcelWizard.Service; + +namespace ExcelWizard.IntegrationTests; + +internal static class WorkbookTestHelper +{ + public static XLWorkbook GenerateWorkbook(IExcelBuilder builder) + { + var service = new ExcelWizardService(new FakeBlazorDownloadFileService()); + var generatedFile = service.GenerateExcel(builder); + + generatedFile.Content.Should().NotBeNullOrEmpty(); + + return new XLWorkbook(new MemoryStream(generatedFile.Content!)); + } +} diff --git a/ExcelWizard.IntegrationTests/WrapTextWorkbookTests.cs b/ExcelWizard.IntegrationTests/WrapTextWorkbookTests.cs new file mode 100644 index 0000000..af5c508 --- /dev/null +++ b/ExcelWizard.IntegrationTests/WrapTextWorkbookTests.cs @@ -0,0 +1,215 @@ +using ClosedXML.Excel; +using ExcelWizard.Models; +using ExcelWizard.Models.EWCell; +using ExcelWizard.Models.EWExcel; +using ExcelWizard.Models.EWGridLayout; +using ExcelWizard.Models.EWRow; +using ExcelWizard.Models.EWSheet; +using ExcelWizard.Models.EWTable; + +namespace ExcelWizard.IntegrationTests; + +public class WrapTextWorkbookTests +{ + private const string LongComment = + "این یک توضیح طولانی برای بررسی نمایش کامل متن در چند خط است و محتوای اصلی نباید تغییر کند " + + "This paragraph also contains enough English words to require several wrapped lines in Excel."; + + [Fact] + public void GridLayout_WrapsLongTextAndAutomaticallyExpandsTheRow() + { + var builder = ExcelBuilder + .SetGeneratedFileName("grid-wrap") + .CreateGridLayoutExcel() + .WithOneSheetUsingModelBinding(new[] + { + new WrappedGridRow { Comment = LongComment, SecondaryComment = "line one\nline two\nline three", Code = "A-1" } + }) + .Build(); + + using var workbook = WorkbookTestHelper.GenerateWorkbook(builder); + var worksheet = workbook.Worksheet("Wrapped Grid"); + + worksheet.Cell(1, 1).Style.Alignment.WrapText.Should().BeFalse(); + worksheet.Cell(2, 1).Style.Alignment.WrapText.Should().BeTrue(); + worksheet.Cell(2, 2).Style.Alignment.WrapText.Should().BeTrue(); + worksheet.Cell(2, 3).Style.Alignment.WrapText.Should().BeFalse(); + worksheet.Cell(2, 1).GetString().Should().Be(LongComment); + worksheet.Cell(2, 2).GetString().Should().Be("line one\nline two\nline three"); + worksheet.Row(2).Height.Should().BeGreaterThan(worksheet.RowHeight); + } + + [Fact] + public void GridLayout_PreservesExplicitDataRowHeightWhenWrapping() + { + var builder = ExcelBuilder + .SetGeneratedFileName("grid-explicit-height") + .CreateGridLayoutExcel() + .WithOneSheetUsingModelBinding(new[] { new FixedHeightGridRow { Comment = LongComment } }) + .Build(); + + using var workbook = WorkbookTestHelper.GenerateWorkbook(builder); + var worksheet = workbook.Worksheet("Fixed Height"); + + worksheet.Cell(2, 1).Style.Alignment.WrapText.Should().BeTrue(); + worksheet.Row(2).Height.Should().Be(42); + } + + [Fact] + public void ModelBoundTable_WrapsOnlyConfiguredDataColumns() + { + var table = TableBuilder + .CreateUsingAModelToBind( + new[] { new WrappedTableRow { Comment = LongComment, Code = "T-1" } }, + new CellLocation(1, 1)) + .BoundTableHasNoMerging() + .Build(); + + var sheet = SheetBuilder + .SetName("Wrapped Table") + .SetTables(table) + .NoMoreTablesRowsOrCells() + .SheetHasNoCustomStyle() + .Build(); + + var builder = ExcelBuilder + .SetGeneratedFileName("table-wrap") + .CreateComplexLayoutExcel() + .SetSheets(sheet) + .SheetsHaveNoDefaultStyle() + .Build(); + + using var workbook = WorkbookTestHelper.GenerateWorkbook(builder); + var worksheet = workbook.Worksheet("Wrapped Table"); + + worksheet.Cell(1, 1).Style.Alignment.WrapText.Should().BeFalse(); + worksheet.Cell(2, 1).Style.Alignment.WrapText.Should().BeTrue(); + worksheet.Cell(2, 2).Style.Alignment.WrapText.Should().BeFalse(); + worksheet.Cell(2, 1).GetString().Should().Be(LongComment); + worksheet.Row(2).Height.Should().BeGreaterThan(worksheet.RowHeight); + } + + [Fact] + public void ManualRow_WrapTextAutomaticallyExpandsTheRow() + { + var builder = BuildManualRowWorkbook(rowHeight: null); + + using var workbook = WorkbookTestHelper.GenerateWorkbook(builder); + var worksheet = workbook.Worksheet("Manual Wrap"); + + worksheet.Cell("A1").Style.Alignment.WrapText.Should().BeTrue(); + worksheet.Cell("A1").GetString().Should().Be(LongComment); + worksheet.Row(1).Height.Should().BeGreaterThan(worksheet.RowHeight); + } + + [Fact] + public void ManualRow_WordwrapAliasStillEnablesWrapping() + { + var cell = CellBuilder.SetLocation("A", 1) + .SetValue("line one\nline two") + .SetCellStyle(new CellStyle { Wordwrap = true }) + .Build(); + var builder = BuildManualRowWorkbook(cell, rowHeight: null); + + using var workbook = WorkbookTestHelper.GenerateWorkbook(builder); + + workbook.Worksheet("Manual Wrap").Cell("A1").Style.Alignment.WrapText.Should().BeTrue(); + } + + [Fact] + public void ManualRow_PreservesExplicitHeightWhenWrapping() + { + var builder = BuildManualRowWorkbook(rowHeight: 36); + + using var workbook = WorkbookTestHelper.GenerateWorkbook(builder); + var worksheet = workbook.Worksheet("Manual Wrap"); + + worksheet.Cell("A1").Style.Alignment.WrapText.Should().BeTrue(); + worksheet.Row(1).Height.Should().Be(36); + } + + [Fact] + public void UnconfiguredGridColumn_RemainsUnwrappedAtDefaultHeight() + { + var builder = ExcelBuilder + .SetGeneratedFileName("grid-default") + .CreateGridLayoutExcel() + .WithOneSheetUsingModelBinding(new[] { new DefaultGridRow { Comment = LongComment } }) + .Build(); + + using var workbook = WorkbookTestHelper.GenerateWorkbook(builder); + var worksheet = workbook.Worksheet("Default Grid"); + + worksheet.Cell(2, 1).Style.Alignment.WrapText.Should().BeFalse(); + worksheet.Row(2).Height.Should().Be(worksheet.RowHeight); + } + + private static IExcelBuilder BuildManualRowWorkbook(double? rowHeight) + { + var cell = CellBuilder.SetLocation("A", 1) + .SetValue(LongComment) + .SetCellStyle(new CellStyle { WrapText = true }) + .Build(); + + return BuildManualRowWorkbook(cell, rowHeight); + } + + private static IExcelBuilder BuildManualRowWorkbook(Cell cell, double? rowHeight) + { + var rowBuilder = RowBuilder + .SetCells(cell) + .RowHasNoMerging(); + var row = rowHeight is null + ? rowBuilder.RowHasNoCustomStyle().Build() + : rowBuilder.SetRowStyle(new RowStyle { RowHeight = rowHeight }).Build(); + var sheet = SheetBuilder + .SetName("Manual Wrap") + .SetRows(row) + .NoMoreTablesRowsOrCells() + .SetSheetStyle(new SheetStyle { SheetDefaultColumnWidth = 18 }) + .Build(); + + return ExcelBuilder + .SetGeneratedFileName("manual-wrap") + .CreateComplexLayoutExcel() + .SetSheets(sheet) + .SheetsHaveNoDefaultStyle() + .Build(); + } + + [ExcelSheet(SheetName = "Wrapped Grid")] + private sealed class WrappedGridRow + { + [ExcelSheetColumn(HeaderName = "Comment", ColumnWidth = 24, WrapText = true)] + public string? Comment { get; init; } + + [ExcelSheetColumn(HeaderName = "Secondary", ColumnWidth = 18, WrapText = true)] + public string? SecondaryComment { get; init; } + + [ExcelSheetColumn(HeaderName = "Code", ColumnWidth = 12)] + public string? Code { get; init; } + } + + [ExcelSheet(SheetName = "Fixed Height", DataRowHeight = 42)] + private sealed class FixedHeightGridRow + { + [ExcelSheetColumn(ColumnWidth = 18, WrapText = true)] + public string? Comment { get; init; } + } + + [ExcelSheet(SheetName = "Default Grid")] + private sealed class DefaultGridRow + { + [ExcelSheetColumn(ColumnWidth = 18)] + public string? Comment { get; init; } + } + + [ExcelTable] + private sealed class WrappedTableRow + { + [ExcelTableColumn(HeaderName = "Comment", WrapText = true)] + public string? Comment { get; init; } + + public string? Code { get; init; } + } +} diff --git a/ExcelWizard.Tests/ExcelWizard.Tests.csproj b/ExcelWizard.Tests/ExcelWizard.Tests.csproj index f9c3006..02fc177 100644 --- a/ExcelWizard.Tests/ExcelWizard.Tests.csproj +++ b/ExcelWizard.Tests/ExcelWizard.Tests.csproj @@ -1,7 +1,8 @@ - net6.0 + net10.0 + Exe enable enable @@ -9,15 +10,15 @@ - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/ExcelWizard.Tests/Models/WrapTextTests.cs b/ExcelWizard.Tests/Models/WrapTextTests.cs new file mode 100644 index 0000000..dafaa20 --- /dev/null +++ b/ExcelWizard.Tests/Models/WrapTextTests.cs @@ -0,0 +1,96 @@ +using ExcelWizard.Models; +using ExcelWizard.Models.EWCell; +using ExcelWizard.Models.EWExcel; +using ExcelWizard.Models.EWGridLayout; +using ExcelWizard.Models.EWTable; + +namespace ExcelWizard.Tests.Models; + +public class WrapTextTests +{ + [Fact] + public void CellStyle_DefaultsToNotWrapped() + { + var style = new CellStyle(); + + style.WrapText.Should().BeFalse(); + style.Wordwrap.Should().BeFalse(); + } + + [Fact] + public void CellStyle_WordwrapAliasUpdatesWrapText() + { + var style = new CellStyle { Wordwrap = true }; + + style.WrapText.Should().BeTrue(); + } + + [Fact] + public void CellStyle_WrapTextUpdatesWordwrapAlias() + { + var style = new CellStyle { WrapText = true }; + + style.Wordwrap.Should().BeTrue(); + } + + [Fact] + public void ColumnAttributes_DefaultToNotWrapped() + { + new ExcelSheetColumnAttribute().WrapText.Should().BeFalse(); + new ExcelTableColumnAttribute().WrapText.Should().BeFalse(); + } + + [Fact] + public void GridBuilder_MapsWrapTextToDataCellOnly() + { + var excel = (ExcelModel)ExcelBuilder + .SetGeneratedFileName("grid-wrap") + .CreateGridLayoutExcel() + .WithOneSheetUsingModelBinding(new[] { new GridRow { Comment = "long comment", Code = "A1" } }) + .Build(); + + var sheet = excel.Sheets.Single(); + var headerRow = sheet.SheetRows.Single(); + var dataRow = sheet.SheetTables.Single().TableRows.Single(); + + headerRow.RowCells.Should().OnlyContain(cell => !cell.CellStyle.WrapText); + dataRow.RowCells.Single(cell => Equals(cell.CellValue, "long comment")).CellStyle.WrapText.Should().BeTrue(); + dataRow.RowCells.Single(cell => Equals(cell.CellValue, "A1")).CellStyle.WrapText.Should().BeFalse(); + } + + [Fact] + public void TableBuilder_MapsWrapTextToDataCellOnly() + { + var table = (Table)TableBuilder + .CreateUsingAModelToBind( + new[] { new TableRow { Comment = "long comment", Code = "A1" } }, + new CellLocation(1, 1)) + .BoundTableHasNoMerging() + .Build(); + + var headerRow = table.TableRows.First(); + var dataRow = table.TableRows.Last(); + + headerRow.RowCells.Should().OnlyContain(cell => !cell.CellStyle.WrapText); + dataRow.RowCells.Single(cell => Equals(cell.CellValue, "long comment")).CellStyle.WrapText.Should().BeTrue(); + dataRow.RowCells.Single(cell => Equals(cell.CellValue, "A1")).CellStyle.WrapText.Should().BeFalse(); + } + + [ExcelSheet] + private sealed class GridRow + { + [ExcelSheetColumn(WrapText = true)] + public string? Comment { get; init; } + + public string? Code { get; init; } + } + + [ExcelTable] + private sealed class TableRow + { + [ExcelTableColumn(WrapText = true)] + public string? Comment { get; init; } + + public string? Code { get; init; } + } +} diff --git a/ExcelWizard.Tests/UnitTest1.cs b/ExcelWizard.Tests/UnitTest1.cs deleted file mode 100644 index afae220..0000000 --- a/ExcelWizard.Tests/UnitTest1.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ExcelWizard.Tests -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - - } - } -} \ No newline at end of file diff --git a/ExcelWizard.sln b/ExcelWizard.sln index 4783243..d2384c5 100644 --- a/ExcelWizard.sln +++ b/ExcelWizard.sln @@ -16,6 +16,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8C405FC4-D38B-493C-94B1-43005080B037}" ProjectSection(SolutionItems) = preProject .gitignore = .gitignore + CHANGELOG.md = CHANGELOG.md README.md = README.md EndProjectSection EndProject @@ -38,6 +39,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Templates", "Templates", "{ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelWizard.Tests", "ExcelWizard.Tests\ExcelWizard.Tests.csproj", "{0BFE5D3A-D0D4-492E-A28B-09A7B6621FEA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelWizard.IntegrationTests", "ExcelWizard.IntegrationTests\ExcelWizard.IntegrationTests.csproj", "{C7A90D2E-4B58-4C39-AF89-D2D4E0A5C1F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -60,6 +63,10 @@ Global {0BFE5D3A-D0D4-492E-A28B-09A7B6621FEA}.Debug|Any CPU.Build.0 = Debug|Any CPU {0BFE5D3A-D0D4-492E-A28B-09A7B6621FEA}.Release|Any CPU.ActiveCfg = Release|Any CPU {0BFE5D3A-D0D4-492E-A28B-09A7B6621FEA}.Release|Any CPU.Build.0 = Release|Any CPU + {C7A90D2E-4B58-4C39-AF89-D2D4E0A5C1F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7A90D2E-4B58-4C39-AF89-D2D4E0A5C1F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7A90D2E-4B58-4C39-AF89-D2D4E0A5C1F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7A90D2E-4B58-4C39-AF89-D2D4E0A5C1F2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ExcelWizard/ExcelWizard.csproj b/ExcelWizard/ExcelWizard.csproj index 530cc6e..2e62fd4 100644 --- a/ExcelWizard/ExcelWizard.csproj +++ b/ExcelWizard/ExcelWizard.csproj @@ -2,7 +2,7 @@ netstandard2.1 - 3.0.9 + 3.1.0 latest $(NoWarn);NU1701;1702;1591;NU1602;CS8609;CS8610;CS8619;CS8632 enable @@ -41,9 +41,11 @@ - - - + + + + + diff --git a/ExcelWizard/Models/EWCell/CellStyle.cs b/ExcelWizard/Models/EWCell/CellStyle.cs index 1a5a361..6b682b8 100644 --- a/ExcelWizard/Models/EWCell/CellStyle.cs +++ b/ExcelWizard/Models/EWCell/CellStyle.cs @@ -5,15 +5,30 @@ namespace ExcelWizard.Models; public class CellStyle { + private bool _wrapText; + /// /// Set Font for the Cell. It will override the Table and Row Fonts. Default inherit /// public TextFont? Font { get; set; } /// - /// Set Wordwrap for the Cell content. Default is false + /// Set text wrapping for the Cell content. Default is false. + /// + public bool WrapText + { + get => _wrapText; + set => _wrapText = value; + } + + /// + /// Compatibility alias for . /// - public bool Wordwrap { get; set; } + public bool Wordwrap + { + get => WrapText; + set => WrapText = value; + } public TextAlign? CellTextAlign { get; set; } diff --git a/ExcelWizard/Models/EWExcel/ExcelBuilder.cs b/ExcelWizard/Models/EWExcel/ExcelBuilder.cs index 59b319d..0c0d33b 100644 --- a/ExcelWizard/Models/EWExcel/ExcelBuilder.cs +++ b/ExcelWizard/Models/EWExcel/ExcelBuilder.cs @@ -1,5 +1,4 @@ -using ClosedXML.Report.Utils; -using ExcelWizard.Models.EWCell; +using ExcelWizard.Models.EWCell; using ExcelWizard.Models.EWColumn; using ExcelWizard.Models.EWGridLayout; using ExcelWizard.Models.EWRow; @@ -154,7 +153,7 @@ private ExcelModel ConvertEasyGridExcelBuilderToExcelWizardBuilder(GridLayoutExc { var excelWizardBuilder = new ExcelModel(); - if (gridLayoutExcelModel.GeneratedFileName.IsNullOrWhiteSpace() is false) + if (string.IsNullOrWhiteSpace(gridLayoutExcelModel.GeneratedFileName) is false) excelWizardBuilder.GeneratedFileName = gridLayoutExcelModel.GeneratedFileName; foreach (var gridExcelSheet in gridLayoutExcelModel.Sheets) @@ -271,7 +270,8 @@ TextAlign GetCellTextAlign(TextAlign defaultAlign, TextAlign? headerOrDataTextAl // Header if (isHeaderAlreadyCalculated is false) { - var headerFont = JsonSerializer.Deserialize(JsonSerializer.Serialize(finalFont)); + var headerFont = JsonSerializer.Deserialize(JsonSerializer.Serialize(finalFont)) + ?? new TextFont(); headerFont.IsBold = excelSheetColumnAttribute is null || excelSheetColumnAttribute.FontWeight == FontWeight.Inherit ? defaultFontWeight != FontWeight.Normal @@ -318,6 +318,7 @@ TextAlign GetCellTextAlign(TextAlign defaultAlign, TextAlign? headerOrDataTextAl .SetCellStyle(new CellStyle { Font = finalFont, + WrapText = excelSheetColumnAttribute?.WrapText ?? false, CellTextAlign = GetCellTextAlign(defaultTextAlign, excelSheetColumnAttribute?.DataTextAlign) }) diff --git a/ExcelWizard/Models/EWGridLayout/ExcelSheetColumnAttribute.cs b/ExcelWizard/Models/EWGridLayout/ExcelSheetColumnAttribute.cs index 67bb41f..41f8747 100644 --- a/ExcelWizard/Models/EWGridLayout/ExcelSheetColumnAttribute.cs +++ b/ExcelWizard/Models/EWGridLayout/ExcelSheetColumnAttribute.cs @@ -30,6 +30,11 @@ public class ExcelSheetColumnAttribute : Attribute /// public TextAlign DataTextAlign { get; set; } = TextAlign.Inherit; + /// + /// Wrap data cell text within the column width. Default is false. + /// + public bool WrapText { get; set; } + /// /// Excel Data Type. Default is Text type /// diff --git a/ExcelWizard/Models/EWTable/ExcelTableColumnAttribute.cs b/ExcelWizard/Models/EWTable/ExcelTableColumnAttribute.cs index 0ffbe02..4f80011 100644 --- a/ExcelWizard/Models/EWTable/ExcelTableColumnAttribute.cs +++ b/ExcelWizard/Models/EWTable/ExcelTableColumnAttribute.cs @@ -31,6 +31,11 @@ public class ExcelTableColumnAttribute : Attribute /// public TextAlign DataTextAlign { get; set; } = TextAlign.Inherit; + /// + /// Wrap data cell text within the column width. Default is false. + /// + public bool WrapText { get; set; } + /// /// Table column data type. Default is Text type /// diff --git a/ExcelWizard/Models/EWTable/TableBuilder.cs b/ExcelWizard/Models/EWTable/TableBuilder.cs index 572a061..987bad3 100644 --- a/ExcelWizard/Models/EWTable/TableBuilder.cs +++ b/ExcelWizard/Models/EWTable/TableBuilder.cs @@ -191,6 +191,7 @@ TextAlign GetCellTextAlign(TextAlign defaultAlign, TextAlign? headerOrDataTextAl .SetCellStyle(new CellStyle { Font = finalFont, + WrapText = excelTableColumnAttribute?.WrapText ?? false, CellTextAlign = GetCellTextAlign(tableDefaultTextAlign, excelTableColumnAttribute?.DataTextAlign) }) diff --git a/ExcelWizard/Service/ExcelWizardService.cs b/ExcelWizard/Service/ExcelWizardService.cs index b9668f0..b63e758 100644 --- a/ExcelWizard/Service/ExcelWizardService.cs +++ b/ExcelWizard/Service/ExcelWizardService.cs @@ -1,6 +1,5 @@ using BlazorDownloadFile; using ClosedXML.Excel; -using ClosedXML.Report.Utils; using ExcelWizard.Models; using ExcelWizard.Models.EWCell; using ExcelWizard.Models.EWColumn; @@ -9,6 +8,7 @@ using ExcelWizard.Models.EWStyles; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -41,7 +41,7 @@ public GeneratedExcelFile GenerateExcel(IExcelBuilder excelBuilder) var content = stream.ToArray(); - if (excelModel.GeneratedFileName.IsNullOrWhiteSpace()) + if (string.IsNullOrWhiteSpace(excelModel.GeneratedFileName)) excelModel.GeneratedFileName = $"ExcelWizard_{DateTime.Now:yyyy-MM-dd HH-mm-ss}"; return new GeneratedExcelFile { FileName = excelModel.GeneratedFileName, Content = content }; @@ -53,7 +53,7 @@ public string GenerateExcel(IExcelBuilder excelBuilder, string savePath) using var xlWorkbook = ClosedXmlEngine(excelModel); - if (excelModel.GeneratedFileName.IsNullOrWhiteSpace()) + if (string.IsNullOrWhiteSpace(excelModel.GeneratedFileName)) excelModel.GeneratedFileName = $"ExcelWizard_{DateTime.Now:yyyy-MM-dd HH-mm-ss}"; var saveUrl = $"{savePath}\\{excelModel.GeneratedFileName}.xlsx"; @@ -91,7 +91,7 @@ private XLWorkbook ClosedXmlEngine(ExcelModel excelModel) // Check sheet names are unique var sheetNames = excelModel.Sheets - .Where(s => s.SheetName.IsNullOrWhiteSpace() is false) + .Where(s => string.IsNullOrWhiteSpace(s.SheetName) is false) .Select(s => s.SheetName) .ToList(); @@ -105,7 +105,7 @@ private XLWorkbook ClosedXmlEngine(ExcelModel excelModel) int i = 1; foreach (Sheet sheet in excelModel.Sheets) { - if (sheet.SheetName.IsNullOrWhiteSpace()) + if (string.IsNullOrWhiteSpace(sheet.SheetName)) { var isNameOk = false; @@ -129,7 +129,7 @@ private XLWorkbook ClosedXmlEngine(ExcelModel excelModel) foreach (var sheet in excelModel.Sheets) { // Set name - var xlSheet = xlWorkbook.Worksheets.Add(sheet.SheetName); + var xlSheet = xlWorkbook.Worksheets.Add(sheet.SheetName!); // Set protection level @@ -286,7 +286,7 @@ XLSheetProtectionElements AddToFlag(XLSheetProtectionElements allowedElements, X if (table.TableStyle.Font?.IsBold is not null) tableRange.Style.Font.SetBold(table.TableStyle.Font.IsBold.Value); - if (table.TableStyle.Font?.FontName.IsNullOrWhiteSpace() is false) + if (string.IsNullOrWhiteSpace(table.TableStyle.Font?.FontName) is false) tableRange.Style.Font.SetFontName(table.TableStyle.Font.FontName); // Config Outside-Border @@ -388,7 +388,7 @@ XLSheetProtectionElements AddToFlag(XLSheetProtectionElements allowedElements, X var value = rangeToMerge.FirstOrDefault(r => r.IsEmpty() is false)?.Value; - rangeToMerge.First().SetValue(value); + rangeToMerge.First().SetValue(value ?? default); var mergedSheetRange = xlSheet.Range(firstCellRow, firstCellColumn, lastCellRow, lastCellColumn).Merge(); @@ -418,43 +418,6 @@ XLSheetProtectionElements AddToFlag(XLSheetProtectionElements allowedElements, X private void ConfigureCell(IXLWorksheet xlSheet, Cell cell, List columnProps, bool isSheetLocked) { - // Infer XLDataType and value from "cell" CellType - XLDataType? xlDataType; - object? cellValue = cell.CellValue; - switch (cell.CellContentType) - { - case CellContentType.Number: - xlDataType = XLDataType.Number; - break; - - case CellContentType.Percentage: - xlDataType = XLDataType.Text; - cellValue = $"{cellValue}%"; - break; - - case CellContentType.Currency: - xlDataType = XLDataType.Number; - if (IsNumber(cellValue) is false) - throw new Exception("Cell with Currency CellType should be Number type"); - cellValue = Convert.ToDecimal(cellValue).ToString("N0"); - break; - - case CellContentType.GregorianDateTime: - xlDataType = XLDataType.DateTime; - if (cellValue is not DateTime) - throw new Exception("Cell with GregorianDateTime CellType should be DateTime type"); - break; - - case CellContentType.Text: - case CellContentType.Formula: - xlDataType = XLDataType.Text; - break; - - default: // = CellType.General - xlDataType = null; - break; - } - // Infer XLAlignment from "cell" XLAlignmentHorizontalValues? cellAlignmentHorizontalValue = cell.CellStyle.CellTextAlign switch { @@ -487,15 +450,15 @@ private void ConfigureCell(IXLWorksheet xlSheet, Cell cell, List co //------------------------------------------- var locationCell = xlSheet.Cell(cell.CellLocation.RowNumber, cell.CellLocation.ColumnNumber); - if (xlDataType is not null) - locationCell.SetDataType((XLDataType)xlDataType); - if (cell.CellContentType == CellContentType.Formula) - locationCell.SetFormulaA1(cellValue?.ToString()); + locationCell.FormulaA1 = cell.CellValue?.ToString(); else - locationCell.SetValue(cellValue); + locationCell.SetValue(GetClosedXmlCellValue(cell)); + + if (cell.CellContentType == CellContentType.Currency) + locationCell.Style.NumberFormat.Format = "#,##0"; - locationCell.Style.Alignment.SetWrapText(cell.CellStyle.Wordwrap); + locationCell.Style.Alignment.SetWrapText(cell.CellStyle.WrapText); locationCell.Style.Protection.Locked = (bool)isLocked; @@ -515,19 +478,20 @@ private void ConfigureCell(IXLWorksheet xlSheet, Cell cell, List co if (cell.CellStyle.Font?.IsBold is not null) locationCell.Style.Font.SetBold(cell.CellStyle.Font.IsBold.Value); - if (cell.CellStyle.Font?.FontName.IsNullOrWhiteSpace() is false) + if (string.IsNullOrWhiteSpace(cell.CellStyle.Font?.FontName) is false) locationCell.Style.Font.SetFontName(cell.CellStyle.Font.FontName); if (cell.CellStyle.BackgroundColor is not null) locationCell.Style.Fill.SetBackgroundColor(XLColor.FromColor(cell.CellStyle.BackgroundColor.Value)); // Set Border - XLBorderStyleValues? cellBorder = GetXlBorderLineStyle(cell.CellStyle.CellBorder?.BorderLineStyle); + var cellBorderStyle = cell.CellStyle.CellBorder; + XLBorderStyleValues? cellBorder = GetXlBorderLineStyle(cellBorderStyle?.BorderLineStyle); - if (cellBorder is not null) + if (cellBorder is not null && cellBorderStyle is not null) { - locationCell.Style.Border.SetOutsideBorder((XLBorderStyleValues)cell.CellStyle.CellBorder!.BorderLineStyle); - locationCell.Style.Border.SetOutsideBorderColor(XLColor.FromColor(cell.CellStyle.CellBorder.BorderColor)); + locationCell.Style.Border.SetOutsideBorder(cellBorder.Value); + locationCell.Style.Border.SetOutsideBorderColor(XLColor.FromColor(cellBorderStyle.BorderColor)); } } @@ -535,14 +499,6 @@ private void ConfigureRow(IXLWorksheet xlSheet, Row row, List colum { row.ValidateRowInstance(); - foreach (var rowCell in row.RowCells) - { - if (rowCell.IsCellVisible is false) - continue; - - ConfigureCell(xlSheet, rowCell, columnsStyleList, isSheetLocked); - } - if (row.RowCells.Count != 0) { var xlRow = xlSheet.Row(row.RowCells.First().CellLocation.RowNumber); @@ -563,7 +519,7 @@ private void ConfigureRow(IXLWorksheet xlSheet, Row row, List colum if (row.RowStyle.Font?.IsBold is not null) xlRowRange.Style.Font.SetBold(row.RowStyle.Font.IsBold.Value); - if (row.RowStyle.Font?.FontName.IsNullOrWhiteSpace() is false) + if (string.IsNullOrWhiteSpace(row.RowStyle.Font?.FontName) is false) xlRowRange.Style.Font.SetFontName(row.RowStyle.Font.FontName); if (row.RowStyle.BackgroundColor is not null) @@ -596,6 +552,25 @@ private void ConfigureRow(IXLWorksheet xlSheet, Row row, List colum _ => throw new ArgumentOutOfRangeException() }; } + + // Cell styles have the highest precedence, so apply them after row-level styles. + foreach (var rowCell in row.RowCells) + { + if (rowCell.IsCellVisible is false) + continue; + + ConfigureCell(xlSheet, rowCell, columnsStyleList, isSheetLocked); + } + + if (row.RowStyle.RowHeight is null) + { + var wrappedCells = row.RowCells + .Where(cell => cell.IsCellVisible && cell.CellStyle.WrapText) + .ToList(); + + if (wrappedCells.Count > 0) + AdjustWrappedRowHeight(xlSheet, xlRow, wrappedCells); + } } // Configure merged cells in the row @@ -664,5 +639,73 @@ private bool IsNumber(object? value) || value is decimal; } + private XLCellValue GetClosedXmlCellValue(Cell cell) + { + var value = cell.CellValue; + + return cell.CellContentType switch + { + CellContentType.Number => GetNumberCellValue(value), + CellContentType.Percentage => $"{value}%", + CellContentType.Currency when IsNumber(value) => Convert.ToDouble( + decimal.Round(Convert.ToDecimal(value, CultureInfo.CurrentCulture), 0), + CultureInfo.CurrentCulture), + CellContentType.Currency => throw new Exception("Cell with Currency CellType should be Number type"), + CellContentType.GregorianDateTime when value is DateTime dateTime => dateTime, + CellContentType.GregorianDateTime => throw new Exception("Cell with GregorianDateTime CellType should be DateTime type"), + CellContentType.Text when value is not null => value.ToString() ?? string.Empty, + CellContentType.Text => default, + _ when value is not null => XLCellValue.FromObject(value, CultureInfo.CurrentCulture), + _ => default + }; + } + + private static XLCellValue GetNumberCellValue(object? value) + { + if (value is null) + return default; + + if (value is sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal) + return XLCellValue.FromObject(value, CultureInfo.CurrentCulture); + + if (double.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.CurrentCulture, out var number)) + return number; + + throw new Exception("Cell with Number CellType should be Number type"); + } + + private static void AdjustWrappedRowHeight(IXLWorksheet xlSheet, IXLRow xlRow, List wrappedCells) + { + var wrappedColumnNumbers = wrappedCells + .Select(cell => cell.CellLocation.ColumnNumber) + .ToList(); + + xlRow.AdjustToContents(wrappedColumnNumbers.Min(), wrappedColumnNumbers.Max()); + + const double defaultFontSize = 11; + const double maximumExcelRowHeight = 409.5; + + var estimatedHeight = wrappedCells.Max(cell => + { + var text = Convert.ToString(cell.CellValue, CultureInfo.CurrentCulture) ?? string.Empty; + + if (text.Length == 0) + return xlSheet.RowHeight; + + var columnNumber = cell.CellLocation.ColumnNumber; + var columnWidth = Math.Max(1, xlSheet.Column(columnNumber).Width); + var lineCount = text + .Replace("\r", string.Empty) + .Split('\n') + .Sum(line => Math.Max(1, (int)Math.Ceiling(line.Replace("\t", " ").Length / columnWidth))); + var fontSize = xlSheet.Cell(cell.CellLocation.RowNumber, columnNumber).Style.Font.FontSize; + var fontScale = Math.Max(1, fontSize / defaultFontSize); + + return xlSheet.RowHeight * lineCount * fontScale; + }); + + xlRow.Height = Math.Min(maximumExcelRowHeight, Math.Max(xlRow.Height, estimatedHeight)); + } + #endregion -} \ No newline at end of file +} diff --git a/ExcelWizard/Service/FakeBlazorDownloadFileService.cs b/ExcelWizard/Service/FakeBlazorDownloadFileService.cs index 6470a19..5ec5a95 100644 --- a/ExcelWizard/Service/FakeBlazorDownloadFileService.cs +++ b/ExcelWizard/Service/FakeBlazorDownloadFileService.cs @@ -296,4 +296,98 @@ public ValueTask DownloadFileFromText(string fileName, strin { throw new InvalidOperationException("You cannot invoke method in not Blazor context"); } -} \ No newline at end of file + + public ValueTask DownloadFile(string fileName, string bytesBase64, int bufferSize, + string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, string bytesBase64, CancellationToken cancellationToken, + int bufferSize, string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, string bytesBase64, TimeSpan timeOut, + int bufferSize, string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, byte[] bytes, int bufferSize, + string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, byte[] bytes, CancellationToken cancellationToken, + int bufferSize, string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, byte[] bytes, TimeSpan timeOut, + int bufferSize, string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, IEnumerable bytes, int bufferSize, + string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, IEnumerable bytes, CancellationToken cancellationToken, + int bufferSize, string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, IEnumerable bytes, TimeSpan timeOut, + int bufferSize, string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, Stream stream, int bufferSize, + string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, Stream stream, + CancellationToken cancellationTokenBytesRead, CancellationToken cancellationTokenJavaScriptInterop, + int bufferSize, string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFile(string fileName, Stream stream, + CancellationToken cancellationTokenBytesRead, TimeSpan timeOutJavaScriptInterop, + int bufferSize, string contentType, Func progress) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFileFromText(string fileName, string plainText, Encoding encoding, + int bufferSize, string contentType, Func progress, bool encoderShouldEmitIdentifier) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFileFromText(string fileName, string plainText, Encoding encoding, + CancellationToken cancellationToken, int bufferSize, string contentType, Func progress, + bool encoderShouldEmitIdentifier) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } + + public ValueTask DownloadFileFromText(string fileName, string plainText, Encoding encoding, + TimeSpan timeOut, int bufferSize, string contentType, Func progress, + bool encoderShouldEmitIdentifier) + { + throw new InvalidOperationException("You cannot invoke method in not Blazor context"); + } +} diff --git a/README.md b/README.md index ced4b5f..9c30fa0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ Using ExcelWizard, you can easily generate Excel file in a very simple and straightforward way, even without any previous Excel knowledge. In addition, make the generated Excel file directly downloadable from Browser without any hassle in case of using Blazor application. The package is a wrapper for ClosedXML and BlazorDownloadFile packages. +## What's new in 3.1.0 + +- Wrap long or multiline cell text from grid attributes, table attributes, and manually built cells. +- Automatically expand wrapped rows so all lines are visible, while respecting explicitly configured row heights. +- Updated the core dependencies, test infrastructure, and sample applications. + +See [CHANGELOG.md](CHANGELOG.md) for the complete release notes. + +## Compatibility + +ExcelWizard 3.1.0 continues to target .NET Standard 2.1, so updating the package does not require consumers to move to a new target framework. The repository's sample and test applications use .NET 10. The package uses ClosedXML 0.105.1 and BlazorDownloadFile 2.4.0.2. + ## Version >= 3.0.0 Breakthrough Changes #### The package has completely rewritten with advanced *builder design pattern* to be more user friendly and easier to use and extremely less complex with some added new features like easily and dynamically create Table component using model binding. @@ -252,12 +264,21 @@ public class User public string? PersonnelCode { get; set; } public string? Nationality { get; set; } + + [ExcelSheetColumn(HeaderName = "Comment", ColumnWidth = 35, WrapText = true)] + public string? Comment { get; set; } [ExcelSheetColumn(Ignore = true)] public string? Age { get; set; } } ``` +### Wrap long text in a grid + +Set `WrapText = true` on an `ExcelSheetColumn` when a column contains comments, paragraphs, explicit line breaks, or other long text. Wrapping applies to the data cells in that column; it does not modify the stored text or wrap the header. + +When `ExcelSheet.DataRowHeight` is not set, ExcelWizard automatically increases the height of rows containing wrapped text so every line is visible. An explicit `DataRowHeight` takes precedence. Giving comment columns a practical `ColumnWidth`, as shown above, produces more predictable wrapping. + The Result: @@ -445,9 +466,14 @@ public class AccountDebitCredit [ExcelTableColumn(DataContentType = CellContentType.Currency, Ignore = false)] public decimal Credit { get; set; } + + [ExcelTableColumn(HeaderName = "Comment", WrapText = true)] + public string? Comment { get; set; } } ``` +`ExcelTableColumn.WrapText` has the same data-cell behavior for model-bound tables. Rows are automatically expanded unless the table's `RowStyle.RowHeight` supplies an explicit height. + **3- Table: Blue bg (+yellow at the end) table**. It is a fat table by the way! ```csharp var tableBlueBg = TableBuilder @@ -677,6 +703,7 @@ ICellBuilder cellUserName = CellBuilder .SetCellStyle(new CellStyle { BackgroundColor = Color.DarkGreen, + WrapText = true, Font = new TextFont { FontColor = Color.White @@ -690,6 +717,8 @@ ICellBuilder cellUserName = CellBuilder .Build(); ``` +Use `CellStyle.WrapText` for manually built cells and rows. The existing `CellStyle.Wordwrap` property remains available as a compatible alias and controls the same setting. + ## *3- Create `IExcelBuilder`* Then we create our main model by using the Excel sub-sections builders created in Step 2 plus other styles that are available in this builder. @@ -717,3 +746,19 @@ At last, we create our gorgeous Excel! by injecting `IExcelWizardService` and us ```csharp return Ok(_excelWizardService.GenerateExcel(excelBuilder, @"C:\GeneratedExcelSamples")); ``` + +## Build and test + +Install the .NET 10 SDK, then run the complete unit and generated-workbook integration test suites: + +```bash +dotnet restore ExcelWizard.sln +dotnet test ExcelWizard.sln -c Release --no-restore +dotnet build ExcelWizard.sln -c Release --no-restore +``` + +The integration tests save generated workbooks in memory, reopen them with ClosedXML, and verify their values, styles, wrapping, row heights, metadata, protection, merging, and borders. + +## Releasing + +Merging a pull request from `develop` into `main` builds, tests, and publishes the package to NuGet.org using OIDC Trusted Publishing. See [Releasing ExcelWizard](docs/RELEASING.md) for the trigger safeguards and one-time NuGet.org policy configuration. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..5da9998 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,32 @@ +# Releasing ExcelWizard + +ExcelWizard uses NuGet.org Trusted Publishing. No long-lived NuGet API key is stored in GitHub. + +## Publication trigger + +The `Publish NuGet package` workflow runs only when a pull request from this repository's `develop` branch is merged into `main`. Opening or closing an unmerged pull request, merging another branch, or pushing directly to `main` does not publish a package. + +The workflow restores, builds, and tests the complete solution before packing and publishing `ExcelWizard`. The project version in `ExcelWizard/ExcelWizard.csproj` must be incremented before the release pull request is merged; NuGet.org rejects a version that already exists. + +## GitHub configuration + +The workflow uses the `nuget` GitHub environment. Its deployment branch policy allows only `main`, and its `NUGET_USER` environment secret contains the NuGet.org profile name used during the OIDC exchange. + +The publish job has only `contents: read` and `id-token: write` permissions. All actions are pinned to immutable commit hashes. + +## NuGet.org trusted publishing policy + +Configure the policy at [NuGet.org Trusted Publishing](https://www.nuget.org/account/trustedpublishing?fromApiKeys=true) with these values: + +| Field | Value | +| --- | --- | +| Repository owner | `farshaddavoudi` | +| Repository | `ExcelWizard` | +| Workflow file | `publish-nuget.yml` | +| Environment | `nuget` | + +Select the owner that owns the `ExcelWizard` package and grant the policy permission to publish new versions of that package. NuGet.org expects only the workflow filename, not the `.github/workflows/` path. + +The first successful publication activates a pending policy permanently when NuGet.org needs to resolve the GitHub repository and owner IDs. A pending policy is temporarily active for seven days and can be restarted from NuGet.org if it expires before the first publication. + +For more information, see Microsoft's [Trusted Publishing documentation](https://learn.microsoft.com/nuget/nuget-org/trusted-publishing). diff --git a/samples/ApiApp/ApiApp.csproj b/samples/ApiApp/ApiApp.csproj index 4afdef9..3749896 100644 --- a/samples/ApiApp/ApiApp.csproj +++ b/samples/ApiApp/ApiApp.csproj @@ -1,13 +1,13 @@ - net6.0 + net10.0 enable enable - + diff --git a/samples/BlazorApp/BlazorApp.csproj b/samples/BlazorApp/BlazorApp.csproj index 5218cf4..1c88ae6 100644 --- a/samples/BlazorApp/BlazorApp.csproj +++ b/samples/BlazorApp/BlazorApp.csproj @@ -1,14 +1,14 @@  - net6.0 + net10.0 enable enable - - + +