diff --git a/.github/workflows/Build-Release.yml b/.github/workflows/Build-Release.yml index ce662d09df..f6ebe73530 100644 --- a/.github/workflows/Build-Release.yml +++ b/.github/workflows/Build-Release.yml @@ -21,39 +21,44 @@ jobs: with: dotnet-version: '9.0.x' - # --- Read version and TFMs from csproj --- - - name: Read version and target frameworks from csproj - id: read_csproj + # --- Read version and target frameworks from Directory.Build.props --- + # Both Version and the shared target framework list are centralized in + # src/Directory.Build.props (shared by EPPlus and the four embedded sub-projects: + # DrawingRenderer, Export.Pdf, Fonts.OpenType, Graphics). EPPlus.csproj no longer + # holds a literal TFM list; it consumes $(EPPlusCoreTargetFrameworks), which a raw + # XML parser cannot expand. Therefore both values are read directly from props. + - name: Read version and target frameworks + id: read_version run: | - $xml = [xml](Get-Content ./src/EPPlus/EPPlus.csproj) - $version = $xml.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 - $tfms = $xml.Project.PropertyGroup.TargetFrameworks | Where-Object { $_ } | Select-Object -First 1 + $propsXml = [xml](Get-Content ./src/Directory.Build.props) + $version = $propsXml.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($version)) { + Write-Error "Failed to read Version from src/Directory.Build.props. Aborting build." + exit 1 + } + + $tfms = $propsXml.Project.PropertyGroup.EPPlusCoreTargetFrameworks | Where-Object { $_ } | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($tfms)) { + Write-Error "Failed to read EPPlusCoreTargetFrameworks from src/Directory.Build.props. Aborting build." + exit 1 + } + echo "VERSION=$version" >> $env:GITHUB_ENV echo "TFMS=$tfms" >> $env:GITHUB_ENV + Write-Host "Version: $version" + Write-Host "Target frameworks: $tfms" shell: pwsh - name: Restore dependencies run: dotnet restore ./src/EPPlus.sln - # --- SBOM --- - - name: Install CycloneDX - run: dotnet tool install --global CycloneDX - - name: Read version from csproj - id: read_version - run: | - $version = ([xml](Get-Content ./src/EPPlus/EPPlus.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 - echo "VERSION=$version" >> $env:GITHUB_ENV - shell: pwsh - - name: Generate SBOM - run: dotnet CycloneDX ./src/EPPlus/EPPlus.csproj -o ./sbom -F Json -st Library -sv ${{ env.VERSION }} -fn epplus-${{ env.VERSION }}.sbom.json -imp ./src/EPPlus/sbom-metadata-template.xml - - name: Generate SHA-256 checksum for SBOM - run: | - $sbomFile = "./sbom/epplus-${{ env.VERSION }}.sbom.json" - $hash = (Get-FileHash -Path $sbomFile -Algorithm SHA256).Hash.ToLower() - "$hash epplus-${{ env.VERSION }}.sbom.json" | Out-File -FilePath "./sbom/epplus-${{ env.VERSION }}.sbom.json.sha256" -Encoding utf8NoBOM - shell: pwsh - # --- SBOM --- - + # Build the whole solution. Note: the EPPlus NuGet package is produced here, not by a + # separate 'dotnet pack' step. EPPlus.csproj has GeneratePackageOnBuild=true and a custom + # target (IncludeReferencedProjectsInPackage) that embeds the four sub-project DLLs into + # lib// inside the package. That target only runs correctly as part of a build, because + # it depends on ReferenceCopyLocalPaths being populated; a standalone 'dotnet pack' produces + # an incomplete package containing only EPPlus.dll. The resulting .nupkg is picked up from + # src/EPPlus/bin/Release/ below. - name: Build run: dotnet build ./src/EPPlus.sln --no-restore --configuration Release - name: Test @@ -71,50 +76,162 @@ jobs: creds: '{"clientId":"${{ secrets.EPPLUS_CODE_SIGNING_APPLICATION_ID }}","clientSecret":"${{ secrets.EPPLUS_CODE_SIGNING_SECRET }}","subscriptionId":"${{ secrets.EPPLUS_CODE_SIGNING_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.EPPLUS_CODE_SIGNING_TENENT_ID }}"}' # --- Sign DLLs --- - - name: Sign EPPlus.dll with AzureSignTool + # All assemblies that ship inside a NuGet package are signed here, before the package is signed. + # EPPlus + the four embedded sub-projects (DrawingRenderer, Export.Pdf, Fonts.OpenType, + # Graphics) share the same target frameworks (EPPlusCoreTargetFrameworks, read into env.TFMS). + # EPPlus.Interfaces and EPPlus.System.Drawing are separate NuGet packages (unchanged from + # earlier versions) and are also signed here, as before. + # NOTE: this signs the DLLs in each project's bin/Release/ output. The embedded copies + # inside the EPPlus package are taken from these same signed outputs, so the package verify + # step below confirms the embedded DLLs carry a valid signature. + - name: Sign assemblies with AzureSignTool run: | + $projectsToSign = @( + "EPPlus", + "EPPlus.Interfaces", + "EPPlus.System.Drawing", + "EPPlus.DrawingRenderer", + "EPPlus.Export.Pdf", + "EPPlus.Fonts.OpenType", + "EPPlus.Graphics" + ) + $tfms = "${{ env.TFMS }}" -split ";" - foreach ($tfm in $tfms) { - $tfm = $tfm.Trim() - if ([string]::IsNullOrEmpty($tfm)) { continue } - $dll = ".\src\EPPlus\bin\Release\$tfm\EPPlus.dll" - Write-Host "Signing $dll" - azuresigntool.exe sign -kvu ${{ secrets.EPPLUS_CODE_SIGNING_KEY_VAULT_URL }} -kvi ${{ secrets.EPPLUS_CODE_SIGNING_APPLICATION_ID }} -kvt ${{ secrets.EPPLUS_CODE_SIGNING_TENENT_ID }} -kvs ${{ secrets.EPPLUS_CODE_SIGNING_SECRET }} -kvc ${{ secrets.EPPLUS_CODE_SIGNING_CERTIFICATE_NAME }} -tr http://timestamp.globalsign.com/tsa/advanced -td sha256 "$dll" + + foreach ($project in $projectsToSign) { + foreach ($tfm in $tfms) { + $tfm = $tfm.Trim() + if ([string]::IsNullOrEmpty($tfm)) { continue } + + $dll = ".\src\$project\bin\Release\$tfm\$project.dll" + if (-not (Test-Path $dll)) { + Write-Host "Skipping $dll (not built for this target framework)" + continue + } + + Write-Host "Signing $dll" + azuresigntool.exe sign ` + -kvu ${{ secrets.EPPLUS_CODE_SIGNING_KEY_VAULT_URL }} ` + -kvi ${{ secrets.EPPLUS_CODE_SIGNING_APPLICATION_ID }} ` + -kvt ${{ secrets.EPPLUS_CODE_SIGNING_TENENT_ID }} ` + -kvs ${{ secrets.EPPLUS_CODE_SIGNING_SECRET }} ` + -kvc ${{ secrets.EPPLUS_CODE_SIGNING_CERTIFICATE_NAME }} ` + -tr http://timestamp.globalsign.com/tsa/advanced ` + -td sha256 ` + "$dll" + } } shell: pwsh - - name: Sign EPPlus.Interfaces.dll with AzureSignTool + # --- Sign DLLs --- + + # --- Collect the built packages --- + # The packages are produced by the build (GeneratePackageOnBuild), not by 'dotnet pack'. + # We copy the three packages we actually ship (EPPlus, EPPlus.Interfaces, EPPlus.System.Drawing) + # into ./output for signing and upload. The four sub-projects and the benchmark project have + # IsPackable=false and therefore produce no package of their own; the sub-project DLLs ship + # embedded inside the EPPlus package. + - name: Collect NuGet packages run: | - $tfms = "${{ env.TFMS }}" -split ";" - foreach ($tfm in $tfms) { - $tfm = $tfm.Trim() - if ([string]::IsNullOrEmpty($tfm)) { continue } - $dll = ".\src\EPPlus.Interfaces\bin\Release\$tfm\EPPlus.Interfaces.dll" - Write-Host "Signing $dll" - azuresigntool.exe sign -kvu ${{ secrets.EPPLUS_CODE_SIGNING_KEY_VAULT_URL }} -kvi ${{ secrets.EPPLUS_CODE_SIGNING_APPLICATION_ID }} -kvt ${{ secrets.EPPLUS_CODE_SIGNING_TENENT_ID }} -kvs ${{ secrets.EPPLUS_CODE_SIGNING_SECRET }} -kvc ${{ secrets.EPPLUS_CODE_SIGNING_CERTIFICATE_NAME }} -tr http://timestamp.globalsign.com/tsa/advanced -td sha256 "$dll" + New-Item -ItemType Directory -Force -Path ./output | Out-Null + + $packageProjects = @( + "EPPlus", + "EPPlus.Interfaces", + "EPPlus.System.Drawing" + ) + + $found = $false + foreach ($project in $packageProjects) { + $pkgDir = ".\src\$project\bin\Release" + $pkgs = Get-ChildItem -Path $pkgDir -Filter "*.nupkg" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch "\.symbols\.nupkg$" } + + if ($null -eq $pkgs -or $pkgs.Count -eq 0) { + Write-Error "No .nupkg found for $project in $pkgDir. Expected GeneratePackageOnBuild to produce one." + exit 1 + } + + foreach ($pkg in $pkgs) { + Write-Host "Collecting $($pkg.Name) from $pkgDir" + Copy-Item $pkg.FullName -Destination ./output -Force + $found = $true + } } - shell: pwsh - - name: Sign EPPlus.System.Drawing.dll with AzureSignTool - run: | - $tfms = "${{ env.TFMS }}" -split ";" - foreach ($tfm in $tfms) { - $tfm = $tfm.Trim() - if ([string]::IsNullOrEmpty($tfm)) { continue } - $dll = ".\src\EPPlus.System.Drawing\bin\Release\$tfm\EPPlus.System.Drawing.dll" - Write-Host "Signing $dll" - azuresigntool.exe sign -kvu ${{ secrets.EPPLUS_CODE_SIGNING_KEY_VAULT_URL }} -kvi ${{ secrets.EPPLUS_CODE_SIGNING_APPLICATION_ID }} -kvt ${{ secrets.EPPLUS_CODE_SIGNING_TENENT_ID }} -kvs ${{ secrets.EPPLUS_CODE_SIGNING_SECRET }} -kvc ${{ secrets.EPPLUS_CODE_SIGNING_CERTIFICATE_NAME }} -tr http://timestamp.globalsign.com/tsa/advanced -td sha256 "$dll" + + if (-not $found) { + Write-Error "No packages were collected. Aborting." + exit 1 } shell: pwsh - # --- Sign DLLs --- + # --- Collect the built packages --- - - name: Pack NuGet package - run: dotnet pack ./src/EPPlus.sln --configuration Release --output ./output - - name: Sign NuGet package + - name: Sign NuGet packages run: | NuGetKeyVaultSignTool.exe sign -kvu ${{ secrets.EPPLUS_CODE_SIGNING_KEY_VAULT_URL }} -kvc ${{ secrets.EPPLUS_CODE_SIGNING_CERTIFICATE_NAME }} -kvi ${{ secrets.EPPLUS_CODE_SIGNING_APPLICATION_ID }} -kvs ${{ secrets.EPPLUS_CODE_SIGNING_SECRET }} -kvt ${{ secrets.EPPLUS_CODE_SIGNING_TENENT_ID }} -tr http://timestamp.globalsign.com/tsa/advanced -fd sha256 -td sha256 -own EPPlusSoftware ".\output\*.nupkg" - - name: Upload NuGet package as artifact + + # --- Verify the EPPlus package contains the embedded assemblies, correctly signed --- + # Automates the manual check that the four sub-projects are present in the final EPPlus + # package and signed with the same certificate as EPPlus.dll. This is the primary safety net: + # because packaging depends on build behaviour, this step must fail the build if the package + # is ever produced without all five embedded assemblies. + - name: Verify EPPlus package contents and signatures + run: | + $nupkg = Get-ChildItem ./output/*.nupkg | + Where-Object { $_.Name -notmatch "EPPlus\.Interfaces" -and $_.Name -notmatch "EPPlus\.System\.Drawing" } | + Where-Object { $_.Name -notmatch "\.symbols\.nupkg$" } | + Select-Object -First 1 + + if ($null -eq $nupkg) { + Write-Error "Could not locate the main EPPlus .nupkg in ./output." + exit 1 + } + Write-Host "Verifying package: $($nupkg.Name)" + + $extractPath = "./nupkg-verify" + if (Test-Path $extractPath) { Remove-Item $extractPath -Recurse -Force } + Expand-Archive -Path $nupkg.FullName -DestinationPath $extractPath -Force + + # The five assemblies that must ship inside the EPPlus package. + $expectedDlls = @( + "EPPlus.dll", + "EPPlus.DrawingRenderer.dll", + "EPPlus.Export.Pdf.dll", + "EPPlus.Fonts.OpenType.dll", + "EPPlus.Graphics.dll" + ) + + $hasError = $false + foreach ($dllName in $expectedDlls) { + $found = Get-ChildItem -Path $extractPath -Recurse -Filter $dllName + if ($found.Count -eq 0) { + Write-Host "ERROR: $dllName not found anywhere in the package." + $hasError = $true + continue + } + + foreach ($file in $found) { + $signature = Get-AuthenticodeSignature $file.FullName + if ($signature.Status -ne "Valid") { + Write-Host "ERROR: $($file.FullName) is not validly signed (status: $($signature.Status))." + $hasError = $true + } else { + Write-Host "OK: $($file.FullName) present and validly signed." + } + } + } + + if ($hasError) { + Write-Error "Package verification failed. See errors above." + exit 1 + } + Write-Host "All embedded assemblies are present and validly signed." + shell: pwsh + # --- Verify package contents --- + + - name: Upload NuGet packages as artifact uses: actions/upload-artifact@v4 with: - name: signed-nuget-package + name: signed-nuget-packages path: ./output/*.nupkg # --- SBOM (after build to avoid CycloneDX overwriting project.assets.json) --- @@ -152,10 +269,4 @@ jobs: --auth-mode login ` --overwrite } - shell: pwsh - - name: Upload all SBOMs as artifact - uses: actions/upload-artifact@v4 - with: - name: sbom - path: ./sbom/ - # --- SBOM --- + shell: pwsh \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index b57d0098f0..b50899c7b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,3 +31,4 @@ We publish detailed security information including vulnerability disclosures, au ## See Also - [EPPlus versioning](https://github.com/EPPlusSoftware/EPPlus/wiki/Releases-versioning) +- [Security-considerations-when-calculating-formulas](https://github.com/EPPlusSoftware/EPPlus/wiki/Security-considerations-when-calculating-formulas) diff --git a/appveyor8.yml b/appveyor8.yml index 164cb73a0b..c7dc848c4c 100644 --- a/appveyor8.yml +++ b/appveyor8.yml @@ -1,4 +1,4 @@ -version: 8.6.1.{build} +version: 8.6.3.{build} branches: only: - develop8 @@ -10,15 +10,15 @@ install: & $env:temp\dotnet-install.ps1 -Architecture x64 -Version '10.0.100' -InstallDir "$env:ProgramFiles\dotnet" init: - ps: >- - Update-AppveyorBuild -Version "8.6.1.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" + Update-AppveyorBuild -Version "8.6.3.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" - Write-Host "8.6.1.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" + Write-Host "8.6.3.$env:appveyor_build_number-$(Get-Date -format yyyyMMdd)-$env:appveyor_repo_branch" dotnet_csproj: patch: true file: '**\*.csproj' version: '{version}' - assembly_version: 8.6.1.{build} - file_version: 8.6.1.{build} + assembly_version: 8.6.3.{build} + file_version: 8.6.3.{build} nuget: project_feed: true before_build: diff --git a/docs/articles/fixedissues.md b/docs/articles/fixedissues.md index f763512770..3183bd4990 100644 --- a/docs/articles/fixedissues.md +++ b/docs/articles/fixedissues.md @@ -1,7 +1,19 @@ # Features / Fixed issues - EPPlus 8 -## Version 9.0.0 -* Added 'Layout' property to 'ExcelChartTrendlineLabel' class. - +## Version 8.6.3 +### Security +* Updated System.Security.Cryptography.Xml to address five security vulnerabilities in the .NET XML signing dependency: four denial of service vulnerabilities (CVE-2026-47302, CVE-2026-50525, CVE-2026-50527, CVE-2026-50648) and one security feature bypass (CVE-2026-47304). The package is updated to 8.0.4 (.NET Framework, .NET 8 and .NET Standard), 9.0.18 (.NET 9) and 10.0.10 (.NET 10). +## Version 8.6 2 +### Minor features and fixed issues +* Added property ´AlwaysRefreshImageFunction´ to ´ParsingConfiguration´, to disable download of external content in the calculation of the IMAGE function. +* Fixed unhandled ´InvalidOperationException´ when loading an icon set conditional formatting with formula cfvo from extLst. +* Fixed an issue where the SEARCH function did not handle arrays in the third argument. +* Fixed ´NullReferenceException´ in GetStyleId after multiple ´InsertColumn´ calls. +* AppVersion in ´OfficeProperties´ can now be set to null, to remove the value. +* Fixed drawing hyperlink reassignment crash and tooltip setting. (Fix by Lieven De Foor) +* Adds a WrappedTextAutofitMode property to ExcelTextSettings, backed by a new eWrappedTextAutofitMode enum, giving control over how cells with WrapText enabled contribute to column width in AutoFitColumns(). (Thanks to Lieven De Foor) +* Fixed ´KeyNotFoundException´ / ´ArgumentException´ on drawing rename and removal. (Fix by Lieven De Foor) +* Fixed unhandled ´IndexOutOfRangeException´ when importing text using the ´ExcelRangeBase.LoadFromText´ method, when text file has more columns than specified ´DataTypes´ argument. (Fix by Lieven De Foor) +* Fixed an issue when positioning shapes in charts. ## Version 8.6 1 ### Features * 3 new functions: diff --git a/src/Directory.Build.props b/src/Directory.Build.props index eb6570e77c..0df8edc17b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,11 +1,16 @@ - + 9.0.1.0 9.0.1.0 9.0.1 + + + net8.0;net9.0;net10.0;netstandard2.1;netstandard2.0;net462 diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index bacdaf92bd..0f29c6501e 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -10,8 +10,8 @@ - - + + diff --git a/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.csproj b/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.csproj index 578dc2d78a..a8ab2f7e66 100644 --- a/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.csproj +++ b/src/EPPlus.DrawingRenderer/EPPlus.DrawingRenderer.csproj @@ -1,11 +1,12 @@  - net8.0;net9.0;net10.0;netstandard2.1;netstandard2.0;net462 + $(EPPlusCoreTargetFrameworks) enable enable EPPlus Software AB EPPlus A spreadsheet library for .NET framework and .NET core + false latest True diff --git a/src/EPPlus.Export.Pdf/EPPlus.Export.Pdf.csproj b/src/EPPlus.Export.Pdf/EPPlus.Export.Pdf.csproj index 1aa441393c..443dc1ae32 100644 --- a/src/EPPlus.Export.Pdf/EPPlus.Export.Pdf.csproj +++ b/src/EPPlus.Export.Pdf/EPPlus.Export.Pdf.csproj @@ -1,9 +1,10 @@  - net8.0;net9.0;netstandard2.1;netstandard2.0;net462 + $(EPPlusCoreTargetFrameworks) disable disable + false true EPPlus.Export.Pdf.snk latest @@ -35,18 +36,6 @@ - - - - - - - - - - - - @@ -61,7 +50,7 @@ - + @@ -70,9 +59,9 @@ - + - + diff --git a/src/EPPlus.Fonts.OpenType/EPPlus.Fonts.OpenType.csproj b/src/EPPlus.Fonts.OpenType/EPPlus.Fonts.OpenType.csproj index 93e50126c2..11a02b7336 100644 --- a/src/EPPlus.Fonts.OpenType/EPPlus.Fonts.OpenType.csproj +++ b/src/EPPlus.Fonts.OpenType/EPPlus.Fonts.OpenType.csproj @@ -1,11 +1,10 @@  - net9.0;net8.0;netstandard2.1;netstandard2.0;net462 + $(EPPlusCoreTargetFrameworks) true license.md git - + false EPPlus.Fonts.OpenType readme.md EPPlusLogo.png @@ -90,9 +89,9 @@ - + - + diff --git a/src/EPPlus.Fonts.OpenType/Integration/OpenTypeFontTextMeasurer.cs b/src/EPPlus.Fonts.OpenType/Integration/OpenTypeFontTextMeasurer.cs index 1dfa66ebc4..d42aa2119b 100644 --- a/src/EPPlus.Fonts.OpenType/Integration/OpenTypeFontTextMeasurer.cs +++ b/src/EPPlus.Fonts.OpenType/Integration/OpenTypeFontTextMeasurer.cs @@ -42,6 +42,11 @@ public OpenTypeFontTextMeasurer(ITextShaper shaper, ShapingOptions options = nul /// Controls whether multi-line text (with CR/LF/CRLF) should be measured. /// public bool MeasureWrappedTextCells { get; set; } + public eWrappedTextAutofitMode WrappedTextAutofitMode + { + get; + set; + } /// /// Measures text width and height. diff --git a/src/EPPlus.Graphics/EPPlus.Graphics.csproj b/src/EPPlus.Graphics/EPPlus.Graphics.csproj index 2333a6667e..7e9c5d4ee2 100644 --- a/src/EPPlus.Graphics/EPPlus.Graphics.csproj +++ b/src/EPPlus.Graphics/EPPlus.Graphics.csproj @@ -1,11 +1,12 @@  - net9.0;net8.0;netstandard2.1;netstandard2.0;net462 + $(EPPlusCoreTargetFrameworks) disable disable True latest + false EPPlus.Graphics.snk @@ -28,18 +29,6 @@ - - - - - - - - - - - - @@ -54,7 +43,7 @@ - + @@ -63,7 +52,7 @@ - + diff --git a/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs b/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs index f148a3062b..7b17e4adbb 100644 --- a/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs +++ b/src/EPPlus.Interfaces/Drawing/Text/ITextMeasurer.cs @@ -11,6 +11,8 @@ Date Author Change 1/4/2021 EPPlus Software AB EPPlus Interfaces 1.0 *************************************************************************************************/ +using System; + namespace OfficeOpenXml.Interfaces.Drawing.Text { /// @@ -31,9 +33,15 @@ public interface ITextMeasurer /// TextMeasurement MeasureText(string text, MeasurementFont font); /// - /// If the text measurer should measure wrap text cells. - /// Only CR, LF or CRLF should be considered. + /// If the text measurer should measure wrapped text cells. /// + [Obsolete("Use WrappedTextAutofitMode instead. This property will be removed in a future major version.")] bool MeasureWrappedTextCells { get; set; } + + /// + /// Determines how cells with WrapText enabled are measured when calculating + /// column width in AutoFitColumns. Default is . + /// + eWrappedTextAutofitMode WrappedTextAutofitMode { get; set; } } } diff --git a/src/EPPlus.Interfaces/Drawing/Text/eWrappedTextAutofitMode.cs b/src/EPPlus.Interfaces/Drawing/Text/eWrappedTextAutofitMode.cs new file mode 100644 index 0000000000..812d4b2541 --- /dev/null +++ b/src/EPPlus.Interfaces/Drawing/Text/eWrappedTextAutofitMode.cs @@ -0,0 +1,47 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 1/4/2021 EPPlus Software AB Added wrapped text autofit mode + *************************************************************************************************/ +namespace OfficeOpenXml.Interfaces.Drawing.Text +{ + /// + /// Determines how cells with WrapText enabled are measured when calculating + /// column width in AutoFitColumns. + /// + public enum eWrappedTextAutofitMode + { + /// + /// Cells with WrapText enabled are ignored when calculating column width. + /// This is the default and matches the behaviour of earlier versions. + /// + Skip = 0, + /// + /// The entire cell text is measured as a single line, ignoring wrapping. + /// + FullText = 1, + /// + /// The text is split on explicit line breaks (CR, LF, CRLF) and the width + /// of the widest resulting line determines the cell's contribution to the + /// column width. + /// + SplitNewLine = 2, + /// + /// The text is split on whitespace (space, tab, and line breaks) and on + /// hyphen characters (U+002D hyphen-minus and U+2010 hyphen). The width of + /// the widest resulting segment determines the cell's contribution to the + /// column width. Hyphens are visible and their width is included in the + /// segment they terminate; whitespace is not. Note: this yields the minimum + /// column width at which no single word overflows, and is not a simulation + /// of Excel's line wrapping. + /// + SplitWord = 3 + } +} diff --git a/src/EPPlus.Interfaces/EPPlus.Interfaces.csproj b/src/EPPlus.Interfaces/EPPlus.Interfaces.csproj index 3a0158508a..6ae5284ef3 100644 --- a/src/EPPlus.Interfaces/EPPlus.Interfaces.csproj +++ b/src/EPPlus.Interfaces/EPPlus.Interfaces.csproj @@ -1,9 +1,9 @@  net10.0;net9.0;net8.0;netstandard2.1;netstandard2.0;net462 - 8.4.0.0 - 8.4.0.0 - 8.4.0 + 8.6.2.0 + 8.6.2.0 + 8.6.2 true license.md git diff --git a/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs b/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs index 303887c0a3..9e2031ace9 100644 --- a/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs +++ b/src/EPPlus.System.Drawing/Drawing/Text/SystemDrawingTextMeasurer.cs @@ -12,11 +12,19 @@ public class SystemDrawingTextMeasurer : ITextMeasurer, IDisposable /// If the text measurer should measure wrap text cells. /// Only CR, LF or CRLF should be considered. /// +#pragma warning disable 618 public bool MeasureWrappedTextCells { get; set; } +#pragma warning restore 618 + public eWrappedTextAutofitMode WrappedTextAutofitMode + { + get; + set; + } + public SystemDrawingTextMeasurer() { if (Environment.OSVersion.Platform == PlatformID.Win32NT && diff --git a/src/EPPlus.System.Drawing/EPPlus.System.Drawing.csproj b/src/EPPlus.System.Drawing/EPPlus.System.Drawing.csproj index 8b3cbde274..2560da378d 100644 --- a/src/EPPlus.System.Drawing/EPPlus.System.Drawing.csproj +++ b/src/EPPlus.System.Drawing/EPPlus.System.Drawing.csproj @@ -2,9 +2,9 @@ net10.0;net9.0;net8.0;netstandard2.1;netstandard2.0;net462 - 8.4.0.0 - 8.4.0.0 - 8.4.0 + 8.6.2.0 + 8.6.2.0 + 8.6.2 true license.md true diff --git a/src/EPPlus.sln b/src/EPPlus.sln index 55d05b41f8..0490b4ba7e 100644 --- a/src/EPPlus.sln +++ b/src/EPPlus.sln @@ -11,6 +11,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig .runsettings = .runsettings + Directory.Build.props = Directory.Build.props EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EPPlus.Interfaces", "EPPlus.Interfaces\EPPlus.Interfaces.csproj", "{F73201C2-B2DE-49F8-BE5B-26F56B5D52B8}" diff --git a/src/EPPlus/ConditionalFormatting/ExcelConditionalFormattingCollection.cs b/src/EPPlus/ConditionalFormatting/ExcelConditionalFormattingCollection.cs index be0a1c4ca5..30a3a2caa3 100644 --- a/src/EPPlus/ConditionalFormatting/ExcelConditionalFormattingCollection.cs +++ b/src/EPPlus/ConditionalFormatting/ExcelConditionalFormattingCollection.cs @@ -462,7 +462,12 @@ void ApplyIconSetExtValues( iconArr[i].Type = types[i].ToEnum() .GetValueOrDefault(); - if(double.TryParse(values[i], out double result)) + // A formula cfvo stores its expression in the @val attribute, even + // when that expression is a numeric constant. Only assign Value for + // the numeric-value types; otherwise route through Formula. This + // mirrors ReadIcon and the databar reader. + if(iconArr[i].Type != eExcelConditionalFormattingValueObjectType.Formula + && double.TryParse(values[i], NumberStyles.Any, CultureInfo.InvariantCulture, out double result)) { iconArr[i].Value = result; } diff --git a/src/EPPlus/Core/AutofitHelper.cs b/src/EPPlus/Core/AutofitHelper.cs index 098bfa6508..e1db3e54a0 100644 --- a/src/EPPlus/Core/AutofitHelper.cs +++ b/src/EPPlus/Core/AutofitHelper.cs @@ -108,7 +108,7 @@ internal void AutofitColumn(double MinimumWidth, double MaximumWidth) worksheet.AutoFilter.Address._toCol)); afAddr[afAddr.Count - 1]._ws = _range.WorkSheetName; } - foreach (var tbl in worksheet.Tables) + foreach (var tbl in worksheet.Tables) { if (tbl.AutoFilterAddress != null) { @@ -155,7 +155,7 @@ internal void AutofitColumn(double MinimumWidth, double MaximumWidth) { var cellStyleId = styles.CellXfs[cell.StyleID]; if (cell.Merge == true) continue; - if (cellStyleId.WrapText && _textSettings.MeasureWrappedTextCells == false) continue; + if (cellStyleId.WrapText && _textSettings.WrappedTextAutofitMode == eWrappedTextAutofitMode.Skip) continue; currentMaxWidth = GetTextLength(cell, textLengthCache, styles, cellStyleId, normalSize, MaximumWidth, currentMaxWidth); if (currentMaxWidth >= MaximumWidth) { diff --git a/src/EPPlus/Core/ChangableDictionary.cs b/src/EPPlus/Core/ChangableDictionary.cs index 260f7a4849..cd8d3af904 100644 --- a/src/EPPlus/Core/ChangableDictionary.cs +++ b/src/EPPlus/Core/ChangableDictionary.cs @@ -59,6 +59,7 @@ internal T this[int key] internal virtual void InsertAndShift(int fromPosition, int add) { + if (_count == 0) return; var pos = Array.BinarySearch(_index[0], 0, _count, fromPosition); if (pos < 0) @@ -66,7 +67,7 @@ internal virtual void InsertAndShift(int fromPosition, int add) pos = ~pos; } - if (_count >= _index[0].Length - 1) + if (_count >= _index[0].Length-1) { Array.Resize(ref _index[0], _index[0].Length << 1); Array.Resize(ref _index[1], _index[1].Length << 1); @@ -83,6 +84,23 @@ internal virtual void InsertAndShift(int fromPosition, int add) } Version++; } + internal virtual void Shift(int fromPosition, int add) + { + if (_count == 0) return; + var pos = Array.BinarySearch(_index[0], 0, _count, fromPosition); + + if (pos < 0) + { + pos = ~pos; + } + if (pos >= _count) return; + + for (int i = pos; i < Count; i++) + { + _index[0][i] += add; + } + Version++; + } internal int Count { get { return _count; } } /// diff --git a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs index 4c037d0c0b..f72b29d464 100644 --- a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs +++ b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurer.cs @@ -23,11 +23,24 @@ internal class GenericFontMetricsTextMeasurer : GenericFontMetricsTextMeasurerBa /// If the text measurer should measure wrap text cells. /// Only CR, LF or CRLF should be considered. /// +#pragma warning disable 618 public bool MeasureWrappedTextCells { get; set; } +#pragma warning restore 618 + /// + /// + /// + /// + public eWrappedTextAutofitMode WrappedTextAutofitMode + { + get; + set; + } + + /// /// Measures the supplied text /// @@ -38,7 +51,7 @@ public TextMeasurement MeasureText(string text, MeasurementFont font) { var fontKey = GetKey(font.FontFamily, font.Style); if (!IsValidFont(fontKey)) return TextMeasurement.Empty; - return MeasureTextInternal(text, fontKey, font.Style, font.Size, MeasureWrappedTextCells); + return MeasureTextInternal(text, fontKey, font.Style, font.Size, WrappedTextAutofitMode); } public bool ValidForEnvironment() diff --git a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs index a79de9c1eb..a631ca99da 100644 --- a/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs +++ b/src/EPPlus/Core/Worksheet/Fonts/GenericFontMetrics/GenericFontMetricsTextMeasurerBase.cs @@ -47,32 +47,58 @@ internal protected bool IsValidFont(uint fontKey) return _fonts.ContainsKey(fontKey); } - internal protected TextMeasurement MeasureTextInternal(string text, uint fontKey, MeasurementFontStyles style, float size, bool wrapText = false) + internal protected TextMeasurement MeasureTextInternal(string text, uint fontKey, MeasurementFontStyles style, float size, eWrappedTextAutofitMode mode = eWrappedTextAutofitMode.Skip) { if(text==null) { return new TextMeasurement(0, 0); } var sFont = _fonts[fontKey]; + + // Width of the current segment (a "segment" is a line in SplitNewLine mode, + // or a word in SplitWord mode). In FullText/Skip the whole text is one segment. var width = 0f; - var maxWidth = 0f; var widthEA = 0f; + + // Width of the widest segment seen so far. + var maxWidth = 0f; + var maxWidthEA = 0f; + for (var x = 0; x < text.Length; x++) { var fnt = sFont; var c = text[x]; - if(wrapText && (c=='\n' || c=='\r')) + + if (IsSegmentBoundary(c, mode)) { - if(x>0 && c=='\r' && text[x-1]=='\n') + // A CRLF pair is a single line break, not two. + if (x > 0 && c == '\r' && text[x - 1] == '\n') { - continue; //CRLF should be handle + continue; //d as one new line. } - if(width>maxWidth) + + // A visible boundary character (hyphen) remains at the end of the + // segment it terminates, so its own width is added before the break. + if (IsVisibleBoundary(c) && sFont.CharMetrics.ContainsKey(c)) + { + width += fnt.ClassWidths[sFont.CharMetrics[c]]; + } + + // Close the current segment: keep it if it is the widest so far. + if ((width + widthEA) > (maxWidth + maxWidthEA)) { maxWidth = width; - width = 0; + maxWidthEA = widthEA; } + + // Start a new, empty segment. + width = 0f; + widthEA = 0f; + + // The boundary character itself is not part of the next segment. + // (Visible boundaries were already counted into the closed segment above.) + continue; } //If east Asian char use default regardless of actual font. @@ -88,16 +114,23 @@ internal protected TextMeasurement MeasureTextInternal(string text, uint fontKey if (Char.IsDigit(c)) fw *= FontScaleFactors.DigitsScalingFactor; width += fw; } - else if (char.IsControl(c)==false) + else if (char.IsControl(c) == false) { width += sFont.ClassWidths[fnt.DefaultWidthClass]; } } } - if(maxWidth > width) + + // Close the final segment. + if ((width + widthEA) > (maxWidth + maxWidthEA)) { - width = maxWidth; + maxWidth = width; + maxWidthEA = widthEA; } + + width = maxWidth; + widthEA = maxWidthEA; + width *= size; widthEA *= size; var sf = _fontScaleFactors.GetScaleFactor(fontKey, width); @@ -107,6 +140,35 @@ internal protected TextMeasurement MeasureTextInternal(string text, uint fontKey return new TextMeasurement(width, height); } + /// + /// Returns true if the character ends the current measurement segment for the given mode. + /// + private static bool IsSegmentBoundary(char c, eWrappedTextAutofitMode mode) + { + switch (mode) + { + case eWrappedTextAutofitMode.SplitNewLine: + return c == '\n' || c == '\r'; + case eWrappedTextAutofitMode.SplitWord: + return c == '\n' || c == '\r' || c == ' ' || c == '\t' + || c == '\u002D' // hyphen-minus + || c == '\u2010'; // hyphen + default: + // FullText and Skip: the whole string is a single segment. + return false; + } + } + + /// + /// Returns true if the boundary character is visible and therefore contributes + /// its own width to the segment it terminates (hyphens). Whitespace and line + /// breaks are invisible and contribute no width. + /// + private static bool IsVisibleBoundary(char c) + { + return c == '\u002D' || c == '\u2010'; + } + static Dictionary AlphabetChars = new Dictionary { {'a', 0x06 }, diff --git a/src/EPPlus/Core/Worksheet/WorksheetCopyHelper.cs b/src/EPPlus/Core/Worksheet/WorksheetCopyHelper.cs index 5f0da53a60..45f5d1e932 100644 --- a/src/EPPlus/Core/Worksheet/WorksheetCopyHelper.cs +++ b/src/EPPlus/Core/Worksheet/WorksheetCopyHelper.cs @@ -1427,15 +1427,10 @@ private static void CopyThreadedComments(ExcelWorksheet copy, ExcelWorksheet add private static void CopyHeaderFooterPictures(ExcelWorksheet Copy, ExcelWorksheet added) { if (Copy.TopNode != null && Copy.GetNode("d:headerFooter") == null) return; - //Copy the texts - if (Copy.HeaderFooter._oddHeader != null) CopyText(Copy.HeaderFooter._oddHeader, added.HeaderFooter.OddHeader); - if (Copy.HeaderFooter._oddFooter != null) CopyText(Copy.HeaderFooter._oddFooter, added.HeaderFooter.OddFooter); - if (Copy.HeaderFooter._evenHeader != null) CopyText(Copy.HeaderFooter._evenHeader, added.HeaderFooter.EvenHeader); - if (Copy.HeaderFooter._evenFooter != null) CopyText(Copy.HeaderFooter._evenFooter, added.HeaderFooter.EvenFooter); - if (Copy.HeaderFooter._firstHeader != null) CopyText(Copy.HeaderFooter._firstHeader, added.HeaderFooter.FirstHeader); - if (Copy.HeaderFooter._firstFooter != null) CopyText(Copy.HeaderFooter._firstFooter, added.HeaderFooter.FirstFooter); - //Copy any images; + //Copy any images first, so the pictures exist on the target before the + //header/footer text is parsed. The text may contain the image code (&G), + //and parsing it reads added.HeaderFooter.Pictures. if (Copy.HeaderFooter.Pictures.Count > 0) { Uri source = Copy.HeaderFooter.Pictures.Uri; @@ -1456,11 +1451,26 @@ private static void CopyHeaderFooterPictures(ExcelWorksheet Copy, ExcelWorksheet } foreach (XmlAttribute att in pic.TopNode.Attributes) { + // Skip attributes that Pictures.Add/AddImage already set on the new + // shape node, otherwise we get duplicates (e.g. a second "type" + // attribute, which produces invalid VML that cannot be reopened). + if (att.LocalName == "id" || att.LocalName == "type" || att.LocalName == "style") + { + continue; + } (item.TopNode as XmlElement).SetAttribute(att.Name, att.Value); } item.TopNode.InnerXml = pic.TopNode.InnerXml; } } + + //Copy the texts + if (Copy.HeaderFooter._oddHeader != null) CopyText(Copy.HeaderFooter._oddHeader, added.HeaderFooter.OddHeader); + if (Copy.HeaderFooter._oddFooter != null) CopyText(Copy.HeaderFooter._oddFooter, added.HeaderFooter.OddFooter); + if (Copy.HeaderFooter._evenHeader != null) CopyText(Copy.HeaderFooter._evenHeader, added.HeaderFooter.EvenHeader); + if (Copy.HeaderFooter._evenFooter != null) CopyText(Copy.HeaderFooter._evenFooter, added.HeaderFooter.EvenFooter); + if (Copy.HeaderFooter._firstHeader != null) CopyText(Copy.HeaderFooter._firstHeader, added.HeaderFooter.FirstHeader); + if (Copy.HeaderFooter._firstFooter != null) CopyText(Copy.HeaderFooter._firstFooter, added.HeaderFooter.FirstFooter); } private static void CopyText(ExcelHeaderFooterText from, ExcelHeaderFooterText to) { diff --git a/src/EPPlus/Core/Worksheet/WorksheetRangeInsertHelper.cs b/src/EPPlus/Core/Worksheet/WorksheetRangeInsertHelper.cs index 0c76bc119d..ae542a4753 100644 --- a/src/EPPlus/Core/Worksheet/WorksheetRangeInsertHelper.cs +++ b/src/EPPlus/Core/Worksheet/WorksheetRangeInsertHelper.cs @@ -706,6 +706,9 @@ private static void AdjustColumns(ExcelWorksheet ws, int columnFrom, int columns } } + //Shift column indexes from columnFrom + ws.ColumnLookup.Shift(columnFrom, columns); + for (int i = lst.Count - 1; i >= 0; i--) { var c = lst[i]; diff --git a/src/EPPlus/Drawing/ExcelDrawing.cs b/src/EPPlus/Drawing/ExcelDrawing.cs index 6f3713f655..6f620d3509 100644 --- a/src/EPPlus/Drawing/ExcelDrawing.cs +++ b/src/EPPlus/Drawing/ExcelDrawing.cs @@ -20,7 +20,7 @@ Date Author Change using OfficeOpenXml.Drawing.Controls; using OfficeOpenXml.Drawing.OleObject; using OfficeOpenXml.Drawing.Slicer; -using OfficeOpenXml.Export.HtmlExport; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; using OfficeOpenXml.FormulaParsing.Excel.Functions.Text; using OfficeOpenXml.Packaging; using OfficeOpenXml.Utils.Drawings; @@ -358,18 +358,7 @@ public virtual string Name // All uniqueness checks successfully passed! Safe to update name-lookup dictionaries. UpdateNameInDictionaries(oldName, value); } - - SetXmlNodeString(_nvPrPath + "/@name", value); - if (this is ExcelSlicer ts) - { - SetXmlNodeString(_nvPrPath + "/../../a:graphic/a:graphicData/sle:slicer/@name", value); - ts.SlicerName = value; - } - else if (this is ExcelSlicer pts) - { - SetXmlNodeString(_nvPrPath + "/../../a:graphic/a:graphicData/sle:slicer/@name", value); - pts.SlicerName = value; - } + SetName(value); } catch (ArgumentException) { @@ -381,6 +370,20 @@ public virtual string Name } } } + internal void SetName(string name) + { + SetXmlNodeString(_nvPrPath + "/@name", name); + if (this is ExcelSlicer ts) + { + SetXmlNodeString(_nvPrPath + "/../../a:graphic/a:graphicData/sle:slicer/@name", name); + ts.SlicerName = name; + } + else if (this is ExcelSlicer pts) + { + SetXmlNodeString(_nvPrPath + "/../../a:graphic/a:graphicData/sle:slicer/@name", name); + pts.SlicerName = name; + } + } /// @@ -1738,6 +1741,10 @@ public void UnGroup(bool ungroupThisItemOnly = true) } else { + foreach (var item in _parent.Drawings) + { + _drawings.AddDrawingInternal(item); + } _parent.Drawings.Clear(); } if (prevParent.Drawings.Count <= 0) @@ -2507,7 +2514,7 @@ private XmlNode CopyShape(ExcelChartStandard targetChart, bool isGroupShape = fa drawNode.InnerXml = TopNode.InnerXml; var targetShape = GetDrawing(targetChart.Drawings._drawings, drawNode, DrawingsCollectionType.Chart) as ExcelShape; targetShape.Id = ++targetChart.Drawings._nextDrawingId; - targetShape.Name = targetChart.Drawings.GetUniqueDrawingName(this.Name); + targetShape.SetName(targetChart.Drawings.GetUniqueDrawingName(this.Name)); } return drawNode; } diff --git a/src/EPPlus/Drawing/ExcelDrawings.cs b/src/EPPlus/Drawing/ExcelDrawings.cs index ddbfe85fec..8b728b3c14 100644 --- a/src/EPPlus/Drawing/ExcelDrawings.cs +++ b/src/EPPlus/Drawing/ExcelDrawings.cs @@ -214,6 +214,7 @@ internal string GetUniqueDrawingName(string name) { var newName = name; var index = 1; + while (_drawingNames.ContainsKey(newName)) { var split = newName.Split(' '); diff --git a/src/EPPlus/Drawing/ExcelShape.cs b/src/EPPlus/Drawing/ExcelShape.cs index a224eab0de..9914424379 100644 --- a/src/EPPlus/Drawing/ExcelShape.cs +++ b/src/EPPlus/Drawing/ExcelShape.cs @@ -10,7 +10,7 @@ Date Author Change ************************************************************************************************* 01/27/2020 EPPlus Software AB Initial release EPPlus 5 *************************************************************************************************/ -using EPPlus.DrawingRenderer; +using System; using EPPlus.DrawingRenderer.Svg; using EPPlus.Export.Utils; using OfficeOpenXml.Drawing.Interfaces; @@ -20,6 +20,7 @@ Date Author Change using System.Text; using System.Xml; using static Microsoft.IO.RecyclableMemoryStreamManager; +using EPPlus.DrawingRenderer; namespace OfficeOpenXml.Drawing { /// @@ -104,5 +105,6 @@ public string ToSvg(SvgRenderOptions options) svg.Render(sr.RenderItems); return sb.ToString(); } + } } diff --git a/src/EPPlus/EPPlus.csproj b/src/EPPlus/EPPlus.csproj index 4ab88ac051..48a45de13e 100644 --- a/src/EPPlus/EPPlus.csproj +++ b/src/EPPlus/EPPlus.csproj @@ -1,6 +1,9 @@  - net8.0;net9.0;net10.0;netstandard2.1;netstandard2.0;net462 + $(EPPlusCoreTargetFrameworks) + 8.6.3.0 + 8.6.3.0 + 8.6.3 true @@ -18,7 +21,7 @@ readme.md EPPlus Software AB - EPPlus 8.6.1 + EPPlus 8.6.3 IMPORTANT NOTICE! From version 5 EPPlus changes the license model using a dual license, Polyform Non @@ -28,6 +31,12 @@ Commercial licenses can be purchased from https://epplussoftware.com This applies to EPPlus version 5 and later. Earlier versions are still licensed LGPL. + ## Version 8.6.3 + * Updated System.Security.Cryptography.Xml to address security vulnerabilities (CVE-2026-47302, CVE-2026-47304, CVE-2026-50525, CVE-2026-50527, CVE-2026-50648). + + ## Version 8.6.2 + * Minor bug fixes. See https://epplussoftware.com/Developers/MinorFeaturesAndIssues + ## Version 8.6.1 * New functions: * REGEXEXTRACT, REGEXREPLACE, REGEXTEST @@ -621,9 +630,10 @@ https://epplussoftware.com/docs/8.6/articles/fixedissues.html Version history - 8.6.1 20260616 3 new functions. Minor bug fixes. See - https://epplussoftware.com/Developers/MinorFeaturesAndIssues - 8.6.0 20260529 9 new functions. Support for trim Reference operator. + 8.6.3 20260724 Updated System.Security.Cryptography.Xml for security vulnerabilities. + 8.6.2 20260721 Minor bug fixes. See https://epplussoftware.com/Developers/MinorFeaturesAndIssues + 8.6.1 20260616 3 new functions. Minor bug fixes. + 8.6.0 20260529 9 new functions. Support for trim Reference operator. 8.5.4 20260430 Minor bug fixes. 8.5.3 20260416 Updated .NET 8 references incorrectly update to 9.x to 8.x. 8.5.2 20260416 Minor bug fixes. @@ -775,17 +785,14 @@ - + PrivateAssets="all" /> + PrivateAssets="all" /> + PrivateAssets="all" /> + PrivateAssets="all" /> @@ -829,18 +836,6 @@ - - - - - - - - - - - - @@ -856,7 +851,7 @@ + Condition="'$(TargetFramework)' != 'net462' and '$(TargetFramework)' != 'net9.0' and '$(TargetFramework)' != 'net10.0'"> @@ -866,20 +861,20 @@ - + - + - - + + - + - + - - + + diff --git a/src/EPPlus/ExcelTextSettings.cs b/src/EPPlus/ExcelTextSettings.cs index e742497d8d..ecfeaeb505 100644 --- a/src/EPPlus/ExcelTextSettings.cs +++ b/src/EPPlus/ExcelTextSettings.cs @@ -44,7 +44,7 @@ public ITextMeasurer PrimaryTextMeasurer set { _primaryTextMeasurer = value; - _primaryTextMeasurer.MeasureWrappedTextCells = _measureWrappedTextCells; + _primaryTextMeasurer.WrappedTextAutofitMode = WrappedTextAutofitMode; } } /// @@ -59,7 +59,10 @@ public ITextMeasurer FallbackTextMeasurer set { _fallbackTextMeasurer = value; - _fallbackTextMeasurer.MeasureWrappedTextCells = _measureWrappedTextCells; + if(value != null) + { + _fallbackTextMeasurer.WrappedTextAutofitMode = WrappedTextAutofitMode; + } } } /// @@ -89,22 +92,25 @@ public ITextMeasurer GenericTextMeasurer /// Measures a text with default settings when there is no other option left... /// internal DefaultTextMeasurer DefaultTextMeasurer { get; set; } + + private eWrappedTextAutofitMode _wrappedTextAutofitMode = eWrappedTextAutofitMode.Skip; + /// - /// Should return true if the text measurer should measure wrap text cells. Only CR, LF or CRLF should be considered + /// Determines how cells with enabled are measured + /// when calculating column width in AutoFitColumns. The default is , + /// which ignores wrapped cells during autofit. /// - /// True if the measurer can be . - bool _measureWrappedTextCells=false; - internal bool MeasureWrappedTextCells - { + public eWrappedTextAutofitMode WrappedTextAutofitMode + { get { - return _measureWrappedTextCells; + return _wrappedTextAutofitMode; } set { - _measureWrappedTextCells = value; - PrimaryTextMeasurer.MeasureWrappedTextCells = value; - if (FallbackTextMeasurer != null) FallbackTextMeasurer.MeasureWrappedTextCells = value; + _wrappedTextAutofitMode = value; + PrimaryTextMeasurer.WrappedTextAutofitMode = value; + if(FallbackTextMeasurer != null) FallbackTextMeasurer.WrappedTextAutofitMode = value; } } } diff --git a/src/EPPlus/ExcelWorksheet.cs b/src/EPPlus/ExcelWorksheet.cs index 28f394c33c..9379682a89 100644 --- a/src/EPPlus/ExcelWorksheet.cs +++ b/src/EPPlus/ExcelWorksheet.cs @@ -2269,7 +2269,7 @@ public void InsertColumn(int columnFrom, int columns) public void InsertColumn(int columnFrom, int columns, int copyStylesFromColumn) { WorksheetRangeInsertHelper.InsertColumn(this, columnFrom, columns, copyStylesFromColumn); - ColumnLookup.InsertAndShift(columnFrom, columns); + //ColumnLookup.InsertAndShift(columnFrom, columns); } #endregion #region DeleteRow diff --git a/src/EPPlus/FormulaParsing/DependencyChain/RpnOptimizedDependencyChain.cs b/src/EPPlus/FormulaParsing/DependencyChain/RpnOptimizedDependencyChain.cs index 72678303a5..34f64b6ab3 100644 --- a/src/EPPlus/FormulaParsing/DependencyChain/RpnOptimizedDependencyChain.cs +++ b/src/EPPlus/FormulaParsing/DependencyChain/RpnOptimizedDependencyChain.cs @@ -44,6 +44,7 @@ public RpnOptimizedDependencyChain(ExcelWorkbook wb, ExcelCalculationOption opti config.CacheExpressions = options.CacheExpressions; config.PrecisionAndRoundingStrategy = options.PrecisionAndRoundingStrategy; config.AlwaysRefreshImageFunction = options.AlwaysRefreshImageFunction; + config.DisableImageFunctionDownloads = options.DisableImageFunctionDownloads; config.EnableUnicodeAwareStringOperations = options.EnableUnicodeAwareStringOperations; }); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Filter.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Filter.cs index 829edc6e55..19fd0a7647 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Filter.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Filter.cs @@ -137,13 +137,23 @@ private static CompileResult FilterOnColumn(IRangeInfo arg1, IRangeInfo arg2, Fu var checkDimension = !arg2.IsInMemoryRange; var fc = arg2.Address.FromCol; var dfc = arg2.Dimension.FromCol; - for (int c = 0; c < s2.NumberOfCols; c++) + var columns = s2.NumberOfCols == 1 ? s1.NumberOfCols : s2.NumberOfCols; + for (int c = 0; c < columns; c++) { if (checkDimension && fc + c > dfc) { break; } - var boolValue = ConvertUtil.GetValueDouble(arg2.GetOffset(0, c), false, true); + int boolIx; + if(s2.NumberOfCols == 1) + { + boolIx = 0; + } + else + { + boolIx = c; + } + var boolValue = ConvertUtil.GetValueDouble(arg2.GetOffset(0, boolIx), false, true); if (double.IsNaN(boolValue)) { return CompileResult.GetDynamicArrayResultError(eErrorType.Value); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunction.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunction.cs index 491a8b4661..5d7328df68 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunction.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunction.cs @@ -119,7 +119,7 @@ public override CompileResult Execute(IList arguments, Parsing if (cellPic == null || context.Configuration.AlwaysRefreshImageFunction) { var httpsService = context.CurrentWorksheet._package.Settings.ImageFunctionService; - if (httpsService == null) + if (httpsService == null || context.Configuration.DisableImageFunctionDownloads) { return CreateResult(eErrorType.Name); } diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/XlookupScanner.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/XlookupScanner.cs index 6dcc60ceff..91f2d79773 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/XlookupScanner.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/LookupUtils/XlookupScanner.cs @@ -83,39 +83,43 @@ public int FindIndex() private int FindIndexInternal() { var direction = GetLookupDirection(); - int maxItems; + int maxItems; + int startOffset; if (direction == LookupRangeDirection.Vertical) { - maxItems = GetMaxItemsRow(_lookupRange); + maxItems = GetMaxItemsRow(_lookupRange, out startOffset); } else { - //dimensionItems = _lookupRange.Dimension.ToCol - _lookupRange.Dimension.FromCol + 1; - //maxItems = _lookupRange.FontSize.NumberOfCols > dimensionItems ? dimensionItems : _lookupRange.FontSize.NumberOfCols; - maxItems = GetMaxItemsColumns(_lookupRange); + maxItems = GetMaxItemsColumns(_lookupRange, out startOffset); } int closestBelowIx = -1; int closestAboveIx = -1; object closestBelow = null; object closestAbove = null; + // ix is relative to the clamped start (startOffset). The value read and the + // index returned are offset by startOffset so they stay relative to the + // range's own FromRow/FromCol, while iteration is limited to the populated + // area to preserve performance on open ranges (e.g. A:A). var ix = _searchMode == LookupSearchMode.ReverseStartingAtLast ? maxItems - 1 : 0; - while (ix >= 0) + while (ix >= 0 && ix < maxItems) { + var actualIx = startOffset + ix; object value = direction == LookupRangeDirection.Vertical ? - _lookupRange.GetOffset(ix, 0) : - _lookupRange.GetOffset(0, ix); + _lookupRange.GetOffset(actualIx, 0) : + _lookupRange.GetOffset(0, actualIx); var cr = _comparer.Compare(_lookupValue, value); if (cr == 0) { - return ix; + return actualIx; } else if (cr < 0) { if (closestAbove == null || _comparer.Compare(closestAbove, value) > 0) { closestAbove = value; - closestAboveIx = ix; + closestAboveIx = actualIx; } } else @@ -123,16 +127,12 @@ private int FindIndexInternal() if (closestBelow == null || _comparer.Compare(closestBelow, value) < 0) { closestBelow = value; - closestBelowIx = ix; + closestBelowIx = actualIx; } } if (_searchMode == LookupSearchMode.StartingAtFirst) { ix++; - if (ix >= maxItems) - { - ix = -1; - } } else { @@ -151,11 +151,14 @@ private int FindIndexInternal() } - private int GetMaxItemsRow(IRangeInfo lookupRange) + private int GetMaxItemsRow(IRangeInfo lookupRange, out int startOffset) { + startOffset = 0; var adjusted = lookupRange.GetAddressDimensionAdjusted(0); if (adjusted != null) { + startOffset = adjusted.FromRow - lookupRange.Address.FromRow; + if (startOffset < 0) startOffset = 0; return adjusted.ToRow - adjusted.FromRow + 1; } if (lookupRange.Address.ToRow > lookupRange.Dimension.ToRow) @@ -165,11 +168,14 @@ private int GetMaxItemsRow(IRangeInfo lookupRange) return _lookupRange.Size.NumberOfRows; } - private int GetMaxItemsColumns(IRangeInfo lookupRange) + private int GetMaxItemsColumns(IRangeInfo lookupRange, out int startOffset) { + startOffset = 0; var adjusted = lookupRange.GetAddressDimensionAdjusted(0); if (adjusted != null) { + startOffset = adjusted.FromCol - lookupRange.Address.FromCol; + if (startOffset < 0) startOffset = 0; return adjusted.ToCol - adjusted.FromCol + 1; } if (lookupRange.Address.ToCol > lookupRange.Dimension.ToCol) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/Search.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/Search.cs index 7ead6b262a..93906f3198 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/Text/Search.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/Search.cs @@ -29,7 +29,7 @@ internal class Search : ExcelFunction public override ExcelFunctionArrayBehaviour ArrayBehaviour => ExcelFunctionArrayBehaviour.Custom; public override void ConfigureArrayBehaviour(ArrayBehaviourConfig config) { - config.SetArrayParameterIndexes(0, 1); + config.SetArrayParameterIndexes(0, 1, 2); } public override int ArgumentMinLength => 2; diff --git a/src/EPPlus/FormulaParsing/ExcelCalculationOption.cs b/src/EPPlus/FormulaParsing/ExcelCalculationOption.cs index 3e61ec6539..809de23ab4 100644 --- a/src/EPPlus/FormulaParsing/ExcelCalculationOption.cs +++ b/src/EPPlus/FormulaParsing/ExcelCalculationOption.cs @@ -116,6 +116,13 @@ public bool AlwaysRefreshImageFunction set; } = false; + /// + /// If true the IMAGE function will never download external content in formula calculation. + /// It will instead return the NAME error as if the function was not implemented. + /// NB! This property overrides the property if set to true. + /// + public bool DisableImageFunctionDownloads { get; set; } = false; + /// /// Enables Unicode-aware string operations, ensuring correct handling of surrogate pairs for comparisons, substrings, and sorting within the library. /// diff --git a/src/EPPlus/FormulaParsing/ParsingConfiguration.cs b/src/EPPlus/FormulaParsing/ParsingConfiguration.cs index 904f3b81a6..39b4ba4b47 100644 --- a/src/EPPlus/FormulaParsing/ParsingConfiguration.cs +++ b/src/EPPlus/FormulaParsing/ParsingConfiguration.cs @@ -46,6 +46,13 @@ public class ParsingConfiguration /// public bool AlwaysRefreshImageFunction { get; set; } = false; + /// + /// If true the IMAGE function will never download external content in formula calculation. + /// It will instead return the NAME error as if the function was not implemented. + /// NB! This property overrides the property if set to true. + /// + public bool DisableImageFunctionDownloads{ get; set; } = false; + /// /// Enables Unicode-aware string operations, ensuring correct handling of surrogate pairs for comparisons, substrings, and sorting within the library. /// diff --git a/src/EPPlusTest/AutofitTests.cs b/src/EPPlusTest/AutofitTests.cs new file mode 100644 index 0000000000..8d50fc42fc --- /dev/null +++ b/src/EPPlusTest/AutofitTests.cs @@ -0,0 +1,368 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using OfficeOpenXml.Interfaces.Drawing.Text; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlusTest +{ + [TestClass] + public class AutofitTests : TestBase + { + static ExcelPackage _pck; + [ClassInitialize] + public static void Init(TestContext context) + { + InitBase(); + _pck = OpenPackage("Worksheet.xlsx", true); + } + [ClassCleanup] + public static void Cleanup() + { + var dirName = _pck.File.DirectoryName; + var fileName = _pck.File.FullName; + + SaveAndCleanup(_pck); + if (File.Exists(fileName)) + { + File.Copy(fileName, dirName + "\\WorksheetRead.xlsx", true); + } + } + + [TestMethod] + public void AutoFitColumns() + { + var ws = _pck.Workbook.Worksheets.Add("Autofit"); + ws.Cells["A1:H1"].Value = "Auto fit column that is veeery long..."; + ws.Cells["A1:H1"].Style.Font.Name = "Arial"; + ws.Cells["B1"].Style.TextRotation = 30; + ws.Cells["C1"].Style.TextRotation = 45; + ws.Cells["D1"].Style.TextRotation = 75; + ws.Cells["E1"].Style.TextRotation = 90; + ws.Cells["F1"].Style.TextRotation = 120; + ws.Cells["G1"].Style.TextRotation = 135; + ws.Cells["H1"].Style.TextRotation = 180; + ws.Cells["A1:H1"].AutoFitColumns(0); + + ws.Column(40).AutoFit(); + } + [TestMethod] + public void AutoFitColumn() + { + var ws = _pck.Workbook.Worksheets.Add("Autofit2"); + ws.Cells["A1:A10"].Value = "Auto fit column that is veeery long..."; + ws.Cells["A1:A10"].Style.Font.Name = "Arial"; + ws.Columns[1].AutoFit(); + } + + [TestMethod] + public void AutoFitColumnTest() + { + var p = OpenTemplatePackage("AutoFitWorkbook.xlsx"); + var ws = p.Workbook.Worksheets[0]; + var start = DateTime.Now; + ws.Columns[1].AutoFit(); + var end = DateTime.Now; + TimeSpan span = end - start; + Assert.AreEqual(125d, ws.Columns[1].Width, 5d); + SaveAndCleanup(p); + } + + [TestMethod] + public void AutofitAutofilterTest() + { + using var package = OpenTemplatePackage("AutoFitAutofilter.xlsx"); + var ws = package.Workbook.Worksheets.Add("Sheet1"); + + // Headers are the widest text in each column - the data below is deliberately + // shorter so the column width is driven by the header + the autofilter dropdown arrow. + ws.Cells["A1"].Value = "Department"; + ws.Cells["B1"].Value = "Annual Budget"; + ws.Cells["C1"].Value = "Region Name"; + + // Data rows - all shorter than the headers above them. + ws.Cells["A2"].Value = "Sales"; + ws.Cells["B2"].Value = 1200; + ws.Cells["C2"].Value = "North"; + + ws.Cells["A3"].Value = "IT"; + ws.Cells["B3"].Value = 980; + ws.Cells["C3"].Value = "West"; + + ws.Cells["A4"].Value = "HR"; + ws.Cells["B4"].Value = 540; + ws.Cells["C4"].Value = "East"; + + // Apply autofilter across the header row + data. + ws.Cells["A1:C4"].AutoFilter = true; + + // Autofit the columns. + ws.Cells["A1:C4"].AutoFitColumns(); + + // Inspect what EPPlus actually produced for each column. + System.Diagnostics.Debug.WriteLine($"Column A (Department): {ws.Column(1).Width}"); + System.Diagnostics.Debug.WriteLine($"Column B (Annual Budget): {ws.Column(2).Width}"); + System.Diagnostics.Debug.WriteLine($"Column C (Region Name): {ws.Column(3).Width}"); + + // Save the workbook + SaveAndCleanup(package); + } + + [TestMethod] + public void AutoFitColumnsWithAutoFilter() + { + var ws = _pck.Workbook.Worksheets.Add("AutofitAutoFilter"); + ws.Cells["A1"].Value = "hour"; + ws.Cells["B1"].Value = "minute"; + ws.Cells["A2"].Value = 12; + ws.Cells["B2"].Value = 30; + + ws.Cells["A1:B2"].AutoFilter = true; + + ws.Cells["A1:B2"].AutoFitColumns(); + + // Without the fix, the AutoFilter header row range (A1:B1) is measured as a whole. + // Under the hood, worksheet.Cells["A1:B1"].TextForWidth evaluated to "System.Object[,]" (16 chars), + // which forced a minimum width of ~16.07 points. + // With the fix, the specific cell for each column in the AutoFilter is measured, + // resulting in a narrow width matching "hour" / "minute". + Assert.IsTrue(ws.Column(1).Width < 12d, $"Column 1 width should be small but was {ws.Column(1).Width}"); + Assert.IsTrue(ws.Column(2).Width < 12d, $"Column 2 width should be small but was {ws.Column(2).Width}"); + } + + [TestMethod] + public void Autofit_Skip() + { + // Skip: a WrapText cell must not contribute to the column width at all. + // Column A holds a long wrapped cell; column B holds nothing. Under Skip the + // wrapped cell is ignored, so both columns end up at the same (default) width. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.Skip; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Line 1\nLine 2 is a bit longer\nLine 3"; + ws.Cells["A1"].Style.WrapText = true; + // Act + ws.Cells["A1:B1"].AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Skip should ignore the wrapped cell, so column A matches the empty column B"); + } + + [TestMethod] + public void Autofit_FullText() + { + // FullText: the entire cell text is measured as a single line. + // The reference cell B holds the same text; because a WrapText cell keeps its + // newlines (measured as zero width), B must use identical newline placement so + // both cells measure the exact same visible characters. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.FullText; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Line 1\nLine 2 is a bit longer\nLine 3"; + ws.Cells["B1"].Value = "Line 1\nLine 2 is a bit longer\nLine 3"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "FullText should measure the entire string, matching the identical reference cell"); + } + + [TestMethod] + public void Autofit_SplitNewLine() + { + // SplitNewLine: the widest newline-separated line drives the width. + // Reference cell B holds that line ("Line 2 is a bit longer") on its own. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Line 1\nLine 2 is a bit longer\nLine 3"; + ws.Cells["B1"].Value = "Line 2 is a bit longer"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Column A (widest line) should match column B (that line in full)"); + } + + [TestMethod] + public void Autofit_SplitWord() + { + // SplitWord: the widest whitespace/hyphen-separated segment drives the width. + // Reference cell B holds that word ("aVeryLongWord") on its own. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitWord; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "short longer aVeryLongWord medium"; + ws.Cells["B1"].Value = "aVeryLongWord"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Column A (widest word) should match column B (that word in full)"); + } + + [TestMethod] + public void Autofit_SplitWord_HyphenIsVisibleAndBreaksTheWord() + { + // A hyphen is a visible break boundary: it terminates the preceding segment + // AND its own width is counted into that segment. So the widest segment of + // "aVeryLongWord-x" is "aVeryLongWord-" (word + trailing hyphen), not the bare word. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitWord; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "aVeryLongWord-x"; + ws.Cells["B1"].Value = "aVeryLongWord-"; // includes the trailing hyphen + ws.Cells["C1"].Value = "aVeryLongWord"; // bare word, no hyphen + ws.Cells["A1:C1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Segment should include the trailing hyphen (visible boundary)"); + Assert.IsTrue(ws.Columns[1].Width > ws.Columns[3].Width, + "Segment with hyphen should be wider than the bare word without it"); + } + + [TestMethod] + public void Autofit_SplitNewLine_CrlfCountsAsSingleBreak() + { + // A CRLF pair must be treated as one line break, not two. If it were counted + // as two breaks it would create an empty phantom segment between the lines, + // but that has zero width and would not change the result. What this guards + // is that the CR is not measured as a separate one-character segment and that + // the widest line is identified correctly across a CRLF boundary. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Short\r\nLine 2 is a bit longer\r\nShort"; + ws.Cells["B1"].Value = "Line 2 is a bit longer"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "CRLF should be treated as a single line break; widest line should match column B"); + } + + [TestMethod] + public void Autofit_SplitNewLine_WidestSegmentFirstIsStillChosen() + { + // The widest segment appears FIRST here. This guards against a reset bug where + // the running width of an earlier (wider) segment fails to carry over into the + // max comparison once a later, narrower segment is measured. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Line 1 is clearly the longest\nShort\nAlso short"; + ws.Cells["B1"].Value = "Line 1 is clearly the longest"; + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "The widest line is the first one and must still drive the column width"); + } + + [TestMethod] + public void Autofit_SplitNewLine_EastAsianWidthResetsPerLine() + { + // Regression test for the East Asian width bug: previously the EA width (widthEA) + // accumulated across ALL lines and was never reset at a line break, so a multi-line + // CJK cell was measured as the SUM of every line's EA width instead of the widest + // single line. Here the three lines are 2, 5 and 3 hiragana characters; the correct + // result is the width of the 5-character line. With the bug it would be roughly the + // width of all 10 characters combined. + using var package = new ExcelPackage(); + package.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "\u3042\u3042\n\u3042\u3042\u3042\u3042\u3042\n\u3042\u3042\u3042"; // , , + ws.Cells["B1"].Value = "\u3042\u3042\u3042\u3042\u3042"; // (widest line) + ws.Cells["A1:B1"].Style.WrapText = true; + // Act + ws.Cells.AutoFitColumns(); + // Assert + Assert.AreEqual(ws.Columns[2].Width, ws.Columns[1].Width, 0.0001d, + "Multi-line CJK column should match its widest line, not the sum of all lines"); + } + + [TestMethod] + public void AutoFitTestWithDifferenLengths() + { + using (var p = OpenPackage("SimpleAutofitTests.xlsx", true)) + { + //p.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitWord; + var ws = p.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "Little"; + ws.Cells["A2"].Value = "MEDIUM"; + ws.Cells["A3"].Value = "Largeeeeeesssst"; + ws.Cells["A4"].Value = "Larg-ish"; + + ws.Cells["B1"].Value = "I should not be autofit"; + + var bWidth = ws.Columns[2].Width; + + ws.Cells["A1:A4"].AutoFitColumns(); + + var bWidthAfter = ws.Columns[2].Width; + + //Untouched cols should remain untouched + Assert.AreEqual(bWidth, bWidthAfter); + + ws.Cells["A5"].Value = "Very large but outside the range of what should be fitted"; + + var widthBeforeA = ws.Columns[1].Width; + + ws.Cells["A1:A4"].AutoFitColumns(); + + var widthAfterA = ws.Columns[1].Width; + + + //Untouched cells within same column Probaly should not change the column + //technically different from excel but also different syntax + Assert.AreEqual(widthBeforeA, widthAfterA); + + + ws.Cells.AutoFitColumns(); + + //Doing all cells should however + Assert.AreNotEqual(widthAfterA, ws.Columns[1].Width); + Assert.IsTrue(ws.Columns[1].Width > widthAfterA); + + SaveAndCleanup(p); + } + } + + [TestMethod] + public void AutofitOneCellCompoundingConfigs() + { + using (var p = OpenPackage("AutofitCompoundOneCell.xlsx", true)) + { + //p.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitWord; + var ws = p.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Value = "aaaaaaaaaaaaaaaaa"; + + ws.Cells["A1"].AutoFitColumns(); + + ws.Cells["A1"].Value = "aaaaaaaaaaaaaaaaaaaaaaaa"; + ws.Cells["A1"].Style.WrapText = true; + + ws.Cells["A1"].AutoFitColumns(); + + var colWidth = ws.Column(1).Width; + + SaveAndCleanup(p); + + //Does not appear to match output file + //Might still be correct bc OS margins etc. + Assert.AreEqual(9.140625, colWidth); + } + } + } +} diff --git a/src/EPPlusTest/Core/Worksheet/HeaderrFooterTests.cs b/src/EPPlusTest/Core/Worksheet/HeaderrFooterTests.cs new file mode 100644 index 0000000000..57273bdcc2 --- /dev/null +++ b/src/EPPlusTest/Core/Worksheet/HeaderrFooterTests.cs @@ -0,0 +1,85 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; + +namespace EPPlusTest.Core.Worksheet +{ + [TestClass] + public class HeaderrFooterTests + { + [TestMethod] + public void Issue_CopyWorksheet_HeaderFooter_Throws() + { + // Direct C# translation of the reporter's "Reproduction — throws" script. + // Source is any workbook that has a header/footer picture. + byte[] sourceBytes = CreateWorkbookWithHeaderFooterPicture(); + + using (var sourceStream = new MemoryStream(sourceBytes)) + using (var source = new ExcelPackage(sourceStream)) + { + var sourceSheet = source.Workbook.Worksheets[0]; + + // The trigger: read a HeaderFooter property on the source first. + var trigger = sourceSheet.HeaderFooter.OddFooter.CenteredText; + + using (var target = new ExcelPackage()) + { + // Before the fix: throws NullReferenceException from + // WorksheetCopyHelper.CopyHeaderFooterPictures. + target.Workbook.Worksheets.Add("Copied", sourceSheet); + } + } + } + + [TestMethod] + public void Issue_CopyWorksheet_HeaderFooter_SilentDataLoss() + { + byte[] sourceBytes = CreateWorkbookWithHeaderFooterPicture(); + + byte[] outputBytes; + using (var sourceStream = new MemoryStream(sourceBytes)) + using (var source = new ExcelPackage(sourceStream)) + { + var sourceSheet = source.Workbook.Worksheets[0]; + + // No HeaderFooter read here. + + using (var target = new ExcelPackage()) + { + var copiedSheet = target.Workbook.Worksheets.Add("Copied", sourceSheet); + + Assert.AreEqual(1, copiedSheet.HeaderFooter.Pictures.Count, + "Header/footer picture missing right after copy."); + + outputBytes = target.GetAsByteArray(); + } + } + + // After save + reopen. + using (var reopenStream = new MemoryStream(outputBytes)) + using (var reopen = new ExcelPackage(reopenStream)) + { + var reopenedSheet = reopen.Workbook.Worksheets["Copied"]; + Assert.AreEqual(1, reopenedSheet.HeaderFooter.Pictures.Count, + "Header/footer picture was lost after save and reopen."); + } + } + + private static byte[] CreateWorkbookWithHeaderFooterPicture() + { + // Stands in for the reporter's "/path/to/any-workbook-with-a-footer.xlsx". + using (var source = new ExcelPackage()) + { + var ws = source.Workbook.Worksheets.Add("Sheet1"); + ws.HeaderFooter.OddFooter.CenteredText = "MyFooter"; + ws.HeaderFooter.OddFooter.InsertPicture(Properties.Resources.Test1, PictureAlignment.Centered); + return source.GetAsByteArray(); + } + } + } +} diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/FilterTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/FilterTests.cs index 5b3e78297c..6c248e1e75 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/FilterTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/FilterTests.cs @@ -83,5 +83,26 @@ public void FilterShouldHandleNAAsIfEmptyValue() Assert.AreEqual("Joe", s.Cells["C1"].Value); } } + + [TestMethod] + public void Filter_SingleColumnInclude_BroadcastsAcrossAllValueColumns() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + + s.Cells["A1"].Value = "GL-40010 - Office Supplies"; + s.Cells["B1"].Value = 4520.75d; + s.Cells["C1"].Value = "Not Posted"; + + s.Cells["E1"].Formula = "FILTER(A1:B1, C1 <> \"Posted!\", 0)"; + s.Calculate(); + + Assert.AreEqual("GL-40010 - Office Supplies", s.Cells["E1"].Value, + "Label-kolumnen ska behållas."); + Assert.AreEqual(4520.75d, s.Cells["F1"].Value, + "Beloppskolumnen ska också behållas via broadcast."); + } + } } } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunctionTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunctionTests.cs index 802089dd55..1691a7d7de 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunctionTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/ImageFunctionTests.cs @@ -133,5 +133,34 @@ public void ImageTest_ShouldCacheUrls1() Assert.AreEqual(1, httpsService.NumberOfCalls); } + + [TestMethod] + public void ImageTest_ShouldReturnNameErrorWhenServiceIsNull() + { + using var package = new ExcelPackage(); + var sheet = package.Workbook.Worksheets.Add("Sheet1"); + sheet.Cells["A1"].Formula = "IMAGE(\"https://epplussoftware.com/img/EPPlus-logo-full.png\", \"Alt text\", 1)"; + + package.Settings.ImageFunctionService = null; + + sheet.Calculate(); + + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Name), sheet.Cells["A1"].Value); + } + + [TestMethod] + public void ImageTest_ShouldNotDownloadWhenDownloadsDisabled() + { + using var package = new ExcelPackage(); + var httpsService = new TestHttpsService(); + package.Settings.ImageFunctionService = httpsService; + var sheet = package.Workbook.Worksheets.Add("Sheet1"); + sheet.Cells["A1"].Formula = "IMAGE(\"https://epplussoftware.com/img/EPPlus-logo-full.png\", \"Alt text\", 1)"; + + sheet.Calculate(opt => opt.DisableImageFunctionDownloads = true); + + Assert.AreEqual(0, httpsService.NumberOfCalls, "the download service should not be called when downloads are disabled"); + Assert.AreEqual(ExcelErrorValue.Create(eErrorType.Name), sheet.Cells["A1"].Value); + } } } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/LookupScannerTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/LookupScannerTests.cs index ba2f71e430..ef215f9949 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/LookupScannerTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/LookupScannerTests.cs @@ -150,5 +150,22 @@ public void ShouldFindExactMatch_StartAtFirst_Horizontal_1() var ix = scanner.FindIndex(); Assert.AreEqual(2, ix); } + + [TestMethod] + public void ShouldFindExactMatch_WhenRangeStartsBeforeWorksheetDimension() + { + // Arrange: Leave rows 1-4 empty. Worksheet dimension will start at Row 5. + _sheet.Cells[5, 2].Value = "Apple"; + _sheet.Cells[6, 2].Value = "Pear"; + // Lookup range B2:B6 starts at row 2 (before worksheet dimension starts). + var ri = new RangeInfo(_sheet, _sheet.Cells["B2:B6"]); + var scanner = new XlookupScanner("Pear", ri, LookupSearchMode.StartingAtFirst, LookupMatchMode.ExactMatch); + + // Act + var ix = scanner.FindIndex(); + + // Assert: "Pear" is at index 4 (relative to B2). In EPPlus 8.5.0+ this returns -1 instead. + Assert.AreEqual(4, ix); + } } } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/XLookupTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/XLookupTests.cs index 80aa4ea81a..5b68da0a62 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/XLookupTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/XLookupTests.cs @@ -467,5 +467,46 @@ public void XlookupReturnEmptyString() ws.Cells["A2"].Calculate(); Assert.AreEqual("", ws.Cells["A2"].Value); } + + [TestMethod] + public void ShouldFindValue_WhenRangeStartsBeforeWorksheetDimension() + { + // Data starts at row 5, but the lookup range starts at row 2 (before the + // worksheet dimension). Regression test for lookup ranges whose FromRow + // is smaller than Worksheet.Dimension.FromRow (issue in 8.5.0-8.6.3). + _sheet.Cells[5, 1].Value = "Apple"; + _sheet.Cells[5, 2].Value = "+11"; + _sheet.Cells[6, 1].Value = "Pear"; + _sheet.Cells[6, 2].Value = "+22"; + + _sheet.Cells["E2"].Value = "Pear"; + _sheet.Cells["F2"].Formula = "XLOOKUP(E2,A2:A6,B2:B6,\"Not found\")"; + + _sheet.Calculate(); + + Assert.AreEqual("+22", _sheet.Cells["F2"].Value.ToString()); + } + + [TestMethod] + [DataRow(1, "+11")] // StartingAtFirst + [DataRow(-1, "+11")] // ReverseStartingAtLast + public void ShouldFindValue_WhenOpenRangeAndDataStartsFarDown(int searchMode, string expected) + { + // Open column ranges (A:A) where the data begins far down the sheet. + // The scanner must clamp both ends to the worksheet dimension for + // performance while still returning a range-relative index, in both + // forward and reverse search mode. + _sheet.Cells[10000, 1].Value = "Apple"; + _sheet.Cells[10000, 2].Value = "+11"; + _sheet.Cells[10001, 1].Value = "Pear"; + _sheet.Cells[10001, 2].Value = "+22"; + + _sheet.Cells["E2"].Value = "Apple"; + _sheet.Cells["F2"].Formula = $"XLOOKUP(E2,A:A,B:B,\"Not found\", 0, {searchMode})"; + + _sheet.Calculate(); + + Assert.AreEqual(expected, _sheet.Cells["F2"].Value.ToString()); + } } } diff --git a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs index 3c4db3c2db..1130ea6626 100644 --- a/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs +++ b/src/EPPlusTest/Issues/ConditionalFormattingIssues.cs @@ -3,9 +3,11 @@ using OfficeOpenXml.Compatibility.System.Drawing; using OfficeOpenXml.ConditionalFormatting; using OfficeOpenXml.ConditionalFormatting.Contracts; +using OfficeOpenXml.ConditionalFormatting.Rules; using OfficeOpenXml.Style; using System.Drawing; using System.Globalization; +using System.IO; using System.Threading; using ColorTranslator = System.Drawing.ColorTranslator; @@ -218,5 +220,88 @@ public void i2381Excel() package.SaveAs(file); } } + + [TestMethod] + public void RoundTrip_ExtIconSetWithNumericFormulaCfvo_DoesNotThrow() + { + // Arrange - build an icon set that is written to the extLst + // (3Stars always goes to extLst) with a Formula-type threshold + // whose value is a numeric constant. Saving to a stream produces + // the exact same OOXML bytes as saving to a file. + var stream = new MemoryStream(); + + using (var package = new ExcelPackage()) + { + var ws = package.Workbook.Worksheets.Add("Sheet1"); + + for (int row = 1; row <= 10; row++) + { + ws.Cells[row, 1].Value = row; + } + + var iconSet = ws.ConditionalFormatting.AddThreeIconSet( + new ExcelAddress("A1:A10"), + eExcelconditionalFormatting3IconsSetType.Stars); + + // Third threshold: Formula type with a numeric constant. + iconSet.Icon3.Type = eExcelConditionalFormattingValueObjectType.Formula; + iconSet.Icon3.Formula = "67"; + + package.SaveAs(stream); + } + + // Act & Assert - reloading must not throw (threw before the fix + // in ApplyIconSetExtValues). + stream.Position = 0; + using (var package = new ExcelPackage(stream)) + { + var ws = package.Workbook.Worksheets[0]; + var iconSet = (IExcelConditionalFormattingThreeIconSet) + ws.ConditionalFormatting[0]; + + Assert.AreEqual(eExcelConditionalFormattingValueObjectType.Formula, iconSet.Icon3.Type); + Assert.AreEqual("67", iconSet.Icon3.Formula); + } + } + + [TestMethod] + public void RoundTrip_RegularIconSetWithNumericFormulaCfvo_DoesNotThrow() + { + // Arrange - a regular (non-ext) icon set with a Formula-type + // threshold whose value is a numeric constant. This exercises the + // ReadIcon path, which was already correct, guarding against a + // future regression there. + var stream = new MemoryStream(); + + using (var package = new ExcelPackage()) + { + var ws = package.Workbook.Worksheets.Add("Sheet1"); + + for (int row = 1; row <= 10; row++) + { + ws.Cells[row, 1].Value = row; + } + + var iconSet = ws.ConditionalFormatting.AddThreeIconSet( + new ExcelAddress("A1:A10"), + eExcelconditionalFormatting3IconsSetType.Arrows); // regular set, not extLst + + iconSet.Icon3.Type = eExcelConditionalFormattingValueObjectType.Formula; + iconSet.Icon3.Formula = "67"; + + package.SaveAs(stream); + } + + stream.Position = 0; + using (var package = new ExcelPackage(stream)) + { + var ws = package.Workbook.Worksheets[0]; + var iconSet = (IExcelConditionalFormattingThreeIconSet) + ws.ConditionalFormatting[0]; + + Assert.AreEqual(eExcelConditionalFormattingValueObjectType.Formula, iconSet.Icon3.Type); + Assert.AreEqual("67", iconSet.Icon3.Formula); + } + } } } diff --git a/src/EPPlusTest/Issues/DefinedNameIssues.cs b/src/EPPlusTest/Issues/DefinedNameIssues.cs index 2ac08c50fd..15f7b8e221 100644 --- a/src/EPPlusTest/Issues/DefinedNameIssues.cs +++ b/src/EPPlusTest/Issues/DefinedNameIssues.cs @@ -253,3 +253,4 @@ static void RunTest(string name, Func<(ExcelPackage pkg, ExcelWorksheet ws1, Exc } } } + diff --git a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs index ca6b0696d7..fe27dcb745 100644 --- a/src/EPPlusTest/Issues/FormulaCalculationIssues.cs +++ b/src/EPPlusTest/Issues/FormulaCalculationIssues.cs @@ -1666,6 +1666,63 @@ public void s1050() } } + [TestMethod] + public void s1063_Isolated() + { + using (var p = OpenPackage("Search3ArgsArrays.xlsx", true)) + { + var ws = p.Workbook.Worksheets.Add("args"); + + ws.Cells["A1:A3"].Formula = "\"I.am.a.Beautiful.Creature\" & ROW()"; + + ws.Cells["A1:A3"].Calculate(); + + ws.Cells["B1:B3"].Formula = "(ROW()-1)*2 + 1"; + ws.Cells["B1:B3"].Calculate(); + + ws.Cells["C1"].CreateArrayFormula("SEARCH(\".\",A1:A3,B1:B3+1)", true); + ws.Cells["C1"].Calculate(); + + var range = ws.Cells["C1:C3"]; + + var myValues = ws.Cells["C1:C3"].Value; + + List intValues = new List(); + + foreach(var cell in range) + { + intValues.Add(cell.GetValue()); + } + + Assert.AreEqual(2, intValues[0]); + Assert.AreEqual(5, intValues[1]); + Assert.AreEqual(7, intValues[2]); + + SaveAndCleanup(p); + } + } + + [TestMethod] + public void s1063() + { + using (var p = OpenTemplatePackage("issues\\s1063.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + + ws.ClearFormulaValues(); + ws.Calculate(); + + Assert.AreEqual(ws.Cells["A1"].Value.ToString(), "R&D Project"); + Assert.AreEqual(ws.Cells["A2"].Value.ToString(), "Labwerks"); + Assert.AreEqual(ws.Cells["A3"].Value.ToString(), "R&D Single Order"); + + p.Workbook.CalcMode = ExcelCalcMode.Manual; + + SaveWorkbook("s1063-saved.xlsx", p); + } + } + + [TestMethod] public void s1054() { @@ -1678,5 +1735,18 @@ public void s1054() Assert.AreEqual(1258679d, result); } } + + [TestMethod] + public void s1060() + { + using (var p = OpenTemplatePackage("s1060.xlsx")) + { + var ws = p.Workbook.Worksheets[0]; + ws.Cells["A12"].Calculate(); + var result = ws.Cells["A13"].Value; + Assert.AreEqual(4520.75, result); + } + } + } } \ No newline at end of file diff --git a/src/EPPlusTest/Issues/WorksheetIssues.cs b/src/EPPlusTest/Issues/WorksheetIssues.cs index adf4fe6a98..c7146ca87d 100644 --- a/src/EPPlusTest/Issues/WorksheetIssues.cs +++ b/src/EPPlusTest/Issues/WorksheetIssues.cs @@ -7,6 +7,7 @@ using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; using OfficeOpenXml.FormulaParsing.Excel.Functions.Logical; using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; +using OfficeOpenXml.Interfaces.Drawing.Text; using OfficeOpenXml.RichData; using OfficeOpenXml.SystemDrawing.Image; using OfficeOpenXml.SystemDrawing.Text; @@ -880,16 +881,16 @@ private static void AddMeasureSheet(ExcelPackage p, ExcelWorksheet ws) ws.Cells["B2"].Value = multiLineText; - p.Settings.TextSettings.MeasureWrappedTextCells = true; - // AutoFitColumns - calculates width as if there were no line breaks. + p.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.SplitNewLine; + // AutoFitColumns - measures the widest newline-separated line. ws.Cells["A1:B2"].AutoFitColumns(); - p.Settings.TextSettings.MeasureWrappedTextCells = false; + p.Settings.TextSettings.WrappedTextAutofitMode = eWrappedTextAutofitMode.Skip; ws.Cells["C1"].Value = multiLineText; ws.Cells["C1"].Style.WrapText = true; ws.Cells["D2"].Value = multiLineText; - // AutoFitColumns - calculates width as if there were no line breaks. + // AutoFitColumns - wrapped cells are ignored. ws.Cells["C1:D2"].AutoFitColumns(); } diff --git a/src/EPPlusTest/WorkSheetTests.cs b/src/EPPlusTest/WorkSheetTests.cs index 9f6422f2ed..a1eab58cde 100644 --- a/src/EPPlusTest/WorkSheetTests.cs +++ b/src/EPPlusTest/WorkSheetTests.cs @@ -2144,105 +2144,7 @@ public void BuildInStyles() ws.Cells["a1:c3"].StyleName = "Normal"; // n.CustomBuildin = true; } - [TestMethod] - public void AutoFitColumns() - { - var ws = _pck.Workbook.Worksheets.Add("Autofit"); - ws.Cells["A1:H1"].Value = "Auto fit column that is veeery long..."; - ws.Cells["A1:H1"].Style.Font.Name = "Arial"; - ws.Cells["B1"].Style.TextRotation = 30; - ws.Cells["C1"].Style.TextRotation = 45; - ws.Cells["D1"].Style.TextRotation = 75; - ws.Cells["E1"].Style.TextRotation = 90; - ws.Cells["F1"].Style.TextRotation = 120; - ws.Cells["G1"].Style.TextRotation = 135; - ws.Cells["H1"].Style.TextRotation = 180; - ws.Cells["A1:H1"].AutoFitColumns(0); - - ws.Column(40).AutoFit(); - } - [TestMethod] - public void AutoFitColumn() - { - var ws = _pck.Workbook.Worksheets.Add("Autofit2"); - ws.Cells["A1:A10"].Value = "Auto fit column that is veeery long..."; - ws.Cells["A1:A10"].Style.Font.Name = "Arial"; - ws.Columns[1].AutoFit(); - } - [TestMethod] - public void AutoFitColumnTest() - { - var p = OpenTemplatePackage("AutoFitWorkbook.xlsx"); - var ws = p.Workbook.Worksheets[0]; - var start = DateTime.Now; - ws.Columns[1].AutoFit(); - var end = DateTime.Now; - TimeSpan span = end - start; - Assert.AreEqual(125d, ws.Columns[1].Width, 5d); - SaveAndCleanup(p); - } - - [TestMethod] - public void AutofitAutofilterTest() - { - using var package = OpenTemplatePackage("AutoFitAutofilter.xlsx"); - var ws = package.Workbook.Worksheets.Add("Sheet1"); - - // Headers are the widest text in each column - the data below is deliberately - // shorter so the column width is driven by the header + the autofilter dropdown arrow. - ws.Cells["A1"].Value = "Department"; - ws.Cells["B1"].Value = "Annual Budget"; - ws.Cells["C1"].Value = "Region Name"; - - // Data rows - all shorter than the headers above them. - ws.Cells["A2"].Value = "Sales"; - ws.Cells["B2"].Value = 1200; - ws.Cells["C2"].Value = "North"; - - ws.Cells["A3"].Value = "IT"; - ws.Cells["B3"].Value = 980; - ws.Cells["C3"].Value = "West"; - - ws.Cells["A4"].Value = "HR"; - ws.Cells["B4"].Value = 540; - ws.Cells["C4"].Value = "East"; - - // Apply autofilter across the header row + data. - ws.Cells["A1:C4"].AutoFilter = true; - - // Autofit the columns. - ws.Cells["A1:C4"].AutoFitColumns(); - - // Inspect what EPPlus actually produced for each column. - System.Diagnostics.Debug.WriteLine($"Column A (Department): {ws.Column(1).Width}"); - System.Diagnostics.Debug.WriteLine($"Column B (Annual Budget): {ws.Column(2).Width}"); - System.Diagnostics.Debug.WriteLine($"Column C (Region Name): {ws.Column(3).Width}"); - - // Save the workbook - SaveAndCleanup(package); - } - - [TestMethod] - public void AutoFitColumnsWithAutoFilter() - { - var ws = _pck.Workbook.Worksheets.Add("AutofitAutoFilter"); - ws.Cells["A1"].Value = "hour"; - ws.Cells["B1"].Value = "minute"; - ws.Cells["A2"].Value = 12; - ws.Cells["B2"].Value = 30; - - ws.Cells["A1:B2"].AutoFilter = true; - - ws.Cells["A1:B2"].AutoFitColumns(); - - // Without the fix, the AutoFilter header row range (A1:B1) is measured as a whole. - // Under the hood, worksheet.Cells["A1:B1"].TextForWidth evaluated to "System.Object[,]" (16 chars), - // which forced a minimum width of ~16.07 points. - // With the fix, the specific cell for each column in the AutoFilter is measured, - // resulting in a narrow width matching "hour" / "minute". - Assert.IsTrue(ws.Column(1).Width < 12d, $"Column 1 width should be small but was {ws.Column(1).Width}"); - Assert.IsTrue(ws.Column(2).Width < 12d, $"Column 2 width should be small but was {ws.Column(2).Width}"); - } + [TestMethod] public void CopyOverwrite() {