From 0c4a5b088d14ce9be55d97a2304bfd379ae1a969 Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 07:16:55 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(IO):=20=E6=96=B0=E5=A2=9E=E5=B9=B6?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Magicodes.IE.IO=20=E5=8C=85(=E9=9B=B6=20EP?= =?UTF-8?q?Plus=20=E8=87=AA=E5=86=99=E6=B5=81=E5=BC=8F=20xlsx=20writer/rea?= =?UTF-8?q?der=E3=80=81=E6=A8=A1=E6=9D=BF=E5=AF=BC=E5=87=BA=E3=80=81?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E3=80=81=E4=BD=8E=E5=88=86=E9=85=8D=E5=BC=82?= =?UTF-8?q?=E6=AD=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnetcore.yml | 91 +- .gitignore | 4 + Directory.Build.props | 2 +- Directory.Packages.props | 13 +- RELEASE.md | 133 ++ src/Magicodes.IE.Benchmarks/LabBenchmarks.cs | 5 +- .../Magicodes.Benchmarks.csproj | 14 +- .../XlsxIO_Benchmarks.cs | 355 +++ .../XlsxIO_Profile_Benchmarks.cs | 296 +++ .../XlsxIO_ReadBenchmarks.cs | 180 ++ .../XlsxIO_ReadCompare_Benchmarks.cs | 94 + .../XlsxIO_ZeroAlloc_Benchmarks.cs | 388 ++++ .../Magicodes.IE.IO.SourceGenerator.csproj | 22 + .../XlsxRowGettersGenerator.cs | 499 +++++ src/Magicodes.IE.IO/AssemblyInfo.cs | 5 + .../Attributes/ExporterHeaderAttribute.cs | 37 + .../Attributes/ImporterHeaderAttribute.cs | 17 + .../Attributes/XlsxExportableAttribute.cs | 13 + src/Magicodes.IE.IO/CellConverter.cs | 42 + src/Magicodes.IE.IO/ColumnConfig.cs | 179 ++ src/Magicodes.IE.IO/ColumnMeta.cs | 134 ++ src/Magicodes.IE.IO/Engine/SheetState.cs | 23 + .../Engine/XlsxReadPipeline.cs | 132 ++ src/Magicodes.IE.IO/Engine/XlsxReader.cs | 656 ++++++ .../Engine/XlsxTemplateExporter.cs | 276 +++ .../Engine/XlsxWritePipeline.cs | 422 ++++ .../Engine/XlsxWriter.Helpers.cs | 865 ++++++++ src/Magicodes.IE.IO/Engine/XlsxWriter.cs | 1962 +++++++++++++++++ src/Magicodes.IE.IO/ExportProfile.cs | 1035 +++++++++ src/Magicodes.IE.IO/Features/Comment.cs | 46 + .../Features/ConditionalFormatting.cs | 75 + .../Features/DataValidation.cs | 128 ++ src/Magicodes.IE.IO/Features/ImageAnchor.cs | 47 + src/Magicodes.IE.IO/Features/NamedRange.cs | 44 + .../Features/OutlineSettings.cs | 22 + src/Magicodes.IE.IO/Features/PageSetup.cs | 80 + .../Features/SheetProtection.cs | 73 + .../Features/TableDefinition.cs | 122 + src/Magicodes.IE.IO/Features/TableStyle.cs | 70 + .../Internal/BufferWriterStream.cs | 95 + .../Internal/ByteBufferWriter.cs | 742 +++++++ src/Magicodes.IE.IO/Internal/CellRefHelper.cs | 63 + .../Internal/CellValueParser.cs | 130 ++ .../Internal/ForwardOnlyZipWriter.cs | 1137 ++++++++++ .../Internal/ModuleInitializerAttribute.cs | 11 + .../Internal/NumberFormatHelper.cs | 90 + src/Magicodes.IE.IO/Internal/XmlHelper.cs | 118 + .../Internal/ZipArchiveExtensions.cs | 15 + src/Magicodes.IE.IO/MIGRATION.md | 198 ++ src/Magicodes.IE.IO/Magicodes.IE.IO.csproj | 39 + src/Magicodes.IE.IO/Model/CellValue.cs | 87 + src/Magicodes.IE.IO/Model/StylesEnums.cs | 71 + src/Magicodes.IE.IO/MultiSheetExports.cs | 90 + src/Magicodes.IE.IO/README.md | 320 +++ .../SourceGen/XlsxGeneratedGettersRegistry.cs | 37 + .../XlsxGeneratedRowWritersRegistry.cs | 39 + .../SourceGen/XlsxGeneratedTypeMetadata.cs | 167 ++ .../XlsxGeneratedTypedGettersRegistry.cs | 39 + src/Magicodes.IE.IO/Xlsx.cs | 432 ++++ src/Magicodes.IE.IO/XlsxException.cs | 88 + src/Magicodes.IE.IO/XlsxWriteOptions.cs | 32 + src/MagicodesWebSite/Pages/Index.cshtml | 80 +- src/MagicodesWebSite/Program.cs | 17 +- .../Properties/launchSettings.json | 6 +- .../Magicodes.IE.IO.ReadmeSamples/Program.cs | 59 + .../ReadmeSamples.csproj | 11 + .../Magicodes.IE.IO.Tests.csproj | 39 + .../NuGetConsumerIntegrationTests.cs | 96 + .../XlsxIO_Core_Tests.cs | 712 ++++++ .../XlsxIO_EdgeCases_Tests.cs | 237 ++ .../XlsxIO_Features_Tests.cs | 800 +++++++ .../XlsxIO_Reader_Tests.cs | 718 ++++++ .../XlsxIO_ReleaseContract_Tests.cs | 42 + .../XlsxIO_S0_Guardrails_Tests.cs | 271 +++ .../XlsxIO_Styles_Tests.cs | 254 +++ .../XlsxIO_Template_Tests.cs | 161 ++ .../XlsxIO_TestSupport.cs | 483 ++++ tests/Magicodes.IE.IO.Tests/XlsxIO_Tests.cs | 5 + .../XlsxIoPerfRegressionTests.cs | 91 + 79 files changed, 16645 insertions(+), 78 deletions(-) create mode 100644 src/Magicodes.IE.Benchmarks/XlsxIO_Benchmarks.cs create mode 100644 src/Magicodes.IE.Benchmarks/XlsxIO_Profile_Benchmarks.cs create mode 100644 src/Magicodes.IE.Benchmarks/XlsxIO_ReadBenchmarks.cs create mode 100644 src/Magicodes.IE.Benchmarks/XlsxIO_ReadCompare_Benchmarks.cs create mode 100644 src/Magicodes.IE.Benchmarks/XlsxIO_ZeroAlloc_Benchmarks.cs create mode 100644 src/Magicodes.IE.IO.SourceGenerator/Magicodes.IE.IO.SourceGenerator.csproj create mode 100644 src/Magicodes.IE.IO.SourceGenerator/XlsxRowGettersGenerator.cs create mode 100644 src/Magicodes.IE.IO/AssemblyInfo.cs create mode 100644 src/Magicodes.IE.IO/Attributes/ExporterHeaderAttribute.cs create mode 100644 src/Magicodes.IE.IO/Attributes/ImporterHeaderAttribute.cs create mode 100644 src/Magicodes.IE.IO/Attributes/XlsxExportableAttribute.cs create mode 100644 src/Magicodes.IE.IO/CellConverter.cs create mode 100644 src/Magicodes.IE.IO/ColumnConfig.cs create mode 100644 src/Magicodes.IE.IO/ColumnMeta.cs create mode 100644 src/Magicodes.IE.IO/Engine/SheetState.cs create mode 100644 src/Magicodes.IE.IO/Engine/XlsxReadPipeline.cs create mode 100644 src/Magicodes.IE.IO/Engine/XlsxReader.cs create mode 100644 src/Magicodes.IE.IO/Engine/XlsxTemplateExporter.cs create mode 100644 src/Magicodes.IE.IO/Engine/XlsxWritePipeline.cs create mode 100644 src/Magicodes.IE.IO/Engine/XlsxWriter.Helpers.cs create mode 100644 src/Magicodes.IE.IO/Engine/XlsxWriter.cs create mode 100644 src/Magicodes.IE.IO/ExportProfile.cs create mode 100644 src/Magicodes.IE.IO/Features/Comment.cs create mode 100644 src/Magicodes.IE.IO/Features/ConditionalFormatting.cs create mode 100644 src/Magicodes.IE.IO/Features/DataValidation.cs create mode 100644 src/Magicodes.IE.IO/Features/ImageAnchor.cs create mode 100644 src/Magicodes.IE.IO/Features/NamedRange.cs create mode 100644 src/Magicodes.IE.IO/Features/OutlineSettings.cs create mode 100644 src/Magicodes.IE.IO/Features/PageSetup.cs create mode 100644 src/Magicodes.IE.IO/Features/SheetProtection.cs create mode 100644 src/Magicodes.IE.IO/Features/TableDefinition.cs create mode 100644 src/Magicodes.IE.IO/Features/TableStyle.cs create mode 100644 src/Magicodes.IE.IO/Internal/BufferWriterStream.cs create mode 100644 src/Magicodes.IE.IO/Internal/ByteBufferWriter.cs create mode 100644 src/Magicodes.IE.IO/Internal/CellRefHelper.cs create mode 100644 src/Magicodes.IE.IO/Internal/CellValueParser.cs create mode 100644 src/Magicodes.IE.IO/Internal/ForwardOnlyZipWriter.cs create mode 100644 src/Magicodes.IE.IO/Internal/ModuleInitializerAttribute.cs create mode 100644 src/Magicodes.IE.IO/Internal/NumberFormatHelper.cs create mode 100644 src/Magicodes.IE.IO/Internal/XmlHelper.cs create mode 100644 src/Magicodes.IE.IO/Internal/ZipArchiveExtensions.cs create mode 100644 src/Magicodes.IE.IO/MIGRATION.md create mode 100644 src/Magicodes.IE.IO/Magicodes.IE.IO.csproj create mode 100644 src/Magicodes.IE.IO/Model/CellValue.cs create mode 100644 src/Magicodes.IE.IO/Model/StylesEnums.cs create mode 100644 src/Magicodes.IE.IO/MultiSheetExports.cs create mode 100644 src/Magicodes.IE.IO/README.md create mode 100644 src/Magicodes.IE.IO/SourceGen/XlsxGeneratedGettersRegistry.cs create mode 100644 src/Magicodes.IE.IO/SourceGen/XlsxGeneratedRowWritersRegistry.cs create mode 100644 src/Magicodes.IE.IO/SourceGen/XlsxGeneratedTypeMetadata.cs create mode 100644 src/Magicodes.IE.IO/SourceGen/XlsxGeneratedTypedGettersRegistry.cs create mode 100644 src/Magicodes.IE.IO/Xlsx.cs create mode 100644 src/Magicodes.IE.IO/XlsxException.cs create mode 100644 src/Magicodes.IE.IO/XlsxWriteOptions.cs create mode 100644 tests/Magicodes.IE.IO.ReadmeSamples/Program.cs create mode 100644 tests/Magicodes.IE.IO.ReadmeSamples/ReadmeSamples.csproj create mode 100644 tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj create mode 100644 tests/Magicodes.IE.IO.Tests/NuGetConsumerIntegrationTests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_EdgeCases_Tests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_Features_Tests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_ReleaseContract_Tests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_S0_Guardrails_Tests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_Styles_Tests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_Template_Tests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_TestSupport.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIO_Tests.cs create mode 100644 tests/Magicodes.IE.IO.Tests/XlsxIoPerfRegressionTests.cs diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 437417d6..988458d0 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -8,7 +8,10 @@ # # Architecture: # changed -> skip detection (docs-only PRs skip tests) -# test -> build & test matrix +# test -> build & test matrix (legacy core test project) +# io-test-macos -> Magicodes.IE.IO dedicated regression suite on Apple-Silicon +# (-c Debug activates the CRC32 self-check; guards #1-class +# arm64 platform traps that x86-only CI would miss) # check -> alls-green gate (single required status check) # # Test runner: @@ -271,6 +274,90 @@ jobs: if-no-files-found: ignore retention-days: 7 + ################################################### + # IO PERF-REGRESSION GUARD (Apple Silicon / arm64) + # + # Runs the *dedicated* Magicodes.IE.IO test project + # (tests/Magicodes.IE.IO.Tests) on Apple-Silicon. This is the project that + # holds XlsxIoPerfRegressionTests — the round-trip + ZipArchive-reopen guard + # for the ForwardOnlyZipWriter CRC32 path. The main matrix above targets the + # legacy core test project only, so without this job the IO regression suite + # never ran in CI and an arm64 CRC bug (#1 class) could slip through on + # x86-only runners. + # + # Built/tested in -c Debug on purpose: ForwardOnlyZipWriter contains a + # #if DEBUG CRC32 self-check ("123456789" => 0xCBF43926). A wrong arm64 CRC + # throws at assembly load, failing the whole run immediately — the cheapest, + # strongest platform trap. (Release runtime CRC is still exercised by the + # ZipArchive reopen assertions inside the tests.) + ################################################### + + io-test-macos: + name: IO Tests (macOS arm64 / Debug) + needs: changed + if: needs.changed.outputs.should_skip != 'true' + runs-on: macos-15 + timeout-minutes: 20 + env: + IO_TEST_PROJECT: tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + cache: true + cache-dependency-path: | + Magicodes.IE.sln + src/**/*.csproj + + - name: Restore + run: dotnet restore ${{ env.IO_TEST_PROJECT }} + + - name: Build + run: dotnet build ${{ env.IO_TEST_PROJECT }} -c Debug --no-restore -bl + + - name: Test + shell: bash + run: | + set +e + dotnet test ${{ env.IO_TEST_PROJECT }} \ + -c Debug --no-build \ + -f net8.0 \ + --blame-hang-timeout 10m \ + --blame-crash-dump-type full \ + --blame-hang-dump-type full \ + --logger "trx;LogFileName=results-io-macos-debug.trx" \ + --logger GitHubActions \ + --results-directory TestResults + TEST_EXIT=$? + set -e + if [ $TEST_EXIT -ne 0 ]; then + echo "::error::IO tests failed on macOS arm64 (Debug CRC self-check active)" + exit 1 + fi + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: io-test-results-macos-debug + path: TestResults + if-no-files-found: ignore + retention-days: 7 + + - name: Upload build log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: io-build-log-macos-debug + path: msbuild.binlog + if-no-files-found: ignore + retention-days: 7 + ################################################### # ALLS-GREEN GATE # Single required status check for branch protection. @@ -280,7 +367,7 @@ jobs: check: name: CI Passed if: always() - needs: [changed, test] + needs: [changed, test, io-test-macos] runs-on: ubuntu-latest steps: - name: Evaluate results diff --git a/.gitignore b/.gitignore index 7419a510..ddb7b2e1 100644 --- a/.gitignore +++ b/.gitignore @@ -265,3 +265,7 @@ __pycache__/ /global.json !/global.json /.omo/ + +# 前端构建产物 (typescript/ 演示的 Vite 打包输出, 勿入库) +index-*.js +index-*.js.map diff --git a/Directory.Build.props b/Directory.Build.props index ba894444..d10a6177 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -30,7 +30,7 @@ - 2.8.5 + 2.9.0 true snupkg 1701;1702;CS1591;CS1573;1591;NU1507 diff --git a/Directory.Packages.props b/Directory.Packages.props index 41ffb1df..c3403a3f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,9 +2,10 @@ true - + + @@ -21,10 +22,15 @@ + + + + + @@ -32,8 +38,10 @@ - + + + @@ -41,7 +49,6 @@ - diff --git a/RELEASE.md b/RELEASE.md index a3d87d42..825176c0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,138 @@ # Release Log +## 2.9.0 +**Magicodes.IE.IO 首发** — 零 EPPlus 依赖、自写流式 xlsx writer/reader、模板导出、表格/样式、低分配异步性能工作。详见下方「新增」段。 + +### 新增 — Magicodes.IE.IO (独立新包,零 EPPlus) + +阶段二全套交付:自写 xlsx writer,零外部依赖。 + +#### Stage 2.5 — Span / 向量化 XML 转义 / Utf8Formatter +- 弃 char[] + UTF8Encoding 二次编码 → 直接 pooled byte[] + UTF-8 直写 +- XML 转义手写 ASCII escape byte 表(`&` / `<` / `>` / `"` / `'` 5 字符) +- 数字直写 `Utf8Formatter.TryFormat(double/long)`,culture-invariant +- 性能(100k × 4 string):79ms → 32ms(60% ↑) + +#### Stage 2.1 — 易用性(属性驱动 + 值类型 + 多 sheet + 列宽 + 冻结) +- 自动识别 `[Display(Name)]` / `[Description]` / `[DisplayFormat]` 做表头与格式 +- fluent 覆盖 attribute,3 层披露:fluent > attribute > 默认 +- 值类型(struct / record struct)支持,取消 `where T : class` 约束 +- 多 sheet:`Xlsx.SaveMultiSheetAsBytes(sheet1, sheet2, ...)` +- 列宽:`WithWidth(30.5)`,支持连续相同宽度合并 `` +- 冻结首行:`WithFreezeHeader(true)`,注入 `` + +#### Stage 2.2 — TypedRowPlan(class 路径零 object 装箱) +- 派生 `TypedRowPlan : RowPlan` 暴露 `Func[]` typed getters +- `XlsxWriter.WriteRows(data, TypedRowPlan)` 走 typed path +- 属性类型分发(string/int/long/double/decimal/datetime/bool/enum 强类型编译 lambda) + +#### Stage 2.3 — 压缩档位可选 +- `XlsxWriter(..., CompressionLevel compression)` 参数 +- `Xlsx.SaveAsBytes(data, compression: CompressionLevel.NoCompression)` facade +- NoCompression 路径 100k string 38ms → 18ms(2x 加速,产物从 1MB → 14MB) + +#### Stage 2.4 — IAsyncEnumerable + 真异步流 +- `Xlsx.SaveAsAsync(path, IAsyncEnumerable data, CancellationToken)` +- `Xlsx.SaveAsBytesAsync(IAsyncEnumerable data, CancellationToken)` +- `XlsxWriter.WriteRowsAsync` 内部 `await foreach + ThrowIfCancellationRequested` +- 大表 + 数据库查询场景:不一次性装内存,边查边写 + +#### Stage 2.6 — CSV / JSON 共用 profile +> 注:当前 2.9.0 构建已移除 CSV/JSON 实现(`Csv.cs`/`Json.cs` 不在产物中),集中做 Excel。此 stage 保留为历史记录。 +- `Csv` / `Json` 静态门面,同一份 `ExportProfile` 可输出 xlsx/csv/json +- CSV RFC 4180:含 `,` / `"` / `\r` / `\n` 自动双引号包裹 + 内部 `"` 转 `""` +- JSON 用 STJ(`System.Text.Json`,BCL 内置) + +#### Stage 2.7 — BenchmarkDotNet 横向对比(正式报告) +- BenchmarkDotNet v0.14.0,macOS Sequoia 15.6.1,Apple M4,ShortRun,Release/net8.0 +- 数据来源:`src/Magicodes.IE.Benchmarks/XlsxIO_Benchmarks.cs` (`--filter "*XlsxIO*"`) +- 结果(Mean 毫秒): + +| 库 | 1k string | 10k string | 10k number | 100k string | +|---|---|---|---|---| +| **Magicodes.IE.IO(自写)** | 0.70ms | 3.95ms | 5.79ms | 58.70ms | +| MiniExcel | 3.84ms | 18.29ms | 13.65ms | 163.34ms | +| ClosedXML | 11.93ms | 71.13ms | — | — | + +**结论**:Magicodes.IE.IO 在该组数据中明显快于 MiniExcel 和 ClosedXML。 + +#### Stage 2.8 — IExporter 抽象 +> 注:`IExporter` / `XlsxExporter` 已在当前重构中移除(`IExporter.cs`、`Engine/Exporters.cs` 删除),API 统一到 `Xlsx` 静态门面(见 P2-1)。 +- `IExporter` 接口 + `XlsxExporter`(已实现) + `CsvExporter` / `JsonExporter`(stage 三 暂未实现,集中做 Excel) + +#### Stage 3.3 — `[ExporterHeader]` attribute +- IE 老用户熟悉的 `[ExporterHeader(Name = ..., IsIgnore = ..., Format = ..., Width = ..., Index = ...)]` +- 优先级:fluent cfg > `[ExporterHeader]` > `[Display]` > `[Description]` > 属性名 +- 不再需要切到 BCL `[Display]` + +#### Stage 3.1 — AOT 警告抑制 +- `[UnconditionalSuppressMessage("Trimming", "IL2026")]` + `("AOT", "IL3050")` +- `Expression.Compile` 在 Native AOT 下回退到 `PropertyInfo.GetValue` 路径 +- 跨 TFM(net6/8/10)统一,不引入 `RequiresDynamicCode` (net7+) 以保持兼容性 + +#### P2-1 — API 统一到 `Xlsx` 静态门面(对齐 MiniExcel 风格) +- `XlsxQuery` / `TemplateExporter` 静态类合并到 `Xlsx` 静态类下 +- `Xlsx.SheetExport` 嵌套类提升为顶层 `SheetExport` +- 用户 API 现在统一是 `Xlsx.{SaveAs, SaveAsBytes, SaveAsAsync, SaveMultiSheetAsBytes, Query, QueryAsync, ExportByTemplateAsync}` 一行 IDE 提示 +- 删除 `TemplateExporter.cs` 文件,功能内置到 `Xlsx.cs` +- `XlsxReader.cs` 只剩 `XlsxReader` 和 `XlsxImporterProfile`(底层 API) +- 测试 46/46 全过(三档 TFM) + +### 阶段 3 P3 — 性能 / 内存 / GC 完整对比 +BenchmarkDotNet v0.14.0,Apple M4,.NET 8.0.19,MediumJob(15 迭代 × 2 启动)。 + +| 场景 | 库 | Mean | Allocated | Gen2 | ThreadPool Items | Lock | +|---|---|---|---|---|---|---| +| | **Magicodes.IE.IO** | 4.73 ms | 2.80 MB | **8** | 0 | 0 | +| | MiniExcel | 18.65 ms | 38.76 MB | 3312 | 0 | 0 | +| | ClosedXML | 66.13 ms | 86.71 MB | 1666 | 0 | 0 | +| 100k × 4 string | **Magicodes.IE.IO** | **54.01 ms** | 30.5 MB | **1222** | 0 | 0 | +| | MiniExcel | 161.00 ms | 245.01 MB | 2666 | 0 | 0 | +| 10k × 4 number | **Magicodes.IE.IO** | **5.90 ms** | **1.43 MB** | 109 | 0 | 0 | +| | MiniExcel | 14.29 ms | 34.18 MB | 3437 | 0 | 0 | + +**亮点**: + +- 完全无锁,纯同步写 + +#### P3-1 RowPlan 缓存(同 T + profile 复用) +- `ConcurrentDictionary<(Type, profile hash), TypedRowPlan>` +- 同一 profile 多次 `SaveAsBytes` 反射只走一次 +- 测试:`RowPlanCache_Same/DifferentProfile_ReusedAcrossCalls` + +#### P3-2 样式系统真生效(ResolveColumnStyles) +- 拆 `WriteStyles` 为 `BuildStylePool`(算 xfId 回写 ColumnMeta.StyleId)+ `EmitStylePoolXml`(Dispose 时写) +- facade 在 `WriteHeader` 之前调 `ResolveColumnStyles`,让表头 cell 真带 `s="N"` +- 测试 verify:`Style_BoldHeader_RealXfIdOnCell` / `Style_FontColor_WritesColor` / `Style_Wrap_WritesWrapText` / `Style_FontName_WritesCustomName` + +#### P3-3 sharedStrings reader 兼容 +- `XlsxReader` 构造时加载 `xl/sharedStrings.xml` +- `t="s"` cell 解析时按 `` 下标查 sharedStrings 数组 +- 真 Office 写的 xlsx 现在可读 +- 测试:`Read_SharedStrings_RealXlsxFormat`(手工拼 t="s" 风格 xlsx) + +#### P3-4 字体名 + 字体颜色真生效 +- `ColumnConfig.WithFontName("微软雅黑")` 写进 styles.xml `` +- `fontSet` 元组增 `Name` 字段,去重时按 Bold/Size/Color/Name +- 测试:`Style_FontName_WritesCustomName` + +#### ThreadPool 调度优化(性能) +- `Xlsx.WriteAsync` 去掉 `await Task.Yield()` +- 实测 `Completed Work Items` 从 1 → 0,Gen2 31 → 7.8(4 倍下) +- 同步路径不再让出 ThreadPool + +### 新增文件 +- `src/Magicodes.IE.IO/Magicodes.IE.IO.csproj` +- `src/Magicodes.IE.IO/XlsxWriter.cs`(自写 writer 核心) +- `src/Magicodes.IE.IO/Xlsx.cs`(静态门面) +- `src/Magicodes.IE.IO/ExportProfile.cs`(frozen fluent profile) +- `src/Magicodes.IE.IO/ExporterHeaderAttribute.cs` +- `tests/Magicodes.IE.IO.Tests/` 34 个测试(三档 TFM 全过) +- `src/Magicodes.IE.Benchmarks/XlsxIO_Benchmarks.cs` + +### 依赖 +- 新增:`ClosedXML` 0.102.3(仅 Benchmarks 用,生产包零依赖) + ## 2.8.5 **2026.06.21** diff --git a/src/Magicodes.IE.Benchmarks/LabBenchmarks.cs b/src/Magicodes.IE.Benchmarks/LabBenchmarks.cs index c7b3e778..b001cbe3 100644 --- a/src/Magicodes.IE.Benchmarks/LabBenchmarks.cs +++ b/src/Magicodes.IE.Benchmarks/LabBenchmarks.cs @@ -4,6 +4,7 @@ using Magicodes.Benchmarks.Models; using Magicodes.ExporterAndImporter.Excel; using Magicodes.ExporterAndImporter.Excel.Utility; +using Magicodes.IE.IO; using MiniExcelLibs; using System; using System.Collections.Generic; @@ -63,10 +64,10 @@ public async Task ExportExcelAsByteArrayAsyncTest() } [Benchmark] - public async Task ExportMiniExcelAsyncTest() + public async Task ExportMioAsyncTest() { var memoryStream = new MemoryStream(); - await memoryStream.SaveAsAsync(_exportTestData); + Xlsx.Write(memoryStream, _exportTestData); memoryStream.Seek(0, SeekOrigin.Begin); } } diff --git a/src/Magicodes.IE.Benchmarks/Magicodes.Benchmarks.csproj b/src/Magicodes.IE.Benchmarks/Magicodes.Benchmarks.csproj index 7d127aaa..1d4122af 100644 --- a/src/Magicodes.IE.Benchmarks/Magicodes.Benchmarks.csproj +++ b/src/Magicodes.IE.Benchmarks/Magicodes.Benchmarks.csproj @@ -1,14 +1,17 @@ - + Exe - net8.0 + net8.0;net10.0 false + + + @@ -17,6 +20,13 @@ + + + + diff --git a/src/Magicodes.IE.Benchmarks/XlsxIO_Benchmarks.cs b/src/Magicodes.IE.Benchmarks/XlsxIO_Benchmarks.cs new file mode 100644 index 00000000..eef54ac0 --- /dev/null +++ b/src/Magicodes.IE.Benchmarks/XlsxIO_Benchmarks.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Order; +using Magicodes.IE.IO; +using MiniExcelLibs; +using ClosedXML.Excel; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; + + +namespace Magicodes.IE.Benchmarks +{ + [MemoryDiagnoser, ThreadingDiagnoser] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + [CategoriesColumn] + public class XlsxIO_Benchmarks + { + // ===== 1. 隔离每个 phase 测 setup vs write vs zip ===== + // 这些 micro-bench 用来定位时间损耗(setup / write rows / zip / dispose) + // 详见 XlsxIO_Profile_Benchmarks.cs + // ---- DTOs ---- + // [XlsxExportable] 触发源生成 fast path。Magicodes.IE.IO 用 InternalsVisibleTo + // 授权 Magicodes.Benchmarks 访问 XlsxGeneratedGettersRegistry,benchmark csproj + // 引用 SourceGenerator 项目作 Analyzer。生成的 *._XlsxGetters 内部类在 + // [ModuleInitializer] 注册 typed Func getter,跳过 + // ExportProfile 反射 + BuildGettersPair 的 Expression.Compile。 + [XlsxExportable] + public record S4(string C1, string C2, string C3, string C4); + [XlsxExportable] + public record N4(int Id, double Amt, long Cnt, decimal Prc); + [XlsxExportable] + public record D3(int Id, DateTime C1, DateTime C2, DateTime C3); + [XlsxExportable] + public record B3(int Id, bool B1, bool B2); + [XlsxExportable] + public record M5(int Id, string N, double A, DateTime D, bool B); + [XlsxExportable] + public record S2(string L, double V); + // 高重复 string(SST dedupe 关键场景):4 列,总 unique 仅 16 + [XlsxExportable] + public record R4(string A, string B, string C, string D); + // 5 列 styled + string + [XlsxExportable] + public record SX5(string K, string V, string W, double P, int N); + // Wide: 1 string key + 10 decimal value 列 + [XlsxExportable] + public record W1K11(string K, decimal V1, decimal V2, decimal V3, decimal V4, decimal V5, decimal V6, decimal V7, decimal V8, decimal V9, decimal V10); + + // ---- data builders ---- + static S4[] S(int n) => Enumerable.Range(0, n).Select(i => new S4("s" + i, "c" + (i % 100), "p" + (i % 50), "t" + (i % 5))).ToArray(); + static N4[] Num(int n) => Enumerable.Range(0, n).Select(i => new N4(i, i * 1.5, i * 1000L, (decimal)(i * 0.99))).ToArray(); + static D3[] Dt(int n) { var b = new DateTime(2020, 1, 1); return Enumerable.Range(0, n).Select(i => new D3(i, b.AddDays(i), b.AddHours(i), b.AddMinutes(i))).ToArray(); } + static B3[] Bo(int n) => Enumerable.Range(0, n).Select(i => new B3(i, i % 2 == 0, i % 3 == 0)).ToArray(); + static M5[] Mx(int n) { var b = new DateTime(2020, 1, 1); return Enumerable.Range(0, n).Select(i => new M5(i, "x" + i, i * 1.5, b.AddDays(i), i % 2 == 0)).ToArray(); } + static S2[] St(int n) => Enumerable.Range(0, n).Select(i => new S2("S" + i, i * 0.1)).ToArray(); + // R4: 4 列 × 2 unique value = 16 总 unique(高 dedupe 率) + static string[] _R4pool = { "alpha", "beta", "gamma", "delta" }; + static R4[] Rep(int n) => Enumerable.Range(0, n).Select(i => new R4(_R4pool[i % 2], _R4pool[(i / 2) % 2], _R4pool[(i / 4) % 2], _R4pool[(i / 8) % 2])).ToArray(); + static SX5[] SX(int n) => Enumerable.Range(0, n).Select(i => new SX5($"k{i % 50}", $"v{i % 100}", $"w{i % 200}", i * 0.5, i)).ToArray(); + static W1K11[] Wd(int n) => Enumerable.Range(0, n).Select(i => new W1K11($"k{i % 100}", + (decimal)(i * 0.01), (decimal)(i * 0.02), (decimal)(i * 0.03), (decimal)(i * 0.04), (decimal)(i * 0.05), + (decimal)(i * 0.06), (decimal)(i * 0.07), (decimal)(i * 0.08), (decimal)(i * 0.09), (decimal)(i * 0.10))).ToArray(); + + // ---- fields ---- + private S4[] _1k, _10k, _50k, _100k; + private N4[] _n, _n100k; private D3[] _d, _d100k; private B3[] _b, _b100k; private M5[] _m; private S2[] _st; + private R4[] _r10k, _r100k; private SX5[] _sx10k; private W1K11[] _w10k; + private S4[] _ms2A = null!; + private S4[] _ms2B = null!; + private Magicodes.IE.IO.Sheet[] _ms2 = null!; + private Magicodes.IE.IO.Sheet[] _ms5 = null!; + private Magicodes.IE.IO.Sheet[] _ms10 = null!; + // styled profile 配置以 Action 形式复用(新 API 接受 Action>) + private Action> _styledSxConfigure = null!; + private Action> _styledSConfigure = null!; + private XlsxWriteOptions _denseSOptions = null!; + private XlsxWriteOptions _denseSNoCompressOptions = null!; + private XlsxWriteOptions _denseRRelaxedOptions = null!; + private XlsxWriteOptions _denseRRelaxedNoCompressOptions = null!; + + [GlobalSetup] + public void Setup() + { + _1k = S(1_000); _10k = S(10_000); _50k = S(50_000); _100k = S(100_000); + _n = Num(10_000); _n100k = Num(100_000); + _d = Dt(10_000); _d100k = Dt(100_000); + _b = Bo(10_000); _b100k = Bo(100_000); + _m = Mx(10_000); _st = St(10_000); + _r10k = Rep(10_000); _r100k = Rep(100_000); + _sx10k = SX(10_000); _w10k = Wd(10_000); + _ms2A = _10k.Take(5000).ToArray(); + _ms2B = _10k.Skip(5000).ToArray(); + _ms2 = BuildMultiSheetArgs(2, 5000); + _ms5 = BuildMultiSheetArgs(5, 2000); + _ms10 = BuildMultiSheetArgs(10, 1000); + // 新 API 接受 Action>? 而非 ExportProfile + // _reuseProfile 原为 new ExportProfile()(无配置)→ 直接用 Xlsx.ToBytes(d) + // _sstProfile 原为 new ExportProfile().WithAutoSst(true) → 内联为 Action + _styledSxConfigure = p => + { + p.Column(x => x.K, c => c.WithBold().WithBackgroundColor("FFFCE4D6").WithBorder()) + .Column(x => x.V, c => c.WithBackgroundColor("FFE2EFDA")) + .Column(x => x.W, c => c.WithItalic()) + .Column(x => x.P, c => c.WithFormat("0.00").WithFontColor("FF0000FF")); + }; + _styledSConfigure = p => + { + p.Column(x => x.L, c => c.WithBold().WithBackgroundColor("FFFCE4D6").WithBorder()) + .Column(x => x.V, c => c.WithFormat("0.00").WithFontColor("FF0000FF")); + }; + _denseSOptions = new XlsxWriteOptions { StrictCellReferences = false }; + _denseSNoCompressOptions = new XlsxWriteOptions { Compression = System.IO.Compression.CompressionLevel.NoCompression, StrictCellReferences = false }; + _denseRRelaxedOptions = new XlsxWriteOptions { StrictCellReferences = false }; + _denseRRelaxedNoCompressOptions = new XlsxWriteOptions { Compression = System.IO.Compression.CompressionLevel.NoCompression, StrictCellReferences = false }; + } + + // ===== 1k-string ===== + [BenchmarkCategory("1k-s"), Benchmark] public byte[] Mio_1k_S() => Mio(_1k); + [BenchmarkCategory("1k-s"), Benchmark] public byte[] Mio_1k_S_ProfileReuse() => Xlsx.ToBytes(_1k); + + [BenchmarkCategory("1k-s"), Benchmark] public byte[] Mini_1k_S() => Mini(_1k); + [BenchmarkCategory("1k-s"), Benchmark] public byte[] Closed_1k_S() => Closed(_1k); + [BenchmarkCategory("1k-s"), Benchmark] public byte[] OXml_1k_S() => OXml(_1k); + [BenchmarkCategory("1k-s"), Benchmark] public byte[] EP_1k_S() => EP(_1k); + + // ===== 10k-string ===== + [BenchmarkCategory("10k-s"), Benchmark] public byte[] Mio_10k_S() => Mio(_10k); + + [BenchmarkCategory("10k-s"), Benchmark] public byte[] Mini_10k_S() => Mini(_10k); + [BenchmarkCategory("10k-s"), Benchmark] public byte[] Closed_10k_S() => Closed(_10k); + [BenchmarkCategory("10k-s"), Benchmark] public byte[] OXml_10k_S() => OXml(_10k); + [BenchmarkCategory("10k-s"), Benchmark] public byte[] EP_10k_S() => EP(_10k); + + // ===== 100k-string ===== + [BenchmarkCategory("100k-s"), Benchmark(Baseline = true)] public byte[] Mio_100k_S() => Mio(_100k); + [BenchmarkCategory("100k-s"), Benchmark] public byte[] Mio_100k_S_Relaxed() => Xlsx.ToBytes(_100k, options: _denseSOptions); + [BenchmarkCategory("100k-s"), Benchmark] public byte[] Mio_100k_S_NoCompress() => Mio(_100k, System.IO.Compression.CompressionLevel.NoCompression); + [BenchmarkCategory("100k-s"), Benchmark] public byte[] Mio_100k_S_NoCompress_Relaxed() => Xlsx.ToBytes(_100k, options: _denseSNoCompressOptions); + + [BenchmarkCategory("100k-s"), Benchmark] public byte[] Mini_100k_S() => Mini(_100k); + [BenchmarkCategory("100k-s"), Benchmark] public byte[] Closed_100k_S() => Closed(_100k); + [BenchmarkCategory("100k-s"), Benchmark] public byte[] EP_100k_S() => EP(_100k); + + // ===== 50k-string (中量级) ===== + [BenchmarkCategory("50k-s"), Benchmark] public byte[] Mio_50k_S() => Mio(_50k); + [BenchmarkCategory("50k-s"), Benchmark] public byte[] Mio_50k_S_NoCompress() => Mio(_50k, System.IO.Compression.CompressionLevel.NoCompression); + + + // ===== 100k-number ===== + [BenchmarkCategory("100k-n"), Benchmark] public byte[] Mio_100k_N() => Mio(_n100k); + [BenchmarkCategory("100k-n"), Benchmark] public byte[] Mio_100k_N_Relaxed() => Xlsx.ToBytes(_n100k, options: _denseSOptions); + [BenchmarkCategory("100k-n"), Benchmark] public byte[] Mio_100k_N_NoCompress() => Mio(_n100k, System.IO.Compression.CompressionLevel.NoCompression); + [BenchmarkCategory("100k-n"), Benchmark] public byte[] Mio_100k_N_NoCompress_Relaxed() => Xlsx.ToBytes(_n100k, options: _denseSNoCompressOptions); + + [BenchmarkCategory("100k-n"), Benchmark] public byte[] Mini_100k_N() => Mini(_n100k); + + // ===== 100k-datetime ===== + [BenchmarkCategory("100k-d"), Benchmark] public byte[] Mio_100k_D() => Mio(_d100k); + [BenchmarkCategory("100k-d"), Benchmark] public byte[] Mio_100k_D_Relaxed() => Xlsx.ToBytes(_d100k, options: _denseSOptions); + [BenchmarkCategory("100k-d"), Benchmark] public byte[] Mio_100k_D_NoCompress() => Mio(_d100k, System.IO.Compression.CompressionLevel.NoCompression); + [BenchmarkCategory("100k-d"), Benchmark] public byte[] Mio_100k_D_NoCompress_Relaxed() => Xlsx.ToBytes(_d100k, options: _denseSNoCompressOptions); + + [BenchmarkCategory("100k-d"), Benchmark] public byte[] Mini_100k_D() => Mini(_d100k); + + // ===== 100k-boolean ===== + [BenchmarkCategory("100k-b"), Benchmark] public byte[] Mio_100k_B() => Mio(_b100k); + [BenchmarkCategory("100k-b"), Benchmark] public byte[] Mio_100k_B_Relaxed() => Xlsx.ToBytes(_b100k, options: _denseSOptions); + [BenchmarkCategory("100k-b"), Benchmark] public byte[] Mio_100k_B_NoCompress() => Mio(_b100k, System.IO.Compression.CompressionLevel.NoCompression); + [BenchmarkCategory("100k-b"), Benchmark] public byte[] Mio_100k_B_NoCompress_Relaxed() => Xlsx.ToBytes(_b100k, options: _denseSNoCompressOptions); + + [BenchmarkCategory("100k-b"), Benchmark] public byte[] Mini_100k_B() => Mini(_b100k); + + // ===== SST 场景:高重复 string ===== + [BenchmarkCategory("sst-10k"), Benchmark] public byte[] Mio_10k_Repeated() => Mio(_r10k); + [BenchmarkCategory("sst-10k"), Benchmark] public byte[] Mio_10k_Repeated_AutoSst() => Xlsx.ToBytes(_r10k, p => p.WithAutoSst(true)); + + [BenchmarkCategory("sst-10k"), Benchmark] public byte[] Mini_10k_Repeated() => Mini(_r10k); + [BenchmarkCategory("sst-100k"), Benchmark] public byte[] Mio_100k_Repeated() => Mio(_r100k); + [BenchmarkCategory("sst-100k"), Benchmark] public byte[] Mio_100k_Repeated_Relaxed() => Xlsx.ToBytes(_r100k, options: _denseRRelaxedOptions); + [BenchmarkCategory("sst-100k"), Benchmark] public byte[] Mio_100k_Repeated_NoCompress() => Mio(_r100k, System.IO.Compression.CompressionLevel.NoCompression); + [BenchmarkCategory("sst-100k"), Benchmark] public byte[] Mio_100k_Repeated_NoCompress_Relaxed() => Xlsx.ToBytes(_r100k, options: _denseRRelaxedNoCompressOptions); + [BenchmarkCategory("sst-100k"), Benchmark] public byte[] Mio_100k_Repeated_AutoSst() => Xlsx.ToBytes(_r100k, p => p.WithAutoSst(true)); + [BenchmarkCategory("sst-100k"), Benchmark] public byte[] Mio_100k_Repeated_AutoSst_Relaxed() => Xlsx.ToBytes(_r100k, p => p.WithAutoSst(true), _denseRRelaxedOptions); + [BenchmarkCategory("sst-100k"), Benchmark] public byte[] Mio_100k_Repeated_AutoSst_NoCompress() => Xlsx.ToBytes(_r100k, p => p.WithAutoSst(true), new XlsxWriteOptions { Compression = System.IO.Compression.CompressionLevel.NoCompression }); + [BenchmarkCategory("sst-100k"), Benchmark] public byte[] Mio_100k_Repeated_AutoSst_NoCompress_Relaxed() => Xlsx.ToBytes(_r100k, p => p.WithAutoSst(true), _denseRRelaxedNoCompressOptions); + + [BenchmarkCategory("sst-100k"), Benchmark] public byte[] Mini_100k_Repeated() => Mini(_r100k); + + // ===== Multi-sheet 多档 ===== + [BenchmarkCategory("ms2"), Benchmark] public byte[] Mio_MS2() => Xlsx.WriteWorkbookToBytes(_ms2); + [BenchmarkCategory("ms5"), Benchmark] public byte[] Mio_MS5() => Xlsx.WriteWorkbookToBytes(_ms5); + [BenchmarkCategory("ms10"), Benchmark] public byte[] Mio_MS10() => Xlsx.WriteWorkbookToBytes(_ms10); + [BenchmarkCategory("ms2"), Benchmark] public byte[] EP_MS2() { using var ms = new MemoryStream(); using var p = new OfficeOpenXml.ExcelPackage(ms); EPWrite(p, "A", _ms2A); EPWrite(p, "B", _ms2B); p.Save(); return ms.ToArray(); } + + // ===== Wide (1 key + 10 decimal) ===== + [BenchmarkCategory("wide-10k"), Benchmark] public byte[] Mio_10k_Wide() => Mio(_w10k); + + [BenchmarkCategory("wide-10k"), Benchmark] public byte[] Mini_10k_Wide() => Mini(_w10k); + + // ===== Styled + string(5 列) ===== + [BenchmarkCategory("10k-sx"), Benchmark] public byte[] Mio_10k_SX() => MioStyledSX(_sx10k); + [BenchmarkCategory("10k-sx"), Benchmark] public byte[] Mio_10k_SX_FastPath() => Mio(_sx10k); + + // ===== 10k-number ===== + [BenchmarkCategory("10k-n"), Benchmark] public byte[] Mio_10k_N() => Mio(_n); + + [BenchmarkCategory("10k-n"), Benchmark] public byte[] Mini_10k_N() => Mini(_n); + [BenchmarkCategory("10k-n"), Benchmark] public byte[] Closed_10k_N() => Closed(_n); + [BenchmarkCategory("10k-n"), Benchmark] public byte[] EP_10k_N() => EP(_n); + + // ===== 10k-datetime ===== + [BenchmarkCategory("10k-d"), Benchmark] public byte[] Mio_10k_D() => Mio(_d); + + [BenchmarkCategory("10k-d"), Benchmark] public byte[] Mini_10k_D() => Mini(_d); + [BenchmarkCategory("10k-d"), Benchmark] public byte[] Closed_10k_D() => Closed(_d); + [BenchmarkCategory("10k-d"), Benchmark] public byte[] EP_10k_D() => EP(_d); + + // ===== 10k-boolean ===== + [BenchmarkCategory("10k-b"), Benchmark] public byte[] Mio_10k_B() => Mio(_b); + + [BenchmarkCategory("10k-b"), Benchmark] public byte[] Mini_10k_B() => Mini(_b); + [BenchmarkCategory("10k-b"), Benchmark] public byte[] Closed_10k_B() => Closed(_b); + [BenchmarkCategory("10k-b"), Benchmark] public byte[] EP_10k_B() => EP(_b); + + // ===== 10k-mixed ===== + [BenchmarkCategory("10k-m"), Benchmark] public byte[] Mio_10k_M() => Mio(_m); + + [BenchmarkCategory("10k-m"), Benchmark] public byte[] Mini_10k_M() => Mini(_m); + [BenchmarkCategory("10k-m"), Benchmark] public byte[] Closed_10k_M() => Closed(_m); + [BenchmarkCategory("10k-m"), Benchmark] public byte[] EP_10k_M() => EP(_m); + + // ===== 10k-styled ===== + [BenchmarkCategory("10k-st"), Benchmark] public byte[] Mio_10k_St() => MioStyled(_st); + [BenchmarkCategory("10k-st"), Benchmark] public byte[] Closed_10k_St() => ClosedStyled(_st); + [BenchmarkCategory("10k-st"), Benchmark] public byte[] EP_10k_St() => EPStyled(_st); + + // ===== multi-sheet ===== + // ===== implementation ===== + + static byte[] Mio(T[] d, System.IO.Compression.CompressionLevel compression = System.IO.Compression.CompressionLevel.Fastest) where T : class => Xlsx.ToBytes(d, options: new XlsxWriteOptions { Compression = compression }); + + // 多 sheet 工厂 + static Magicodes.IE.IO.Sheet[] BuildMultiSheetArgs(int sheetCount, int rowsPerSheet) + { + var args = new Magicodes.IE.IO.Sheet[sheetCount]; + for (int s = 0; s < sheetCount; s++) + { + var arr = new S4[rowsPerSheet]; + for (int i = 0; i < rowsPerSheet; i++) + arr[i] = new S4($"s{s}r{i}", $"c{i % 100}", $"p{i % 50}", $"t{i % 5}"); + args[s] = new Magicodes.IE.IO.Sheet($"S{s}", arr); + } + return args; + } + + // 5 列 styled + string + byte[] MioStyledSX(SX5[] d) => Xlsx.ToBytes(d, _styledSxConfigure); + + byte[] MioStyled(S2[] d) => Xlsx.ToBytes(d, _styledSConfigure); + + + static byte[] Mini(T[] d) + { + using var ms = new MemoryStream(); + MiniExcelLibs.MiniExcel.SaveAs(ms, d, true, "S1", ExcelType.XLSX); + return ms.ToArray(); + } + + static byte[] Closed(T[] d) + { + using var wb = new XLWorkbook(); + wb.Worksheets.Add("S1").Cell(1, 1).InsertData(d); + using var ms = new MemoryStream(); wb.SaveAs(ms); return ms.ToArray(); + } + + static byte[] ClosedStyled(S2[] d) + { + using var wb = new XLWorkbook(); var ws = wb.Worksheets.Add("S1"); + ws.Cell(1, 1).Value = "L"; ws.Cell(1, 1).Style.Font.Bold = true; ws.Cell(1, 1).Style.Fill.BackgroundColor = XLColor.FromArgb(252, 228, 214); ws.Cell(1, 1).Style.Border.BottomBorder = XLBorderStyleValues.Thin; + ws.Cell(1, 2).Value = "V"; ws.Cell(1, 2).Style.Font.FontColor = XLColor.Blue; + for (int i = 0; i < d.Length; i++) { ws.Cell(i + 2, 1).Value = d[i].L; ws.Cell(i + 2, 2).Value = d[i].V; } + ws.Range(2, 2, d.Length + 1, 2).Style.NumberFormat.Format = "0.00"; + using var ms = new MemoryStream(); wb.SaveAs(ms); return ms.ToArray(); + } + + static byte[] OXml(T[] d) + { + using var ms = new MemoryStream(); + using var doc = SpreadsheetDocument.Create(ms, DocumentFormat.OpenXml.SpreadsheetDocumentType.Workbook); + var wbp = doc.AddWorkbookPart(); wbp.Workbook = new Workbook(); + var sp = wbp.AddNewPart(); sp.Worksheet = new Worksheet(new SheetData()); + wbp.Workbook.AppendChild(new Sheets()).Append(new DocumentFormat.OpenXml.Spreadsheet.Sheet { Id = wbp.GetIdOfPart(sp), SheetId = 1, Name = "S1" }); + var sd = sp.Worksheet.GetFirstChild()!; + var pr = typeof(T).GetProperties(); + var h = new Row(); foreach (var p in pr) h.AppendChild(new Cell { DataType = CellValues.String, CellValue = new DocumentFormat.OpenXml.Spreadsheet.CellValue(p.Name) }); sd.AppendChild(h); + foreach (var item in d) + { + var row = new Row(); + foreach (var p in pr) + { + var v = p.GetValue(item); + DocumentFormat.OpenXml.Spreadsheet.CellValue cv; + if (v is null) cv = new DocumentFormat.OpenXml.Spreadsheet.CellValue(""); + else if (v is string sv) cv = new DocumentFormat.OpenXml.Spreadsheet.CellValue(sv); + else if (v is int iv) cv = new DocumentFormat.OpenXml.Spreadsheet.CellValue((double)iv); + else if (v is long lv) cv = new DocumentFormat.OpenXml.Spreadsheet.CellValue((double)lv); + else if (v is double dv) cv = new DocumentFormat.OpenXml.Spreadsheet.CellValue(dv); + else if (v is decimal mv) cv = new DocumentFormat.OpenXml.Spreadsheet.CellValue((double)mv); + else if (v is DateTime dtv) cv = new DocumentFormat.OpenXml.Spreadsheet.CellValue(dtv.ToOADate().ToString(CultureInfo.InvariantCulture)); + else if (v is bool bv) cv = new DocumentFormat.OpenXml.Spreadsheet.CellValue(bv ? "1" : "0"); + else cv = new DocumentFormat.OpenXml.Spreadsheet.CellValue(v.ToString() ?? ""); + row.AppendChild(new Cell { DataType = CellValues.String, CellValue = cv }); + } + sd.AppendChild(row); + } + wbp.Workbook.Save(); return ms.ToArray(); + } + + static byte[] EP(T[] d) + { + var pr = typeof(T).GetProperties(); + using var ms = new MemoryStream(); + using var pkg = new OfficeOpenXml.ExcelPackage(ms); + var ws = pkg.Workbook.Worksheets.Add("S1"); + for (int c = 0; c < pr.Length; c++) ws.Cells[1, c + 1].Value = pr[c].Name; + for (int r = 0; r < d.Length; r++) for (int c = 0; c < pr.Length; c++) ws.Cells[r + 2, c + 1].Value = pr[c].GetValue(d[r]); + pkg.Save(); return ms.ToArray(); + } + + static byte[] EPStyled(S2[] d) + { + using var ms = new MemoryStream(); using var pkg = new OfficeOpenXml.ExcelPackage(ms); var ws = pkg.Workbook.Worksheets.Add("S1"); + ws.Cells[1, 1].Value = "L"; ws.Cells[1, 1].Style.Font.Bold = true; ws.Cells[1, 1].Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid; ws.Cells[1, 1].Style.Fill.BackgroundColor.SetColor(SixLabors.ImageSharp.Color.FromRgba(252, 228, 214, 255)); ws.Cells[1, 1].Style.Border.BorderAround(OfficeOpenXml.Style.ExcelBorderStyle.Thin); + ws.Cells[1, 2].Value = "V"; ws.Cells[1, 2].Style.Font.Color.SetColor(SixLabors.ImageSharp.Color.FromRgb(0, 0, 255)); + for (int i = 0; i < d.Length; i++) { ws.Cells[i + 2, 1].Value = d[i].L; ws.Cells[i + 2, 2].Value = d[i].V; } + ws.Cells[2, 2, d.Length + 1, 2].Style.Numberformat.Format = "0.00"; + pkg.Save(); return ms.ToArray(); + } + + static void EPWrite(OfficeOpenXml.ExcelPackage pkg, string name, T[] d) + { + var ws = pkg.Workbook.Worksheets.Add(name); var pr = typeof(T).GetProperties(); + for (int c = 0; c < pr.Length; c++) ws.Cells[1, c + 1].Value = pr[c].Name; + for (int r = 0; r < d.Length; r++) for (int c = 0; c < pr.Length; c++) ws.Cells[r + 2, c + 1].Value = pr[c].GetValue(d[r]); + } + } +} diff --git a/src/Magicodes.IE.Benchmarks/XlsxIO_Profile_Benchmarks.cs b/src/Magicodes.IE.Benchmarks/XlsxIO_Profile_Benchmarks.cs new file mode 100644 index 00000000..76764cd9 --- /dev/null +++ b/src/Magicodes.IE.Benchmarks/XlsxIO_Profile_Benchmarks.cs @@ -0,0 +1,296 @@ +// ====================================================================== +// XlsxIO_Profile_Benchmarks.cs — 隔离每个 phase 测损耗 +// +// 目的:把 "100k string 24ms" 拆成 setup / write / zip / dispose 4 段, +// 定位时间花在哪。 +// +// 设计: +// - Mio_Phase1_Setup: new writer + AddSheet + WriteSheetMeta + WriteHeader(无 row) +// - Mio_Phase2_Write: 复用已 AddSheet 的 writer,只 WriteRows 100k +// - Mio_Phase3_Dispose: 复用已写完的 writer,只 Dispose +// - Mio_Full: 全流程(= phase1+2+3) +// +// 用 [InvocationCount(1)] 避免 BDN 默认 16 次 invocation 抹掉 setup 开销。 +// ====================================================================== + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Order; +using Magicodes.IE.IO; + +namespace Magicodes.IE.Benchmarks +{ + [MemoryDiagnoser] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + [CategoriesColumn] + [InvocationCount(1)] + public class XlsxIO_Profile_Benchmarks + { + public record S4(string C1, string C2, string C3, string C4); + + static S4[] S(int n) => System.Linq.Enumerable.Range(0, n) + .Select(i => new S4("s" + i, "c" + (i % 100), "p" + (i % 50), "t" + (i % 5))).ToArray(); + + private static readonly ColumnMeta[] s_cols = XlsxIO_TestSupport_Profile.MakeCols(); + private static readonly TypedRowPlan s_plan = XlsxIO_TestSupport_Profile.MakeTypedPlan(new[] { "C1", "C2", "C3", "C4" }); + + private S4[] _10k = null!, _100k = null!; + private System.IO.Compression.CompressionLevel _comp = System.IO.Compression.CompressionLevel.Fastest; + + [GlobalSetup] + public void Setup() + { + _10k = S(10_000); + _100k = S(100_000); + } + + // ===== 10k 拆 phase ===== + + [BenchmarkCategory("phase-10k"), Benchmark(Baseline = true)] + public byte[] Mio_10k_Full() => MioBytes(_10k, _comp); + + [BenchmarkCategory("phase-10k"), Benchmark] + public long Mio_10k_Full_WriteOnly() => MioLength(_10k, _comp); + + [BenchmarkCategory("phase-10k"), Benchmark] + public int Mio_10k_Full_CopySizeOnly() => MioCopySize(_10k, _comp); + + [BenchmarkCategory("phase-10k"), Benchmark] + public byte[] Mio_10k_Phase1_Setup() + { + // setup: new writer + AddSheet + WriteSheetMeta + WriteHeader + // (不写 row, 立即 dispose 触发 zip close) + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: _comp)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + } + return ms.ToArray(); + } + + [BenchmarkCategory("phase-10k"), Benchmark] + public byte[] Mio_10k_Phase2_WriteOnly() + { + // 只写 rows: 复用现成 reader + cols + // writer + setup 仍要 1 次, 但我们只测增量 + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: _comp)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + w.WriteRows(_10k, s_plan); + } + return ms.ToArray(); + } + + // ===== 100k 拆 phase ===== + + [BenchmarkCategory("phase-100k"), Benchmark] + public byte[] Mio_100k_Full() => MioBytes(_100k, _comp); + + [BenchmarkCategory("phase-100k"), Benchmark] + public long Mio_100k_Full_WriteOnly() => MioLength(_100k, _comp); + + [BenchmarkCategory("phase-100k"), Benchmark] + public int Mio_100k_Full_CopySizeOnly() => MioCopySize(_100k, _comp); + + [BenchmarkCategory("phase-100k"), Benchmark] + public long Mio_100k_Full_NullStream() => MioNull(_100k, _comp); + + [BenchmarkCategory("phase-100k"), Benchmark] + public byte[] Mio_100k_Phase1_Setup() + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: _comp)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + } + return ms.ToArray(); + } + + [BenchmarkCategory("phase-100k"), Benchmark] + public byte[] Mio_100k_NoCompress_Full() + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: System.IO.Compression.CompressionLevel.NoCompression)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + w.WriteRows(_100k, s_plan); + } + return ms.ToArray(); + } + + [BenchmarkCategory("phase-100k"), Benchmark] + public byte[] Mio_100k_NoCompress_Phase1_Setup() + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: System.IO.Compression.CompressionLevel.NoCompression)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + } + return ms.ToArray(); + } + + // ===== 2. SST path(高重复 → 走 ) ===== + // 看 SST 路径在 100k string 上比 inline 慢还是快(escape 工作应大幅减少) + [BenchmarkCategory("phase-100k"), Benchmark] + public byte[] Mio_100k_SST_Full() + { + // _100k 4 列 unique = 4 unique count, 高度重复 + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.EnableSharedStrings(); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + w.WriteRows(_100k, s_plan); + } + return ms.ToArray(); + } + + [BenchmarkCategory("phase-100k"), Benchmark] + public byte[] Mio_100k_SST_NoCompress_Full() + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: System.IO.Compression.CompressionLevel.NoCompression)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.EnableSharedStrings(); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + w.WriteRows(_100k, s_plan); + } + return ms.ToArray(); + } + + // ===== 3. 10k 写 detail (多 phase) ===== + [BenchmarkCategory("phase-10k"), Benchmark] + public byte[] Mio_10k_NoCompress_Full() + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: System.IO.Compression.CompressionLevel.NoCompression)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + w.WriteRows(_10k, s_plan); + } + return ms.ToArray(); + } + + private static byte[] MioBytes(S4[] d, System.IO.Compression.CompressionLevel compression) + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: compression)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + w.WriteRows(d, s_plan); + } + return ms.ToArray(); + } + + private static long MioLength(S4[] d, System.IO.Compression.CompressionLevel compression) + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: compression)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + w.WriteRows(d, s_plan); + } + return ms.Length; + } + + private static int MioCopySize(S4[] d, System.IO.Compression.CompressionLevel compression) + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms, compression: compression)) + { + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + w.WriteRows(d, s_plan); + } + return ms.ToArray().Length; + } + + private static long MioNull(S4[] d, System.IO.Compression.CompressionLevel compression) + { + using var w = new XlsxWriter(Stream.Null, compression: compression); + w.AddSheet("S1"); + PrepareWriter(w); + w.WriteSheetMeta(s_cols, freezeHeader: true); + w.WriteHeader(s_cols); + w.WriteRows(d, s_plan); + return 0; + } + + // ===== helpers ===== + private static void PrepareWriter(XlsxWriter writer) + { + writer.SetNumFmts(s_plan.BuildNumFmts()); + writer.ResolveColumnStyles(s_plan.Columns); + } + } + + internal static class XlsxIO_TestSupport_Profile + { + public static ColumnMeta[] MakeCols() => new[] + { + new ColumnMeta("C1", "C1", null, null, false, 0, 0), + new ColumnMeta("C2", "C2", null, null, false, 0, 1), + new ColumnMeta("C3", "C3", null, null, false, 0, 2), + new ColumnMeta("C4", "C4", null, null, false, 0, 3), + }; + + public static TypedRowPlan MakeTypedPlan(string[] propertyNames) + { + var cols = new ColumnMeta[propertyNames.Length]; + for (int i = 0; i < propertyNames.Length; i++) + cols[i] = new ColumnMeta(propertyNames[i], propertyNames[i], null, null, false, 0, i); + var props = typeof(T).GetProperties(); + var getters = new Func[propertyNames.Length]; + for (int i = 0; i < propertyNames.Length; i++) + { + var p = System.Array.Find(props, x => x.Name == propertyNames[i])!; + getters[i] = o => p.GetValue(o) switch + { + string s => CellValue.FromString(s), + int v => CellValue.FromInteger(v), + _ => CellValue.Null + }; + } + return new TypedRowPlan(cols, new Func[0], getters, new int[propertyNames.Length], new Action?[propertyNames.Length], false); + } + } +} diff --git a/src/Magicodes.IE.Benchmarks/XlsxIO_ReadBenchmarks.cs b/src/Magicodes.IE.Benchmarks/XlsxIO_ReadBenchmarks.cs new file mode 100644 index 00000000..a90f2ed3 --- /dev/null +++ b/src/Magicodes.IE.Benchmarks/XlsxIO_ReadBenchmarks.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Globalization; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Order; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using Magicodes.IE.IO; +using MiniExcelLibs; + +namespace Magicodes.IE.Benchmarks +{ + [MemoryDiagnoser] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + [CategoriesColumn] + public class XlsxIO_ReadBenchmarks + { + public sealed class ReadRowDto + { + public string OrderNo { get; set; } = ""; + public string Region { get; set; } = ""; + public string Product { get; set; } = ""; + public int Qty { get; set; } + } + + private byte[] _inline10k = Array.Empty(); + private byte[] _sst10k = Array.Empty(); + private byte[] _sparse10k = Array.Empty(); + + [GlobalSetup] + public void Setup() + { + var inline = Enumerable.Range(0, 10_000) + .Select(i => new ReadRowDto + { + OrderNo = $"O{i}", + Region = $"R{i % 8}", + Product = $"P{i % 64}", + Qty = i % 100, + }) + .ToArray(); + + var repeated = Enumerable.Range(0, 10_000) + .Select(i => new ReadRowDto + { + OrderNo = $"O{i % 32}", + Region = $"R{i % 4}", + Product = $"P{i % 16}", + Qty = i % 100, + }) + .ToArray(); + + _inline10k = Xlsx.ToBytes(inline); + _sst10k = Xlsx.ToBytes(repeated, p => p.WithAutoSst(true)); + _sparse10k = BuildSparseWorkbook(10_000); + } + + [BenchmarkCategory("read-10k"), Benchmark(Baseline = true)] + public int Query_InlineStrings_Count() + => CountRows(_inline10k); + + [BenchmarkCategory("read-10k"), Benchmark] + public int Query_SharedStrings_Count() + => CountRows(_sst10k); + + [BenchmarkCategory("read-10k"), Benchmark] + public int Query_SparseColumns_Count() + => CountRows(_sparse10k); + + [BenchmarkCategory("read-10k"), Benchmark] + public int Query_MiniExcel_Count() + => CountRowsMiniExcel(_inline10k); + + [BenchmarkCategory("read-10k"), Benchmark] + public int Query_OpenXml_Count() + => CountRowsOpenXml(_inline10k); + + private static int CountRows(byte[] bytes) + { + using var stream = new MemoryStream(bytes, writable: false); + var count = 0; + foreach (var _ in Xlsx.Read(stream)) + count++; + return count; + } + + private static int CountRowsMiniExcel(byte[] bytes) + { + using var stream = new MemoryStream(bytes, writable: false); + var count = 0; + foreach (var _ in MiniExcel.Query(stream)) + count++; + return count; + } + + private static int CountRowsOpenXml(byte[] bytes) + { + using var stream = new MemoryStream(bytes, writable: false); + using var doc = SpreadsheetDocument.Open(stream, false); + var worksheetPart = doc.WorkbookPart!.WorksheetParts.First(); + using var reader = OpenXmlReader.Create(worksheetPart); + + var count = 0; + while (reader.Read()) + { + if (reader.ElementType == typeof(Row) && reader.IsStartElement) + count++; + } + + return count; + } + + private static byte[] BuildSparseWorkbook(int rows) + { + using var ms = new MemoryStream(); + using (var doc = SpreadsheetDocument.Create(ms, SpreadsheetDocumentType.Workbook, autoSave: true)) + { + var wbPart = doc.AddWorkbookPart(); + wbPart.Workbook = new Workbook(); + + var wsPart = wbPart.AddNewPart(); + var sheetData = new SheetData(); + wsPart.Worksheet = new Worksheet(sheetData); + + var sheets = wbPart.Workbook.AppendChild(new Sheets()); + sheets.Append(new DocumentFormat.OpenXml.Spreadsheet.Sheet + { + Id = wbPart.GetIdOfPart(wsPart), + SheetId = 1, + Name = "Sparse", + }); + + static Cell InlineCell(string cellRef, string value) => new() + { + CellReference = cellRef, + DataType = CellValues.InlineString, + InlineString = new InlineString(new Text(value)), + }; + + var header = new Row { RowIndex = 1U }; + header.Append( + InlineCell("A1", "OrderNo"), + InlineCell("B1", "Region"), + InlineCell("C1", "Product"), + InlineCell("D1", "Qty")); + sheetData.Append(header); + + for (int i = 0; i < rows; i++) + { + int rowIndex = i + 2; + var row = new Row { RowIndex = (uint)rowIndex }; + if ((i & 1) == 0) + { + row.Append( + InlineCell($"A{rowIndex}", $"O{i}"), + new Cell + { + CellReference = $"D{rowIndex}", + CellValue = new DocumentFormat.OpenXml.Spreadsheet.CellValue((i % 100).ToString(CultureInfo.InvariantCulture)), + }); + } + else + { + row.Append( + InlineCell($"B{rowIndex}", $"R{i % 8}"), + InlineCell($"C{rowIndex}", $"P{i % 64}")); + } + sheetData.Append(row); + } + + wbPart.Workbook.Save(); + } + return ms.ToArray(); + } + } +} diff --git a/src/Magicodes.IE.Benchmarks/XlsxIO_ReadCompare_Benchmarks.cs b/src/Magicodes.IE.Benchmarks/XlsxIO_ReadCompare_Benchmarks.cs new file mode 100644 index 00000000..85ccb55a --- /dev/null +++ b/src/Magicodes.IE.Benchmarks/XlsxIO_ReadCompare_Benchmarks.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Order; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using Magicodes.IE.IO; +using MiniExcelLibs; + +namespace Magicodes.IE.Benchmarks +{ + [MemoryDiagnoser] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + [CategoriesColumn] + public class XlsxIO_ReadCompare_Benchmarks + { + public sealed class ReadRowDto + { + public string OrderNo { get; set; } = ""; + public string Region { get; set; } = ""; + public string Product { get; set; } = ""; + public int Qty { get; set; } + } + + private byte[] _sameFile = Array.Empty(); + + [GlobalSetup] + public void Setup() + { + var data = Enumerable.Range(0, 10_000) + .Select(i => new ReadRowDto + { + OrderNo = $"O{i}", + Region = $"R{i % 8}", + Product = $"P{i % 64}", + Qty = i % 100, + }) + .ToArray(); + + _sameFile = Xlsx.ToBytes(data); + } + + [BenchmarkCategory("read-same-file"), Benchmark(Baseline = true)] + public int Query_Mio_Count() + => CountRowsMio(_sameFile); + + [BenchmarkCategory("read-same-file"), Benchmark] + public int Query_MiniExcel_Count() + => CountRowsMiniExcel(_sameFile); + + [BenchmarkCategory("read-same-file"), Benchmark] + public int Query_OpenXml_RowScan_Count() + => CountRowsOpenXml(_sameFile); + + private static int CountRowsMio(byte[] bytes) + { + using var stream = new MemoryStream(bytes, writable: false); + var count = 0; + foreach (var _ in Xlsx.Read(stream)) + count++; + return count; + } + + private static int CountRowsMiniExcel(byte[] bytes) + { + using var stream = new MemoryStream(bytes, writable: false); + var count = 0; + foreach (var _ in MiniExcel.Query(stream)) + count++; + return count; + } + + private static int CountRowsOpenXml(byte[] bytes) + { + using var stream = new MemoryStream(bytes, writable: false); + using var doc = SpreadsheetDocument.Open(stream, false); + var worksheetPart = doc.WorkbookPart!.WorksheetParts.First(); + using var reader = OpenXmlReader.Create(worksheetPart); + + var count = 0; + while (reader.Read()) + { + if (reader.IsStartElement && reader.ElementType == typeof(Row)) + count++; + } + + return count; + } + } +} diff --git a/src/Magicodes.IE.Benchmarks/XlsxIO_ZeroAlloc_Benchmarks.cs b/src/Magicodes.IE.Benchmarks/XlsxIO_ZeroAlloc_Benchmarks.cs new file mode 100644 index 00000000..ef1f67f2 --- /dev/null +++ b/src/Magicodes.IE.Benchmarks/XlsxIO_ZeroAlloc_Benchmarks.cs @@ -0,0 +1,388 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Order; +using Magicodes.IE.IO; + + +namespace Magicodes.IE.Benchmarks +{ + [MemoryDiagnoser] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + [CategoriesColumn] + public class XlsxIO_ZeroAlloc_Benchmarks + { + [XlsxExportable] + public record ZA4(string C1, string C2, string C3, string C4); + [XlsxExportable] + public record Nullable4(string C1, int? C2, DateTime? C3, bool? C4); + + private ZA4[] _10k = null!; + private ZA4[] _100k = null!; + private FixedBufferWriter _bufferWriter10k = null!; + private FixedBufferWriter _bufferWriter100k = null!; + private FixedWriteStream _stream100k = null!; + private byte[] _deflateInput = null!; + + [GlobalSetup] + public void Setup() + { + _10k = Build(10_000); + _100k = Build(100_000); + _bufferWriter10k = new FixedBufferWriter(2 * 1024 * 1024); + _bufferWriter100k = new FixedBufferWriter(8 * 1024 * 1024); + _stream100k = new FixedWriteStream(8 * 1024 * 1024); + // 诊断:可压缩的 sheet-XML 风格字节,供 DeflateStream sync vs async micro-benchmark。 + _deflateInput = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat( + "s0\r\n", 50_000))); + } + + [BenchmarkCategory("convenience-10k"), Benchmark] + public int Mio_10k_Bytes() + => Xlsx.ToBytes(_10k).Length; + + [BenchmarkCategory("zeroalloc-10k"), Benchmark] + public int Mio_10k_Write_BufferWriter() + { + _bufferWriter10k.Reset(); + Xlsx.Write(_bufferWriter10k, _10k); + return _bufferWriter10k.WrittenCount; + } + + [BenchmarkCategory("convenience-100k"), Benchmark] + public int Mio_100k_Bytes() + => Xlsx.ToBytes(_100k).Length; + + [BenchmarkCategory("zeroalloc-100k"), Benchmark] + public int Mio_100k_Write_BufferWriter() + { + _bufferWriter100k.Reset(); + Xlsx.Write(_bufferWriter100k, _100k); + return _bufferWriter100k.WrittenCount; + } + + [BenchmarkCategory("zeroalloc-100k"), Benchmark] + public long Mio_100k_Write_Stream_NoSeek() + { + _stream100k.Reset(); + using (var output = new NonSeekableWriteStream(_stream100k)) + { + Xlsx.Write(output, _100k); + } + return _stream100k.Length; + } + + // 异步路径零分配验证:走 WriteRowsAsync + CompleteAsync + DisposeAsync 全链路。 + // FixedWriteStream 的 WriteAsync 同步完成(不切线程),BDN 的 GetAllocatedBytesForCurrentThread + // 能测准异步状态机 + I/O 链路上的真实分配(状态机 box 不可避免,但 ToArray/AsTask 应已消除)。 + [BenchmarkCategory("zeroalloc-100k"), Benchmark] + public async Task Mio_100k_WriteAsync_Stream() + { + _stream100k.Reset(); + await Xlsx.WriteAsync(_stream100k, ToAsyncEnum(_100k)).ConfigureAwait(false); + return _stream100k.Length; + } + + // 诊断:10k 行异步,与 100k 线性对比定位分配来源(固定开销 vs 每行开销)。 + [BenchmarkCategory("zeroalloc-10k"), Benchmark] + public async Task Mio_10k_WriteAsync_Stream() + { + _stream100k.Reset(); + await Xlsx.WriteAsync(_stream100k, ToAsyncEnum(_10k)).ConfigureAwait(false); + return _stream100k.Length; + } + + // IList 快路径:数据已物化(数组),同步遍历 + 异步 flush,无 IAsyncEnumerable 枚举层。 + [BenchmarkCategory("zeroalloc-100k"), Benchmark] + public async Task Mio_100k_WriteAsync_IList() + { + _stream100k.Reset(); + await Xlsx.WriteAsync(_stream100k, _100k).ConfigureAwait(false); + return _stream100k.Length; + } + + // ---- 真异步 I/O 场景(AsyncYieldingWriteStream,每次 WriteAsync 都 Yield) ---- + // 验证零分配改造 / 批量化 / IList 快路径在异步完成下的实际收益(同步完成 benchmark 测不出)。 + + [BenchmarkCategory("asyncyield-100k"), Benchmark] + public async Task Mio_100k_AsyncYield_IList() + { + _stream100k.Reset(); + using var ys = new AsyncYieldingWriteStream(_stream100k); + await Xlsx.WriteAsync(ys, _100k).ConfigureAwait(false); + return _stream100k.Length; + } + + [BenchmarkCategory("asyncyield-100k"), Benchmark] + public async Task Mio_100k_AsyncYield_AsyncEnum() + { + _stream100k.Reset(); + using var ys = new AsyncYieldingWriteStream(_stream100k); + await Xlsx.WriteAsync(ys, ToAsyncEnum(_100k)).ConfigureAwait(false); + return _stream100k.Length; + } + + + // 同步迭代器(无 Task.Yield):避免切线程,让 BDN 测准当前线程分配。 + private static async IAsyncEnumerable ToAsyncEnum(IEnumerable source, [EnumeratorCancellation] CancellationToken ct = default) + { + using var e = source.GetEnumerator(); + while (e.MoveNext()) + { + ct.ThrowIfCancellationRequested(); + yield return e.Current; + } + } + + private static ZA4[] Build(int n) + => Enumerable.Range(0, n) + .Select(i => new ZA4("s" + i, "c" + (i % 100), "p" + (i % 50), "t" + (i % 5))) + .ToArray(); + + // 诊断:0 行异步,隔离固定 async 开销(CompleteAsync/DisposeAsync/方法链)vs 每行开销。 + [BenchmarkCategory("deflate-diag"), Benchmark] + public async Task Mio_0_WriteAsync_IList() + { + _stream100k.Reset(); + await Xlsx.WriteAsync(_stream100k, Array.Empty()).ConfigureAwait(false); + return _stream100k.Length; + } + + // 诊断:同步 Write 包在 async Task 里(无真异步 I/O),确认 async 方法状态机本身是否分配。 + // 若 ≈ 同步 15KB → async Task 包装零分配,81KB 差距在异步 flush 链;若 ≈96KB → async 方法本身分配。 + [BenchmarkCategory("deflate-diag"), Benchmark] + public async Task Mio_100k_SyncWriteInAsync() + { + _stream100k.Reset(); + Xlsx.Write(_stream100k, _100k); + await Task.CompletedTask; + return _stream100k.Length; + } + + // ---- 诊断:BCL DeflateStream sync vs async 的内部分配差(隔离异步层元凶) ---- + // 同样的可压缩输入 + MemoryStream(同步完成),测 DeflateStream.Write(span) vs WriteAsync(ROM)。 + // 若 async 远高于 sync,则异步路径固定开销主要落在 BCL DeflateStream 异步内部,非库可控。 + // 分块写(模拟 Mio 的 ~50 次 sheet flush),测 DeflateStream 每次 WriteAsync 的 per-call 分配。 + [BenchmarkCategory("deflate-diag"), Benchmark] + public long Deflate_Sync() + { + _stream100k.Reset(); + using var ds = new DeflateStream(_stream100k, CompressionLevel.Optimal, leaveOpen: true); + int chunk = _deflateInput.Length / 50; + for (int i = 0; i < 50; i++) + { +#if NETSTANDARD2_0 + ds.Write(_deflateInput, i * chunk, chunk); +#else + ds.Write(_deflateInput.AsSpan(i * chunk, chunk)); +#endif + } + ds.Dispose(); + return _stream100k.Length; + } + + [BenchmarkCategory("deflate-diag"), Benchmark] + public async Task Deflate_Async() + { + _stream100k.Reset(); + using var ds = new DeflateStream(_stream100k, CompressionLevel.Optimal, leaveOpen: true); + int chunk = _deflateInput.Length / 50; + for (int i = 0; i < 50; i++) + { +#if NETSTANDARD2_0 + await ds.WriteAsync(_deflateInput, i * chunk, chunk).ConfigureAwait(false); +#else + await ds.WriteAsync(_deflateInput.AsMemory(i * chunk, chunk)).ConfigureAwait(false); +#endif + } + await ds.DisposeAsync().ConfigureAwait(false); + return _stream100k.Length; + } + + private sealed class NonSeekableWriteStream : Stream + { + private readonly Stream _inner; + + public NonSeekableWriteStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new System.NotSupportedException(); + + public override long Position + { + get => throw new System.NotSupportedException(); + set => throw new System.NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => throw new System.NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new System.NotSupportedException(); + + public override void SetLength(long value) => _inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + +#if !NETSTANDARD2_0 + public override void Write(System.ReadOnlySpan buffer) => _inner.Write(buffer); +#endif + } + + private sealed class FixedBufferWriter : IBufferWriter + { + private readonly byte[] _buffer; + private int _written; + + public FixedBufferWriter(int capacity) + { + _buffer = new byte[capacity]; + } + + public int WrittenCount => _written; + + public void Reset() => _written = 0; + + public void Advance(int count) + { + var next = _written + count; + if ((uint)next > (uint)_buffer.Length) + throw new InvalidOperationException("fixed buffer overflow"); + _written = next; + } + + public Memory GetMemory(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsMemory(_written); + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsSpan(_written); + } + + private void EnsureCapacity(int sizeHint) + { + if (sizeHint < 0) throw new ArgumentOutOfRangeException(nameof(sizeHint)); + if (sizeHint == 0) sizeHint = 1; + if (_written + sizeHint > _buffer.Length) + throw new InvalidOperationException("fixed buffer overflow"); + } + } + + private sealed class FixedWriteStream : Stream + { + private readonly byte[] _buffer; + private int _length; + + public FixedWriteStream(int capacity) + { + _buffer = new byte[capacity]; + } + + public override long Length => _length; + + public void Reset() => _length = 0; + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Position + { + get => _length; + set => throw new NotSupportedException(); + } + + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + if ((uint)offset > (uint)buffer.Length) throw new ArgumentOutOfRangeException(nameof(offset)); + if ((uint)count > (uint)(buffer.Length - offset)) throw new ArgumentOutOfRangeException(nameof(count)); + EnsureCapacity(count); + Buffer.BlockCopy(buffer, offset, _buffer, _length, count); + _length += count; + } + +#if !NETSTANDARD2_0 + public override void Write(ReadOnlySpan buffer) + { + EnsureCapacity(buffer.Length); + buffer.CopyTo(_buffer.AsSpan(_length)); + _length += buffer.Length; + } + + // 关键:override WriteAsync(ROM) 高效版,避免 Stream 基类默认实现的 ValueTask 包装分配。 + // 基类默认 WriteAsync(ROM) 调 WriteAsync(byte[])+Task 包装,每次分配;override 后同步完成零分配。 + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + Write(buffer.Span); + return default; + } +#endif + + private void EnsureCapacity(int count) + { + if (count < 0) throw new ArgumentOutOfRangeException(nameof(count)); + if (_length + count > _buffer.Length) + throw new InvalidOperationException("fixed stream overflow"); + } + } + + // 真异步 I/O mock:每次 WriteAsync 先 Task.Yield 再写,模拟 NetworkStream/FileStream 异步完成。 + // 用于验证零分配改造(AsTask 消除)、批量化 await、IList 快路径在异步完成场景的实际收益 + // (FixedWriteStream 同步完成下 AsTask 返回缓存 Task 不分配,测不出这些优化的真实效果)。 + private sealed class AsyncYieldingWriteStream : Stream + { + private readonly Stream _inner; + public AsyncYieldingWriteStream(Stream inner) => _inner = inner; + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => _inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await Task.Yield(); + _inner.Write(buffer, offset, count); + } + +#if !NETSTANDARD2_0 + public override void Write(ReadOnlySpan buffer) => _inner.Write(buffer); + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await Task.Yield(); + _inner.Write(buffer.Span); + } +#endif + } + } +} diff --git a/src/Magicodes.IE.IO.SourceGenerator/Magicodes.IE.IO.SourceGenerator.csproj b/src/Magicodes.IE.IO.SourceGenerator/Magicodes.IE.IO.SourceGenerator.csproj new file mode 100644 index 00000000..5f691f69 --- /dev/null +++ b/src/Magicodes.IE.IO.SourceGenerator/Magicodes.IE.IO.SourceGenerator.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0 + latest + enable + true + true + false + true + true + false + Magicodes.IE.IO.SourceGenerator + Source generator for Magicodes.IE.IO — generates typed property getters at compile time, eliminating Expression.Compile runtime reflection. + + + + + + + + diff --git a/src/Magicodes.IE.IO.SourceGenerator/XlsxRowGettersGenerator.cs b/src/Magicodes.IE.IO.SourceGenerator/XlsxRowGettersGenerator.cs new file mode 100644 index 00000000..8e7bdc32 --- /dev/null +++ b/src/Magicodes.IE.IO.SourceGenerator/XlsxRowGettersGenerator.cs @@ -0,0 +1,499 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace Magicodes.IE.IO.SourceGenerator +{ + [Generator(LanguageNames.CSharp)] + public sealed class XlsxRowGettersGenerator : IIncrementalGenerator + { + private sealed class TypeInfo + { + public TypeDeclarationSyntax? Syntax { get; set; } + public INamedTypeSymbol? Symbol { get; set; } + } + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.CreateSyntaxProvider( + predicate: (node, _) => node is TypeDeclarationSyntax, + transform: (ctx, _) => + { + if (ctx.Node is TypeDeclarationSyntax tds) + { + var sym = ctx.SemanticModel.GetDeclaredSymbol(tds); + if (sym is not null && HasAttr(sym) && sym is INamedTypeSymbol nts) + return new TypeInfo { Syntax = tds, Symbol = nts }; + } + return null; + }) + .Where(t => t is not null && t.Syntax is not null && t.Symbol is not null); + + var all = candidates.Collect(); + context.RegisterSourceOutput(all, (spc, items) => Emit(spc, items)); + } + + private static bool HasAttr(ISymbol sym) + { + const string expected = "global::Magicodes.IE.IO.XlsxExportableAttribute"; + foreach (var attr in sym.GetAttributes()) + if (attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == expected) + return true; + return false; + } + + private static void Emit(SourceProductionContext spc, ImmutableArray items) + { + if (items.IsDefaultOrEmpty) return; + var seen = new HashSet(); + + foreach (var item in items) + { + if (item?.Symbol is null) continue; + var named = item.Symbol; + if (!seen.Add(named.ToDisplayString())) continue; + + try + { + var source = Generate(named); + if (source is not null) + spc.AddSource($"XlsxGetters_{GetHintName(named)}.g.cs", SourceText.From(source, Encoding.UTF8)); + } + catch (Exception ex) + { + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "MIEIO001", + "XlsxRowGetters source generation failed", + "Source generation for type '{0}' failed: {1}", + "Magicodes.IE.IO.SourceGenerator", + DiagnosticSeverity.Warning, + isEnabledByDefault: true), + item.Syntax!.GetLocation(), + named.ToDisplayString(), ex.Message)); + } + } + } + + private static string? Generate(INamedTypeSymbol type) + { + if (type.IsGenericType || type.ContainingType is not null) return null; + List props = new(); + foreach (var m in type.GetMembers()) + { + if (m is IPropertySymbol p + && !p.IsStatic + && !p.IsIndexer + && p.GetMethod is not null + && p.GetMethod.DeclaredAccessibility == Accessibility.Public) + props.Add(p); + } + if (props.Count == 0) return null; + + var ns = GetNamespace(type); + var name = type.Name; + var fullName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var sb = new StringBuilder(); + sb.Append("// \r\n"); + sb.Append("#nullable enable\r\n"); + sb.Append("#pragma warning disable CS8600, CS8601, CS8603, CS8604, CS8619\r\n"); + sb.Append("\r\n"); + if (!string.IsNullOrEmpty(ns) && ns != "global") + { + sb.Append("namespace ").Append(ns).Append("\r\n{\r\n"); + } + sb.Append(" internal static class ").Append(name).Append("_XlsxGetters\r\n"); + sb.Append(" {\r\n"); + + foreach (var p in props) + { + sb.Append(" public static global::Magicodes.IE.IO.CellValue Get_").Append(p.Name) + .Append("(").Append(fullName).Append(" item) => ") + .Append(Expr(p, "item")).Append(";\r\n"); + } + sb.Append("\r\n"); + + sb.Append(" [System.Runtime.CompilerServices.ModuleInitializer]\r\n"); + sb.Append(" internal static void Register()\r\n"); + sb.Append(" {\r\n"); + sb.Append(" global::Magicodes.IE.IO.XlsxGeneratedGettersRegistry.Register<").Append(fullName).Append(">(() =>\r\n"); + sb.Append(" {\r\n"); + sb.Append(" var d = new System.Collections.Generic.Dictionary>(").Append(props.Count).Append(");\r\n"); + foreach (var p in props) + { + sb.Append(" d[\"").Append(p.Name).Append("\"] = obj =>\r\n"); + sb.Append(" {\r\n"); + sb.Append(" var item = (").Append(fullName).Append(")obj!;\r\n"); + sb.Append(" return ").Append(Expr(p, "item")).Append(";\r\n"); + sb.Append(" };\r\n"); + } + sb.Append(" return d;\r\n"); + sb.Append(" });\r\n"); + sb.Append(" global::Magicodes.IE.IO.XlsxGeneratedTypedGettersRegistry.Register<").Append(fullName).Append(">(() =>\r\n"); + sb.Append(" {\r\n"); + sb.Append(" var td = new System.Collections.Generic.Dictionary>(").Append(props.Count).Append(");\r\n"); + foreach (var p in props) + { + sb.Append(" td[\"").Append(p.Name).Append("\"] = Get_").Append(p.Name).Append(";\r\n"); + } + sb.Append(" return td;\r\n"); + sb.Append(" });\r\n"); + EmitRowWriters(sb, props, fullName); + EmitMetadata(sb, props, fullName); + sb.Append(" }\r\n"); + sb.Append(" }\r\n"); + if (!string.IsNullOrEmpty(ns) && ns != "global") sb.Append("}\r\n"); + + return sb.ToString(); + } + + private static void EmitRowWriters(StringBuilder sb, List props, string fullName) + { + sb.Append(" var __cw = new System.Collections.Generic.Dictionary>(").Append(props.Count).Append(");\r\n"); + foreach (var p in props) + { + sb.Append(" __cw[\"").Append(p.Name).Append("\"] = (w, row, styleId) =>\r\n"); + sb.Append(" {\r\n"); + sb.Append(" ").Append(DirectWrite(p, "row." + EscapeIdentifier(p.Name), "styleId")); + sb.Append(" };\r\n"); + } + sb.Append(" global::Magicodes.IE.IO.XlsxGeneratedRowWritersRegistry.Register<").Append(fullName).Append(">(() => __cw);\r\n"); + } + + private static void EmitMetadata(StringBuilder sb, List props, string fullName) + { + sb.Append(" global::Magicodes.IE.IO.XlsxGeneratedTypeMetadataRegistry.Register<").Append(fullName).Append(">(() => new global::Magicodes.IE.IO.XlsxGeneratedPropertyMetadata<").Append(fullName).Append(">[]\r\n"); + sb.Append(" {\r\n"); + foreach (var p in props) + { + var exporter = GetAttribute(p, "ExporterHeaderAttribute"); + var importer = GetAttribute(p, "ImporterHeaderAttribute"); + var display = GetAttribute(p, "DisplayAttribute"); + var description = GetAttribute(p, "DescriptionAttribute"); + var displayFormat = GetAttribute(p, "DisplayFormatAttribute"); + var propertyType = GetParseType(p.Type); + var setter = CanGenerateSetter(p) + ? "(item, cell, converters, date1904) =>\r\n {\r\n" + + " if (!global::Magicodes.IE.IO.XlsxGeneratedReadHelper.TryParse<" + TypeName(propertyType) + ">(cell, out var value, converters, date1904)) return false;\r\n" + + " item." + EscapeIdentifier(p.Name) + " = value;\r\n" + + " return true;\r\n" + + " }" + : "null"; + + sb.Append(" new global::Magicodes.IE.IO.XlsxGeneratedPropertyMetadata<").Append(fullName).Append(">(\r\n"); + sb.Append(" ").Append(Quote(p.Name)).Append(",\r\n"); + sb.Append(" ").Append(Quote(GetNamedString(importer, "Name"))).Append(",\r\n"); + sb.Append(" ").Append(Quote(GetNamedString(display, "Name"))).Append(",\r\n"); + sb.Append(" ").Append(Quote(GetConstructorString(description))).Append(",\r\n"); + sb.Append(" ").Append(Quote(GetNamedString(exporter, "Format") ?? GetNamedString(displayFormat, "DataFormatString"))).Append(",\r\n"); + sb.Append(" ").Append(GetNamedDouble(exporter, "Width") is double width && width > 0 ? width.ToString(System.Globalization.CultureInfo.InvariantCulture) : "null").Append(",\r\n"); + sb.Append(" ").Append(GetNamedInt(exporter, "Index")?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-1").Append(",\r\n"); + sb.Append(" ").Append(GetNamedBool(exporter, "IsIgnore") ? "true" : "false").Append(",\r\n"); + sb.Append(" ").Append(Quote(propertyType.Name)).Append(",\r\n"); + sb.Append(" Get_").Append(p.Name).Append(",\r\n"); + sb.Append(" (w, row, styleId) =>\r\n"); + sb.Append(" {\r\n"); + sb.Append(DirectWrite(p, "row." + EscapeIdentifier(p.Name), "styleId")); + sb.Append(" },\r\n"); + sb.Append(" ").Append(setter).Append("),\r\n"); + } + sb.Append(" });\r\n"); + } + + private static AttributeData? GetAttribute(IPropertySymbol property, string name) + { + foreach (var attribute in property.GetAttributes()) + { + if (attribute.AttributeClass?.Name == name || attribute.AttributeClass?.Name == name.Replace("Attribute", string.Empty)) + return attribute; + } + return null; + } + + private static string? GetNamedString(AttributeData? attribute, string name) + { + if (attribute is null) return null; + foreach (var argument in attribute.NamedArguments) + if (argument.Key == name && argument.Value.Value is string value) + return value; + return null; + } + + private static bool GetNamedBool(AttributeData? attribute, string name) + { + if (attribute is null) return false; + foreach (var argument in attribute.NamedArguments) + if (argument.Key == name && argument.Value.Value is bool value) + return value; + return false; + } + + private static int? GetNamedInt(AttributeData? attribute, string name) + { + if (attribute is null) return null; + foreach (var argument in attribute.NamedArguments) + if (argument.Key == name && argument.Value.Value is int value) + return value; + return null; + } + + private static double? GetNamedDouble(AttributeData? attribute, string name) + { + if (attribute is null) return null; + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is double value) + return value; + } + return null; + } + + private static string? GetConstructorString(AttributeData? attribute) + { + if (attribute is null || attribute.ConstructorArguments.Length == 0) + return null; + return attribute.ConstructorArguments[0].Value as string; + } + + private static bool CanGenerateSetter(IPropertySymbol property) + { + return property.SetMethod is not null + && property.SetMethod.DeclaredAccessibility == Accessibility.Public + && !property.SetMethod.IsInitOnly; + } + + private static ITypeSymbol GetParseType(ITypeSymbol type) + { + if (type is INamedTypeSymbol named + && named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + return named.TypeArguments[0]; + return type; + } + + private static string TypeName(ITypeSymbol type) + { + return type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + private static string Quote(string? value) + { + if (value is null) return "null"; + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (var ch in value) + { + switch (ch) + { + case '\\': sb.Append("\\\\"); break; + case '"': sb.Append("\\\""); break; + case '\r': sb.Append("\\r"); break; + case '\n': sb.Append("\\n"); break; + case '\t': sb.Append("\\t"); break; + case '\0': sb.Append("\\0"); break; + default: + if (char.IsControl(ch)) + sb.Append("\\u").Append(((int)ch).ToString("x4", System.Globalization.CultureInfo.InvariantCulture)); + else + sb.Append(ch); + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + + private static string GetHintName(INamedTypeSymbol type) + { + var full = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = new StringBuilder(full.Length); + foreach (var ch in full) + sb.Append(char.IsLetterOrDigit(ch) ? ch : '_'); + return sb.ToString(); + } + + private static string GetNamespace(INamedTypeSymbol type) + { + var ns = type.ContainingNamespace; + if (ns is null || ns.IsGlobalNamespace) return string.Empty; + var parts = new Stack(); + while (ns is not null && !ns.IsGlobalNamespace) + { + parts.Push(ns.Name); + ns = ns.ContainingNamespace; + } + return string.Join(".", parts); + } + + private static string Expr(IPropertySymbol p, string v) + { + var u = Underlying(p.Type); + var nullable = IsNullableValueType(p.Type); + var access = $"{v}.{EscapeIdentifier(p.Name)}"; + if (nullable) + { + if (u.SpecialType == SpecialType.System_Int32) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromInteger({access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + if (u.SpecialType == SpecialType.System_Int64) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromInteger({access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + if (u.SpecialType == SpecialType.System_Double) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromNumber({access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + if (u.SpecialType == SpecialType.System_Single) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromNumber((double){access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + if (u.SpecialType == SpecialType.System_Decimal) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromNumber((double){access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + if (u.SpecialType == SpecialType.System_Boolean) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromBool({access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + if (u.SpecialType == SpecialType.System_DateTime) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromDateTime({access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + if (u.TypeKind == TypeKind.Enum) + { + if (IsUlongEnum(u)) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromNumber((double)(ulong){access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromInteger((long){access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + } + if (u.SpecialType == SpecialType.System_UInt64) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromNumber((double){access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + if (IsInt(u)) return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromInteger((long){access}.Value) : global::Magicodes.IE.IO.CellValue.Null"; + return $"{access}.HasValue ? global::Magicodes.IE.IO.CellValue.FromString({access}.Value.ToString()) : global::Magicodes.IE.IO.CellValue.Null"; + } + if (u.SpecialType == SpecialType.System_String) return $"global::Magicodes.IE.IO.CellValue.FromString({access})"; + if (u.SpecialType == SpecialType.System_Int32) return $"global::Magicodes.IE.IO.CellValue.FromInteger({access})"; + if (u.SpecialType == SpecialType.System_Int64) return $"global::Magicodes.IE.IO.CellValue.FromInteger({access})"; + if (u.SpecialType == SpecialType.System_Double) return $"global::Magicodes.IE.IO.CellValue.FromNumber({access})"; + if (u.SpecialType == SpecialType.System_Decimal) return $"global::Magicodes.IE.IO.CellValue.FromNumber((double){access})"; + if (u.SpecialType == SpecialType.System_Boolean) return $"global::Magicodes.IE.IO.CellValue.FromBool({access})"; + if (u.TypeKind == TypeKind.Enum) + { + if (IsUlongEnum(u)) return $"global::Magicodes.IE.IO.CellValue.FromNumber((double)(ulong){access})"; + return $"global::Magicodes.IE.IO.CellValue.FromInteger((long){access})"; + } + if (u.SpecialType == SpecialType.System_DateTime) return $"global::Magicodes.IE.IO.CellValue.FromDateTime({access})"; + if (u.SpecialType == SpecialType.System_Single) return $"global::Magicodes.IE.IO.CellValue.FromNumber((double){access})"; + if (u.SpecialType == SpecialType.System_UInt64) return $"global::Magicodes.IE.IO.CellValue.FromNumber((double){access})"; + if (IsInt(u)) return $"global::Magicodes.IE.IO.CellValue.FromInteger((long){access})"; + return $"global::Magicodes.IE.IO.CellValue.FromString({access}?.ToString())"; + } + + private static string DirectWrite(IPropertySymbol p, string access, string styleExpr) + { + var u = Underlying(p.Type); + var nullable = IsNullableValueType(p.Type); + var sb = new StringBuilder(); + + if (nullable) + { + if (u.SpecialType == SpecialType.System_Int32 || u.SpecialType == SpecialType.System_Int64 || IsInt(u)) + { + sb.Append(" if (").Append(access).Append(".HasValue) w.WriteNumberCell(").Append(styleExpr).Append(", ").Append(access).Append(".Value); else w.WriteEmptyCell(").Append(styleExpr).Append(");\r\n"); + return sb.ToString(); + } + + if (u.SpecialType == SpecialType.System_Double) + { + sb.Append(" if (").Append(access).Append(".HasValue) w.WriteNumberCell(").Append(styleExpr).Append(", ").Append(access).Append(".Value); else w.WriteEmptyCell(").Append(styleExpr).Append(");\r\n"); + return sb.ToString(); + } + + if (u.SpecialType == SpecialType.System_Single) + { + sb.Append(" if (").Append(access).Append(".HasValue) w.WriteNumberCell(").Append(styleExpr).Append(", (double)").Append(access).Append(".Value); else w.WriteEmptyCell(").Append(styleExpr).Append(");\r\n"); + return sb.ToString(); + } + + if (u.SpecialType == SpecialType.System_Decimal) + { + sb.Append(" if (").Append(access).Append(".HasValue) w.WriteNumberCell(").Append(styleExpr).Append(", (double)").Append(access).Append(".Value); else w.WriteEmptyCell(").Append(styleExpr).Append(");\r\n"); + return sb.ToString(); + } + + if (u.SpecialType == SpecialType.System_Boolean) + { + sb.Append(" if (").Append(access).Append(".HasValue) w.WriteBoolCell(").Append(styleExpr).Append(", ").Append(access).Append(".Value); else w.WriteEmptyCell(").Append(styleExpr).Append(");\r\n"); + return sb.ToString(); + } + + if (u.SpecialType == SpecialType.System_DateTime) + { + sb.Append(" if (").Append(access).Append(".HasValue) w.WriteNumberCell(").Append(styleExpr).Append(", ").Append(access).Append(".Value.ToOADate()); else w.WriteEmptyCell(").Append(styleExpr).Append(");\r\n"); + return sb.ToString(); + } + + if (IsDateTimeOffset(u)) + { + sb.Append(" if (").Append(access).Append(".HasValue) w.WriteStringCell(").Append(styleExpr).Append(", ").Append(access).Append(".Value.ToString(\"O\", System.Globalization.CultureInfo.InvariantCulture)); else w.WriteEmptyCell(").Append(styleExpr).Append(");\r\n"); + return sb.ToString(); + } + + if (u.TypeKind == TypeKind.Enum) + { + var enumCast = IsUlongEnum(u) ? "(double)(ulong)" : "(long)"; + sb.Append(" if (").Append(access).Append(".HasValue) w.WriteNumberCell(").Append(styleExpr).Append(", ").Append(enumCast).Append(access).Append(".Value); else w.WriteEmptyCell(").Append(styleExpr).Append(");\r\n"); + return sb.ToString(); + } + + sb.Append(" if (").Append(access).Append(" is null) w.WriteEmptyCell(").Append(styleExpr).Append("); else w.WriteStringCell(").Append(styleExpr).Append(", ").Append(access).Append(".Value.ToString());\r\n"); + return sb.ToString(); + } + + if (u.SpecialType == SpecialType.System_String) + return $" w.WriteStringCell({styleExpr}, {access});\r\n"; + if (u.SpecialType == SpecialType.System_Int32 || u.SpecialType == SpecialType.System_Int64 || IsInt(u)) + return $" w.WriteNumberCell({styleExpr}, {access});\r\n"; + if (u.SpecialType == SpecialType.System_Double) + return $" w.WriteNumberCell({styleExpr}, {access});\r\n"; + if (u.SpecialType == SpecialType.System_Single) + return $" w.WriteNumberCell({styleExpr}, (double){access});\r\n"; + if (u.SpecialType == SpecialType.System_Decimal) + return $" w.WriteNumberCell({styleExpr}, (double){access});\r\n"; + if (u.SpecialType == SpecialType.System_Boolean) + return $" w.WriteBoolCell({styleExpr}, {access});\r\n"; + if (u.SpecialType == SpecialType.System_DateTime) + return $" w.WriteNumberCell({styleExpr}, {access}.ToOADate());\r\n"; + if (IsDateTimeOffset(u)) + return $" w.WriteStringCell({styleExpr}, {access}.ToString(\"O\", System.Globalization.CultureInfo.InvariantCulture));\r\n"; + if (u.TypeKind == TypeKind.Enum) + return IsUlongEnum(u) + ? $" w.WriteNumberCell({styleExpr}, (double)(ulong){access});\r\n" + : $" w.WriteNumberCell({styleExpr}, (long){access});\r\n"; + return $" w.WriteStringCell({styleExpr}, {access}?.ToString());\r\n"; + } + + private static ITypeSymbol Underlying(ITypeSymbol t) + { + if (t is INamedTypeSymbol n && n.ConstructedFrom?.ToString() == "System.Nullable" && n.TypeArguments.Length == 1) + return n.TypeArguments[0]; + return t; + } + + private static bool IsNullableValueType(ITypeSymbol t) => + t is INamedTypeSymbol n + && n.ConstructedFrom?.ToString() == "System.Nullable" + && n.TypeArguments.Length == 1; + + private static bool IsInt(ITypeSymbol t) + { + var s = t.SpecialType; + return s == SpecialType.System_Int16 || s == SpecialType.System_Byte || s == SpecialType.System_SByte + || s == SpecialType.System_UInt16 || s == SpecialType.System_UInt32 || s == SpecialType.System_UInt64; + } + + private static bool IsUlongEnum(ITypeSymbol t) + { + return t.TypeKind == TypeKind.Enum + && t is INamedTypeSymbol n + && n.EnumUnderlyingType?.SpecialType == SpecialType.System_UInt64; + } + + private static bool IsDateTimeOffset(ITypeSymbol t) + { + return t is INamedTypeSymbol n + && n.SpecialType == SpecialType.None + && n.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.DateTimeOffset"; + } + + private static string EscapeIdentifier(string name) + { + return Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(name) != Microsoft.CodeAnalysis.CSharp.SyntaxKind.None + || Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetContextualKeywordKind(name) != Microsoft.CodeAnalysis.CSharp.SyntaxKind.None + ? "@" + name + : name; + } + } +} diff --git a/src/Magicodes.IE.IO/AssemblyInfo.cs b/src/Magicodes.IE.IO/AssemblyInfo.cs new file mode 100644 index 00000000..dbb46ff2 --- /dev/null +++ b/src/Magicodes.IE.IO/AssemblyInfo.cs @@ -0,0 +1,5 @@ + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Magicodes.Benchmarks")] +[assembly: InternalsVisibleTo("Magicodes.IE.IO.Tests")] diff --git a/src/Magicodes.IE.IO/Attributes/ExporterHeaderAttribute.cs b/src/Magicodes.IE.IO/Attributes/ExporterHeaderAttribute.cs new file mode 100644 index 00000000..1aabf01d --- /dev/null +++ b/src/Magicodes.IE.IO/Attributes/ExporterHeaderAttribute.cs @@ -0,0 +1,37 @@ +using System; + +namespace Magicodes.IE.IO +{ + /// + /// Controls the header text, number format, and order of an exported column. + /// + /// Apply to a property of the exported model. When is not set, the property name is used as the header. + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class ExporterHeaderAttribute : Attribute + { + /// + /// Gets or sets the column header text shown when exporting. When omitted, the property name is used. + /// + public string? Name { get; set; } + + /// + /// Gets or sets a value indicating whether this column is excluded from the export. + /// + public bool IsIgnore { get; set; } + + /// + /// Gets or sets the Excel number format string, for example "0.00" or "yyyy-MM-dd". + /// + public string? Format { get; set; } + + /// + /// Gets or sets the column width. Values less than or equal to 0 leave the width unset. + /// + public double Width { get; set; } + + /// + /// Gets or sets the zero-based column order. The default is -1, which preserves the property declaration order. + /// + public int Index { get; set; } = -1; + } +} diff --git a/src/Magicodes.IE.IO/Attributes/ImporterHeaderAttribute.cs b/src/Magicodes.IE.IO/Attributes/ImporterHeaderAttribute.cs new file mode 100644 index 00000000..ec46ce48 --- /dev/null +++ b/src/Magicodes.IE.IO/Attributes/ImporterHeaderAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace Magicodes.IE.IO +{ + /// + /// Specifies the header name used to map a property when importing from a worksheet. + /// + /// Apply to a property of the imported model. When is not set, the property name is matched instead. + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class ImporterHeaderAttribute : Attribute + { + /// + /// Gets or sets the header name to match during import. When omitted, the property name is used. + /// + public string? Name { get; set; } + } +} diff --git a/src/Magicodes.IE.IO/Attributes/XlsxExportableAttribute.cs b/src/Magicodes.IE.IO/Attributes/XlsxExportableAttribute.cs new file mode 100644 index 00000000..2c93d5fc --- /dev/null +++ b/src/Magicodes.IE.IO/Attributes/XlsxExportableAttribute.cs @@ -0,0 +1,13 @@ +using System; + +namespace Magicodes.IE.IO +{ + /// + /// Enables source-generated metadata so the annotated type can be read from and written to .xlsx without reflection. + /// + /// Apply to a class or struct that you export or import. The generated reader and writer avoid reflection and reduce allocations. + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] + public sealed class XlsxExportableAttribute : Attribute + { + } +} diff --git a/src/Magicodes.IE.IO/CellConverter.cs b/src/Magicodes.IE.IO/CellConverter.cs new file mode 100644 index 00000000..4701c107 --- /dev/null +++ b/src/Magicodes.IE.IO/CellConverter.cs @@ -0,0 +1,42 @@ +using System; + +namespace Magicodes.IE.IO +{ + /// + /// Base class for cell converters. Most converters should derive from instead. + /// + public abstract class CellConverter + { + /// + /// Gets the this converter handles. + /// + public abstract Type Type { get; } + + internal virtual bool TryRead(string cell, out object? value) + { + value = null; + return false; + } + } + + /// + /// Converts the text of a cell into a value of type . + /// + /// The target type. + public abstract class CellConverter : CellConverter + { + /// + /// Attempts to parse into a . Returns if the value cannot be parsed. + /// + public abstract bool Read(string cell, out T value); + + internal sealed override bool TryRead(string cell, out object? value) + { + var result = Read(cell, out var typedValue); + value = typedValue; + return result; + } + + public sealed override Type Type => typeof(T); + } +} diff --git a/src/Magicodes.IE.IO/ColumnConfig.cs b/src/Magicodes.IE.IO/ColumnConfig.cs new file mode 100644 index 00000000..da7a31f0 --- /dev/null +++ b/src/Magicodes.IE.IO/ColumnConfig.cs @@ -0,0 +1,179 @@ +using System; +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + /// + /// Configuration for a single exported column, created by . The fluent With* methods return this instance so calls can be chained. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class ColumnConfig + { + /// + /// Gets or sets the column header text that overrides the inferred name. + /// + public string? Name { get; private set; } + /// + /// Gets or sets the Excel number format string. + /// + public string? Format { get; private set; } + /// + /// Gets or sets the column width. + /// + public double? Width { get; private set; } + /// + /// Gets or sets the zero-based column position. + /// + public int? Index { get; private set; } + /// + /// Gets or sets a value indicating whether the text is bold. + /// + public bool? Bold { get; private set; } + /// + /// Gets or sets a value indicating whether the text wraps within the cell. + /// + public bool? Wrap { get; private set; } + /// + /// Gets or sets a value indicating whether the column is hidden. + /// + public bool? Hidden { get; private set; } + /// + /// Gets or sets the font size in points. + /// + public float? FontSize { get; private set; } + /// + /// Gets or sets a value indicating whether content is horizontally centered. + /// + public bool? AutoCenter { get; private set; } + /// + /// Gets or sets the background color as an ARGB hex string. + /// + public string? BackgroundColor { get; private set; } + /// + /// Gets or sets the font color as an ARGB hex string. + /// + public string? FontColor { get; private set; } + /// + /// Gets or sets the font name. + /// + public string? FontName { get; private set; } + /// + /// Gets or sets the row height in points. + /// + public double? RowHeight { get; private set; } + /// + /// Gets or sets the border style. + /// + public BorderStyle? BorderStyle { get; private set; } + /// + /// Gets or sets the border color as an ARGB hex string. + /// + public string? BorderColor { get; private set; } + /// + /// Gets or sets a value indicating whether the text is italic. + /// + public bool? Italic { get; private set; } + /// + /// Gets or sets a value indicating whether the text is underlined. + /// + public bool? Underline { get; private set; } + /// + /// Gets or sets a value indicating whether the text has strikethrough. + /// + public bool? StrikeThrough { get; private set; } + /// + /// Gets or sets the vertical alignment. + /// + public VerticalAlignment? VerticalAlignment { get; private set; } + /// + /// Gets or sets the formula template, which may contain a {row} placeholder. + /// + public string? Formula { get; private set; } + + /// + /// Overrides the column header text. Returns this instance for chaining. + /// + public ColumnConfig WithName(string name) { Name = name; return this; } + /// + /// Sets the number format string. Returns this instance for chaining. + /// + public ColumnConfig WithFormat(string format) { Format = format; return this; } + /// + /// Sets the column width. Returns this instance for chaining. + /// + public ColumnConfig WithWidth(double width) { Width = width; return this; } + /// + /// Sets the zero-based column position. Returns this instance for chaining. + /// + public ColumnConfig WithIndex(int index) { Index = index; return this; } + /// + /// Sets the bold flag. Returns this instance for chaining. + /// + public ColumnConfig WithBold(bool value = true) { Bold = value; return this; } + /// + /// Sets the italic flag. Returns this instance for chaining. + /// + public ColumnConfig WithItalic(bool value = true) { Italic = value; return this; } + /// + /// Sets the underline flag. Returns this instance for chaining. + /// + public ColumnConfig WithUnderline(bool value = true) { Underline = value; return this; } + /// + /// Sets the strikethrough flag. Returns this instance for chaining. + /// + public ColumnConfig WithStrikeThrough(bool value = true) { StrikeThrough = value; return this; } + /// + /// Sets the text-wrap flag. Returns this instance for chaining. + /// + public ColumnConfig WithWrap(bool value = true) { Wrap = value; return this; } + /// + /// Sets the hidden flag. Returns this instance for chaining. + /// + public ColumnConfig WithHidden(bool value = true) { Hidden = value; return this; } + /// + /// Sets the font size. Returns this instance for chaining. + /// + public ColumnConfig WithFontSize(float size) { FontSize = size; return this; } + /// + /// Sets the horizontal-center flag. Returns this instance for chaining. + /// + public ColumnConfig WithAutoCenter(bool value = true) { AutoCenter = value; return this; } + /// + /// Sets the background color as an ARGB hex string. Returns this instance for chaining. + /// + public ColumnConfig WithBackgroundColor(string argb) { BackgroundColor = argb; return this; } + /// + /// Sets the font color as an ARGB hex string. Returns this instance for chaining. + /// + public ColumnConfig WithFontColor(string argb) { FontColor = argb; return this; } + /// + /// Sets the font name. Returns this instance for chaining. + /// + public ColumnConfig WithFontName(string name) { FontName = name; return this; } + /// + /// Sets the data row height. Returns this instance for chaining. + /// + public ColumnConfig WithRowHeight(double height) { RowHeight = height; return this; } + /// + /// Enables or disables a thin border, optionally with a color. Returns this instance for chaining. + /// + public ColumnConfig WithBorder(bool value = true, string? color = null) + { + BorderStyle = value ? Magicodes.IE.IO.BorderStyle.Thin : Magicodes.IE.IO.BorderStyle.None; + BorderColor = color; + return this; + } + /// + /// Sets the border style and color. Returns this instance for chaining. + /// + public ColumnConfig WithBorderStyle(BorderStyle style, string? color = null) { BorderStyle = style; BorderColor = color; return this; } + /// + /// Sets the vertical alignment. Returns this instance for chaining. + /// + public ColumnConfig WithVerticalAlignment(VerticalAlignment alignment) { VerticalAlignment = alignment; return this; } + /// + /// Sets the formula template, where the row number is represented by {row}. Returns this instance for chaining. + /// + public ColumnConfig WithFormula(string formulaTemplate) { Formula = formulaTemplate; return this; } + } +} diff --git a/src/Magicodes.IE.IO/ColumnMeta.cs b/src/Magicodes.IE.IO/ColumnMeta.cs new file mode 100644 index 00000000..520a5cf6 --- /dev/null +++ b/src/Magicodes.IE.IO/ColumnMeta.cs @@ -0,0 +1,134 @@ +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + /// + /// Resolved metadata for a single exported column, consumed by the lower-level write APIs. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class ColumnMeta + { + /// + /// Gets the name of the bound property. + /// + public string PropertyName { get; } + /// + /// Gets the text displayed in the header row. + /// + public string DisplayName { get; } + /// + /// Gets the Excel number format string, or if none. + /// + public string? Format { get; } + /// + /// Gets the column width, or to use the default. + /// + public double? Width { get; } + /// + /// Gets a value indicating whether the column is hidden. + /// + public bool Hidden { get; } + /// + /// Gets the resolved style index. + /// + public int StyleId { get; } + /// + /// Gets the zero-based column position. + /// + public int Index { get; } + /// + /// Gets a value indicating whether the text is bold, or for the default. + /// + public bool? Bold { get; } + /// + /// Gets a value indicating whether the text wraps within the cell, or for the default. + /// + public bool? Wrap { get; } + /// + /// Gets the font size in points, or for the default. + /// + public float? FontSize { get; } + /// + /// Gets a value indicating whether content is horizontally centered, or for the default. + /// + public bool? AutoCenter { get; } + /// + /// Gets the background color as an ARGB hex string, or for none. + /// + public string? BackgroundColor { get; } + /// + /// Gets the font color as an ARGB hex string, or for the default. + /// + public string? FontColor { get; } + /// + /// Gets the font name, or for the default. + /// + public string? FontName { get; } + /// + /// Gets the border style, or for none. + /// + public BorderStyle? BorderStyle { get; } + /// + /// Gets the border color as an ARGB hex string, or for none. + /// + public string? BorderColor { get; } + /// + /// Gets a value indicating whether the text is italic, or for the default. + /// + public bool? Italic { get; } + /// + /// Gets a value indicating whether the text is underlined, or for the default. + /// + public bool? Underline { get; } + /// + /// Gets a value indicating whether the text has strikethrough, or for the default. + /// + public bool? StrikeThrough { get; } + /// + /// Gets the vertical alignment, or for the default. + /// + public VerticalAlignment? VerticalAlignment { get; } + /// + /// Gets the row height in points, or for the default. + /// + public double? RowHeight { get; } + /// + /// Gets the formula template, which may contain a {row} placeholder. + /// + public string? Formula { get; } + + /// + /// Creates the resolved metadata for a single exported column. The trailing parameters are optional style overrides; omit them to accept the defaults. + /// + public ColumnMeta(string propertyName, string displayName, string? format, double? width, bool hidden, int styleId, int index, + bool? bold = null, bool? wrap = null, float? fontSize = null, bool? autoCenter = null, + string? backgroundColor = null, string? fontColor = null, string? fontName = null, + BorderStyle? borderStyle = null, string? borderColor = null, + bool? italic = null, bool? underline = null, bool? strikeThrough = null, + VerticalAlignment? verticalAlignment = null, double? rowHeight = null, string? formula = null) + { + PropertyName = propertyName; + DisplayName = displayName; + Format = format; + Width = width; + Hidden = hidden; + StyleId = styleId; + Index = index; + Bold = bold; + Wrap = wrap; + FontSize = fontSize; + AutoCenter = autoCenter; + BackgroundColor = backgroundColor; + FontColor = fontColor; + FontName = fontName; + BorderStyle = borderStyle; + BorderColor = borderColor; + Italic = italic; + Underline = underline; + StrikeThrough = strikeThrough; + VerticalAlignment = verticalAlignment; + RowHeight = rowHeight; + Formula = formula; + } + } +} diff --git a/src/Magicodes.IE.IO/Engine/SheetState.cs b/src/Magicodes.IE.IO/Engine/SheetState.cs new file mode 100644 index 00000000..d197e1e5 --- /dev/null +++ b/src/Magicodes.IE.IO/Engine/SheetState.cs @@ -0,0 +1,23 @@ + +using System.Collections.Generic; + +namespace Magicodes.IE.IO +{ + + internal sealed class SheetState + { + public List Images { get; } = new(); + public List MergeCells { get; } = new(); + public string? AutoFilter { get; set; } + public List<(string Ref, string Uri)> Hyperlinks { get; } = new(); + public List DataValidations { get; } = new(); + public List Tables { get; } = new(); + public SheetProtection? Protection { get; set; } + public PageSetup? PageSetup { get; set; } + public OutlineSettings? Outline { get; set; } + public List Comments { get; } = new(); + public List ConditionalFormattings { get; } = new(); + public int[]? StyleIds { get; set; } + public ColumnMeta[]? Columns { get; set; } + } +} diff --git a/src/Magicodes.IE.IO/Engine/XlsxReadPipeline.cs b/src/Magicodes.IE.IO/Engine/XlsxReadPipeline.cs new file mode 100644 index 00000000..13b07cbf --- /dev/null +++ b/src/Magicodes.IE.IO/Engine/XlsxReadPipeline.cs @@ -0,0 +1,132 @@ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Reflection; + +namespace Magicodes.IE.IO +{ + internal static class XlsxReadPipeline + { + + public static Func BuildResolver(string[] headers, XlsxReadOptions? profile) where T : new() + { + var type = typeof(T); + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + var headerMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in props) + { + if (!p.CanWrite) continue; + var importerAttr = p.GetCustomAttribute(inherit: true); + var displayAttr = p.GetCustomAttribute(inherit: true); + var descriptionAttr = p.GetCustomAttribute(inherit: true); + var name = importerAttr?.Name ?? displayAttr?.GetName() ?? descriptionAttr?.Description ?? p.Name; + if (!headerMap.ContainsKey(name)) headerMap[name] = p; + if (importerAttr?.Name is not null && !headerMap.ContainsKey(importerAttr.Name)) headerMap[importerAttr.Name] = p; + if (displayAttr?.GetName() is { } displayName && !headerMap.ContainsKey(displayName)) headerMap[displayName] = p; + if (descriptionAttr?.Description is { } description && !headerMap.ContainsKey(description)) headerMap[description] = p; + } + + // An explicit column/header mapping wins over the names discovered from attributes. + if (profile is not null) + { + return i => + { + var header = i < headers.Length ? headers[i] : null; + var configured = profile.Resolve(i, header); + if (configured is not null) + return headerMap.TryGetValue(configured, out var configuredProperty) ? configuredProperty : null; + if (headerMap.TryGetValue(header ?? "", out var p)) return p; + return null; + }; + } + + return i => i < headers.Length && headerMap.TryGetValue(headers[i] ?? "", out var p) ? p : null; + } + + public static Func?> BuildGeneratedResolver( + string[] headers, + XlsxReadOptions? profile, + IReadOnlyList> metadata) + where T : new() + { + var byName = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var byHeader = new Dictionary>(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < metadata.Count; i++) + { + var property = metadata[i]; + if (property.Setter is null) continue; + byName[property.Name] = property; + if (!byHeader.ContainsKey(property.Name)) byHeader[property.Name] = property; + if (property.ImportHeader is not null && !byHeader.ContainsKey(property.ImportHeader)) + byHeader[property.ImportHeader] = property; + if (property.DisplayName is not null && !byHeader.ContainsKey(property.DisplayName)) + byHeader[property.DisplayName] = property; + if (property.Description is not null && !byHeader.ContainsKey(property.Description)) + byHeader[property.Description] = property; + } + + return i => + { + var header = i < headers.Length ? headers[i] : null; + var configured = profile?.Resolve(i, header); + if (configured is not null) + return byName.TryGetValue(configured, out var configuredProperty) ? configuredProperty : null; + return byHeader.TryGetValue(header ?? string.Empty, out var property) ? property : null; + }; + } + + public static void SetCellProperty(object item, PropertyInfo prop, string cell, Action? onParseError, int rowIndex, int colIndex, string? header, IReadOnlyList? converters = null, bool date1904 = false) + { + var targetType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; + + if (CellValueParser.TryParse(cell, targetType, out var value, converters, date1904)) + { + if (value is not null) + prop.SetValue(item, value); + } + else if (onParseError is not null) + { + onParseError(new XlsxReadErrorInfo + { + RowIndex = rowIndex, + ColIndex = colIndex, + Header = header, + PropertyName = prop.Name, + RawCellValue = cell, + TargetTypeName = targetType.Name, + Exception = new FormatException($"Cannot convert '{cell}' to {targetType.Name}"), + }); + } + } + + public static void SetGeneratedProperty( + T item, + XlsxGeneratedPropertyMetadata property, + string cell, + Action? onParseError, + int rowIndex, + int colIndex, + string? header, + IReadOnlyList? converters = null, + bool date1904 = false) + where T : new() + { + if (property.Setter is null || property.Setter(item, cell, converters, date1904)) + return; + + onParseError?.Invoke(new XlsxReadErrorInfo + { + RowIndex = rowIndex, + ColIndex = colIndex, + Header = header, + PropertyName = property.Name, + RawCellValue = cell, + TargetTypeName = property.TargetTypeName, + Exception = new FormatException($"Cannot convert '{cell}' to {property.TargetTypeName}"), + }); + } + } +} \ No newline at end of file diff --git a/src/Magicodes.IE.IO/Engine/XlsxReader.cs b/src/Magicodes.IE.IO/Engine/XlsxReader.cs new file mode 100644 index 00000000..97bf7b83 --- /dev/null +++ b/src/Magicodes.IE.IO/Engine/XlsxReader.cs @@ -0,0 +1,656 @@ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; + +namespace Magicodes.IE.IO +{ + + /// + /// Advanced streaming reader for xlsx workbooks. + /// For ordinary reads, use . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class XlsxReader : IDisposable + { + private readonly ZipArchive _zip; + private readonly Stream _sheetStream; + private readonly XmlReader _xml; + private readonly List _currentRow = new(16); + private readonly bool _date1904; + private bool _headerRead; + private string[] _headers = Array.Empty(); + private string[] _sharedStrings = Array.Empty(); + + /// + /// Opens a .xlsx workbook positioned at the first readable worksheet. + /// + public XlsxReader(Stream xlsxStream) + { + if (xlsxStream is null) throw new ArgumentNullException(nameof(xlsxStream)); + var zip = new ZipArchive(xlsxStream, ZipArchiveMode.Read, leaveOpen: true); + Stream? sheetStream = null; + XmlReader? xml = null; + try + { + _date1904 = ReadWorkbookDate1904(zip); + var entry = ResolveWorksheetEntry(zip) + ?? throw new InvalidDataException("No readable worksheet found in xlsx."); + sheetStream = entry.Open(); + var safeSettings = new XmlReaderSettings + { + IgnoreWhitespace = true, + Async = true, + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + }; + xml = XmlReader.Create(sheetStream, safeSettings); + _zip = zip; + _sheetStream = sheetStream; + _xml = xml; + + LoadSharedStrings(); + } + catch + { + xml?.Dispose(); + sheetStream?.Dispose(); + zip.Dispose(); + throw; + } + } + + internal bool Date1904 => _date1904; + + private static bool ReadWorkbookDate1904(ZipArchive zip) + { + var workbookEntry = zip.GetEntry("xl/workbook.xml"); + if (workbookEntry is null) return false; + + using var stream = workbookEntry.Open(); + using var reader = XmlReader.Create(stream, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + IgnoreComments = true, + IgnoreWhitespace = true, + XmlResolver = null, + }); + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element || reader.LocalName != "workbookPr") continue; + var value = reader.GetAttribute("date1904"); + return value == "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + return false; + } + + private ZipArchiveEntry? ResolveWorksheetEntry(ZipArchive zip) + { + var fromWorkbook = ResolveFirstWorksheetEntryFromWorkbook(zip); + if (fromWorkbook is not null) return fromWorkbook; + + return zip.Entries.FirstOrDefault(static e => + e.FullName.StartsWith("xl/worksheets/", StringComparison.OrdinalIgnoreCase) + && e.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) + && e.FullName.IndexOf("/_rels/", StringComparison.OrdinalIgnoreCase) < 0); + } + + private static ZipArchiveEntry? ResolveFirstWorksheetEntryFromWorkbook(ZipArchive zip) + { + var workbookEntry = zip.GetEntry("xl/workbook.xml"); + var workbookRelsEntry = zip.GetEntry("xl/_rels/workbook.xml.rels"); + if (workbookEntry is null || workbookRelsEntry is null) return null; + + var relTargets = LoadWorkbookRelationshipTargets(workbookRelsEntry); + if (relTargets.Count == 0) return null; + + using var es = workbookEntry.Open(); + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + IgnoreComments = true, + IgnoreWhitespace = true, + XmlResolver = null, + }; + using var xr = XmlReader.Create(es, settings); + while (xr.Read()) + { + if (xr.NodeType != XmlNodeType.Element || xr.LocalName != "sheet") continue; + var rid = xr.GetAttribute("id", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"); + if (string.IsNullOrEmpty(rid)) continue; + if (!relTargets.TryGetValue(rid, out var target)) continue; + var normalized = NormalizeWorkbookTarget(target); + if (normalized is null) continue; + if (!normalized.StartsWith("xl/worksheets/", StringComparison.OrdinalIgnoreCase)) continue; + var entry = zip.GetEntry(normalized); + if (entry is not null) return entry; + } + + return null; + } + + private static Dictionary LoadWorkbookRelationshipTargets(ZipArchiveEntry workbookRelsEntry) + { + using var es = workbookRelsEntry.Open(); + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + IgnoreComments = true, + IgnoreWhitespace = true, + XmlResolver = null, + }; + using var xr = XmlReader.Create(es, settings); + var map = new Dictionary(StringComparer.Ordinal); + while (xr.Read()) + { + if (xr.NodeType != XmlNodeType.Element || xr.LocalName != "Relationship") continue; + var id = xr.GetAttribute("Id"); + var target = xr.GetAttribute("Target"); + var mode = xr.GetAttribute("TargetMode"); + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(target)) continue; + if (string.Equals(mode, "External", StringComparison.OrdinalIgnoreCase)) continue; + map[id] = target; + } + return map; + } + + private static string? NormalizeWorkbookTarget(string? target) + { + if (string.IsNullOrWhiteSpace(target)) return null; + target = target!.Replace('\\', '/'); + if (target.StartsWith("/", StringComparison.Ordinal)) + return target.TrimStart('/'); + if (target.StartsWith("xl/", StringComparison.OrdinalIgnoreCase)) + return target; + return "xl/" + target.TrimStart('/'); + } + + private void LoadSharedStrings() + { + var entry = _zip.GetEntry("xl/sharedStrings.xml"); + if (entry is null) return; + + using var es = entry.Open(); + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, IgnoreComments = true, IgnoreWhitespace = false }; + using var xr = XmlReader.Create(es, settings); + if (xr.ReadToFollowing("sst")) xr.Read(); + var list = new List(); + do + { + if (xr.NodeType != XmlNodeType.Element || xr.LocalName != "si") continue; + list.Add(ReadSharedStringItem(xr)); + } + while (xr.Read()); + _sharedStrings = list.ToArray(); + } + + private static string ReadSharedStringItem(XmlReader xr) + { + if (xr.IsEmptyElement) return string.Empty; + + StringBuilder? sb = null; + int depth = xr.Depth; + while (xr.Read()) + { + if (xr.NodeType == XmlNodeType.EndElement && xr.LocalName == "si" && xr.Depth == depth) + break; + if (xr.NodeType != XmlNodeType.Element) continue; + if (xr.LocalName == "rPh") + { + int rPhDepth = xr.Depth; + if (!xr.IsEmptyElement) + while (xr.Read() && !(xr.NodeType == XmlNodeType.EndElement && xr.LocalName == "rPh" && xr.Depth == rPhDepth)) { } + continue; + } + if (xr.LocalName != "t") continue; + sb ??= new StringBuilder(); + sb.Append(xr.ReadElementContentAsString()); + if (xr.NodeType == XmlNodeType.EndElement && xr.LocalName == "si" && xr.Depth == depth) + break; + } + return sb?.ToString() ?? string.Empty; + } + + /// + /// Reads the header row. Subsequent calls return the same result. + /// + public string[] ReadHeader() + { + if (_headerRead) return _headers; + while (_xml.Read()) + { + if (_xml.NodeType != XmlNodeType.Element || _xml.LocalName != "row") continue; + ReadRowCells(_currentRow); + _headers = BuildHeaders(_currentRow); + if (_headers.Length > _currentRow.Capacity) + _currentRow.Capacity = _headers.Length; + _currentRow.Clear(); + _headerRead = true; + return _headers; + } + _headerRead = true; + return Array.Empty(); + } + + /// + /// Asynchronously reads the header row. Subsequent calls return the same result. + /// + public async Task ReadHeaderAsync(CancellationToken cancellationToken = default) + { + if (_headerRead) return _headers; + while (await _xml.ReadAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_xml.NodeType != XmlNodeType.Element || _xml.LocalName != "row") continue; + await ReadRowCellsAsync(_currentRow, cancellationToken).ConfigureAwait(false); + _headers = BuildHeaders(_currentRow); + if (_headers.Length > _currentRow.Capacity) + _currentRow.Capacity = _headers.Length; + _currentRow.Clear(); + _headerRead = true; + return _headers; + } + _headerRead = true; + return Array.Empty(); + } + + /// + /// Reads the next row into a standalone list, or when the sheet is exhausted. + /// + public IReadOnlyList? ReadNextRow() + { + if (!_headerRead) ReadHeader(); + var row = ReadNextRowView(); + if (row is null) return null; + + var copy = new List(row.Count); + copy.AddRange(row); + return copy; + } + + /// + /// Reads the next row as a reused view; its contents are overwritten on the next read. + /// + public IReadOnlyList? ReadNextRowView() + { + if (!_headerRead) ReadHeader(); + // Reuse the row buffer on purpose. Callers that need to retain it should use ReadNextRow. + while (_xml.Read()) + { + if (_xml.NodeType != XmlNodeType.Element || _xml.LocalName != "row") continue; + ReadRowCells(_currentRow); + return _currentRow; + } + return null; + } + + /// + /// Asynchronously reads the next row as a reused view; its contents are overwritten on the next read. + /// + public async ValueTask?> ReadNextRowViewAsync(CancellationToken cancellationToken = default) + { + if (!_headerRead) await ReadHeaderAsync(cancellationToken).ConfigureAwait(false); + while (await _xml.ReadAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_xml.NodeType != XmlNodeType.Element || _xml.LocalName != "row") continue; + await ReadRowCellsAsync(_currentRow, cancellationToken).ConfigureAwait(false); + return _currentRow; + } + return null; + } + + private enum CellKind : byte + { + Other = 0, + Boolean = 1, + SharedString = 2, + FormulaString = 3, + Date = 4, + } + + private void ReadRowCells(List buf) + { + buf.Clear(); + if (_xml.IsEmptyElement) return; + CellKind currentType = CellKind.Other; + int colRef = 0; + int currentCol = 0; + while (_xml.Read()) + { + if (_xml.NodeType == XmlNodeType.EndElement && _xml.LocalName == "row") break; + if (_xml.NodeType == XmlNodeType.Element && _xml.LocalName == "c") + { + var rAttr = _xml.GetAttribute("r"); + int thisCol = rAttr is not null ? CellRefToCol(rAttr) : -1; + if (thisCol < 0) { thisCol = colRef; colRef++; } + else colRef = thisCol + 1; + while (buf.Count <= thisCol) buf.Add(null); + currentCol = thisCol; + currentType = _xml.GetAttribute("t") switch + { + "b" => CellKind.Boolean, + "s" => CellKind.SharedString, + "str" => CellKind.FormulaString, + "d" => CellKind.Date, + _ => CellKind.Other, + }; + continue; + } + if (_xml.NodeType == XmlNodeType.Element && _xml.LocalName == "is") + { + string text = ReadInlineStringValue(); + buf[currentCol] = text; + continue; + } + if (_xml.NodeType == XmlNodeType.Element && _xml.LocalName == "v") + { + string text = _xml.ReadElementContentAsString(); + ApplyCellValue(buf, currentCol, currentType, text); + continue; + } + } + } + + private async ValueTask ReadRowCellsAsync(List buf, CancellationToken cancellationToken) + { + buf.Clear(); + if (_xml.IsEmptyElement) return; + CellKind currentType = CellKind.Other; + int colRef = 0; + int currentCol = 0; + while (await _xml.ReadAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_xml.NodeType == XmlNodeType.EndElement && _xml.LocalName == "row") break; + if (_xml.NodeType == XmlNodeType.Element && _xml.LocalName == "c") + { + var rAttr = _xml.GetAttribute("r"); + int thisCol = rAttr is not null ? CellRefToCol(rAttr) : -1; + if (thisCol < 0) { thisCol = colRef; colRef++; } + else colRef = thisCol + 1; + while (buf.Count <= thisCol) buf.Add(null); + currentCol = thisCol; + currentType = _xml.GetAttribute("t") switch + { + "b" => CellKind.Boolean, + "s" => CellKind.SharedString, + "str" => CellKind.FormulaString, + "d" => CellKind.Date, + _ => CellKind.Other, + }; + continue; + } + if (_xml.NodeType == XmlNodeType.Element && _xml.LocalName == "is") + { + string text = await ReadInlineStringValueAsync(cancellationToken).ConfigureAwait(false); + buf[currentCol] = text; + continue; + } + if (_xml.NodeType == XmlNodeType.Element && _xml.LocalName == "v") + { + string text = await _xml.ReadElementContentAsStringAsync().ConfigureAwait(false); + ApplyCellValue(buf, currentCol, currentType, text); + continue; + } + } + } + + private void ApplyCellValue(List buf, int currentCol, CellKind currentType, string text) + { + switch (currentType) + { + case CellKind.Boolean: + buf[currentCol] = text switch + { + "1" => "true", + "0" => "false", + _ => text, + }; + break; + case CellKind.SharedString: + if (int.TryParse(text, out var idx) && idx >= 0 && idx < _sharedStrings.Length) + buf[currentCol] = _sharedStrings[idx]; + else + buf[currentCol] = null; + break; + case CellKind.FormulaString: + buf[currentCol] = text; + break; + case CellKind.Date: + buf[currentCol] = text; + break; + default: + buf[currentCol] = text; + break; + } + } + + private async ValueTask ReadInlineStringValueAsync(CancellationToken cancellationToken) + { + if (_xml.IsEmptyElement) return string.Empty; + + StringBuilder? sb = null; + string? single = null; + int depth = _xml.Depth; + while (await _xml.ReadAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_xml.NodeType == XmlNodeType.EndElement && _xml.LocalName == "is" && _xml.Depth == depth) + break; + if (_xml.NodeType != XmlNodeType.Element) continue; + if (_xml.LocalName == "rPh") + { + int rPhDepth = _xml.Depth; + if (!_xml.IsEmptyElement) + while (await _xml.ReadAsync().ConfigureAwait(false) && !(_xml.NodeType == XmlNodeType.EndElement && _xml.LocalName == "rPh" && _xml.Depth == rPhDepth)) + { + cancellationToken.ThrowIfCancellationRequested(); + } + continue; + } + if (_xml.LocalName != "t") continue; + var text = await _xml.ReadElementContentAsStringAsync().ConfigureAwait(false); + if (sb is not null) + sb.Append(text); + else if (single is null) + single = text; + else + sb = new StringBuilder(single).Append(text); + if (_xml.NodeType == XmlNodeType.EndElement && _xml.LocalName == "is" && _xml.Depth == depth) + break; + } + if (sb is not null) return sb.ToString(); + return single ?? string.Empty; + } + + private static string[] BuildHeaders(List row) + { + int count = row.Count; + var headers = new string[count]; + for (int i = 0; i < count; i++) + headers[i] = row[i] ?? string.Empty; + return headers; + } + + private string ReadInlineStringValue() + { + if (_xml.IsEmptyElement) return string.Empty; + + // Common case is a single child; capture it directly without allocating a + // StringBuilder. Only build one if a second appears (rich text runs). + string? single = null; + StringBuilder? sb = null; + int depth = _xml.Depth; + while (_xml.Read()) + { + if (_xml.NodeType == XmlNodeType.EndElement && _xml.LocalName == "is" && _xml.Depth == depth) + break; + if (_xml.NodeType != XmlNodeType.Element) continue; + if (_xml.LocalName == "rPh") + { + int rPhDepth = _xml.Depth; + if (!_xml.IsEmptyElement) + while (_xml.Read() && !(_xml.NodeType == XmlNodeType.EndElement && _xml.LocalName == "rPh" && _xml.Depth == rPhDepth)) { } + continue; + } + if (_xml.LocalName != "t") continue; + var text = _xml.ReadElementContentAsString(); + if (sb is not null) + sb.Append(text); + else if (single is null) + single = text; + else + sb = new StringBuilder(single).Append(text); + if (_xml.NodeType == XmlNodeType.EndElement && _xml.LocalName == "is" && _xml.Depth == depth) + break; + } + if (sb is not null) return sb.ToString(); + return single ?? string.Empty; + } + + private static int CellRefToCol(string cellRef) => CellRefHelper.CellRefToCol(cellRef); + + public void Dispose() + { + try { _xml.Dispose(); } finally { try { _sheetStream.Dispose(); } finally { _zip.Dispose(); } } + } + + } + + /// + /// Configures how headers and columns map to properties of the target type . + /// + /// The row model type being read. + public sealed class XlsxReadOptions + { + private readonly Dictionary _headerToProp = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _colIndexToProp = new(); + private readonly List _converters = new(); + + /// + /// Gets the custom cell converters registered for this read. + /// + public IList Converters => _converters; + + /// + /// Maps a column, by zero-based index, to a property. Returns this instance for chaining. + /// + public XlsxReadOptions MapColumn(int colIndex, string? propertyName) + { + if (colIndex < 0) throw new ArgumentOutOfRangeException(nameof(colIndex)); + if (propertyName is null) return this; + _colIndexToProp[colIndex] = ResolvePropertyName(propertyName, nameof(propertyName)); + return this; + } + + /// + /// Maps a header name to a property. Returns this instance for chaining. + /// + public XlsxReadOptions MapHeader(string header, string? propertyName) + { + if (header is null) throw new ArgumentNullException(nameof(header)); + if (header.Length == 0) throw new ArgumentException("Header cannot be empty.", nameof(header)); + if (propertyName is null) return this; + _headerToProp[header] = ResolvePropertyName(propertyName, nameof(propertyName)); + return this; + } + + /// + /// Adds a custom cell converter. Returns this instance for chaining. + /// + public XlsxReadOptions WithConverter(CellConverter converter) + { + if (converter is null) throw new ArgumentNullException(nameof(converter)); + _converters.Add(converter); + return this; + } + + internal IReadOnlyList? GetConverters() => _converters.Count > 0 ? _converters : null; + + internal string? Resolve(int colIndex, string? header) + { + if (_colIndexToProp.TryGetValue(colIndex, out var p)) return p; + if (header is not null && _headerToProp.TryGetValue(header, out p)) return p; + return null; + } + + private static string ResolvePropertyName(string propertyName, string parameterName) + { + var generated = XlsxGeneratedTypeMetadataRegistry.TryGet(); + if (generated is not null) + { + for (int i = 0; i < generated.Count; i++) + { + var property = generated[i]; + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + if (property.Setter is null) + throw new ArgumentException($"Property '{property.Name}' must be writable.", parameterName); + return property.Name; + } + } + } + else + { + var prop = typeof(T).GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (prop is not null) + { + if (!prop.CanWrite) throw new ArgumentException($"Property '{prop.Name}' must be writable.", parameterName); + return prop.Name; + } + } + + throw new ArgumentException($"Property '{propertyName}' was not found on {typeof(T).Name}.", parameterName); + } + } + + /// + /// Context passed to the error callback when a cell cannot be parsed. + /// + public sealed class XlsxReadErrorInfo + { + + /// + /// Gets the zero-based data row index, excluding the header row. + /// + public int RowIndex { get; init; } + + /// + /// Gets the zero-based column index. + /// + public int ColIndex { get; init; } + /// + /// Gets the matched header text. + /// + public string? Header { get; init; } + /// + /// Gets the target property name. + /// + public string? PropertyName { get; init; } + /// + /// Gets the raw cell text. + /// + public string? RawCellValue { get; init; } + /// + /// Gets the name of the target type. + /// + public string? TargetTypeName { get; init; } + /// + /// Gets the exception that caused the parse failure. + /// + public Exception? Exception { get; init; } + } +} diff --git a/src/Magicodes.IE.IO/Engine/XlsxTemplateExporter.cs b/src/Magicodes.IE.IO/Engine/XlsxTemplateExporter.cs new file mode 100644 index 00000000..5202a82f --- /dev/null +++ b/src/Magicodes.IE.IO/Engine/XlsxTemplateExporter.cs @@ -0,0 +1,276 @@ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Magicodes.IE.IO +{ + internal static class XlsxTemplateExporter + { + private static readonly Regex TemplateSingleVarRegex = new(@"\{\{([^!#/][^}]*?)\}\}", RegexOptions.Compiled); + private static readonly Regex TemplateSheetNameRegex = new(@"\{\{!Sheet:Name=([^}]+)\}\}", RegexOptions.Compiled); + private static readonly Regex TemplateListBlockRegex = new(@"\{\{#([A-Za-z_][A-Za-z0-9_]*)\}\}(.*?)\{\{/\1\}\}", RegexOptions.Compiled | RegexOptions.Singleline); + private static readonly Regex RowNumberAttributeRegex = new(@"(]*\br="")(\d+)("")", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex A1ReferenceRegex = new(@"(?]*>)(.*?)()", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + + /// + /// Replaces template placeholders and writes the output to a new .xlsx file. + /// + public static async Task ExportAsync(string templatePath, string outputPath, T data, CancellationToken cancellationToken = default) + { + if (templatePath is null) throw new ArgumentNullException(nameof(templatePath)); + if (outputPath is null) throw new ArgumentNullException(nameof(outputPath)); + using var inStream = File.OpenRead(templatePath); + using var outStream = File.Create(outputPath); + await ExportAsync(inStream, outStream, data, cancellationToken).ConfigureAwait(false); + } + + /// + /// Replaces template placeholders in a source stream and writes to the output stream. + /// + public static async Task ExportAsync(Stream templateStream, Stream outputStream, T data, CancellationToken cancellationToken = default) + { + if (templateStream is null) throw new ArgumentNullException(nameof(templateStream)); + if (outputStream is null) throw new ArgumentNullException(nameof(outputStream)); + cancellationToken.ThrowIfCancellationRequested(); + + var readableTemplate = await EnsureSeekableAsync(templateStream, cancellationToken).ConfigureAwait(false); + try + { + var overrides = new Dictionary(StringComparer.Ordinal); + using (var inZip = new ZipArchive(readableTemplate, ZipArchiveMode.Read, leaveOpen: true)) + { + foreach (var entry in inZip.Entries) + { + // Only worksheet, shared-string, and workbook XML can contain template values. + if (IsTextEntry(entry.FullName) && NeedsProcessing(entry.FullName)) + { + string xml = await ReadEntryTextAsync(entry, cancellationToken).ConfigureAwait(false); + string processed = ProcessTemplateEntry(entry.FullName, xml, data); + + if (processed != xml) + overrides[entry.FullName] = Encoding.UTF8.GetBytes(processed); + } + } + } + + readableTemplate.Position = 0; + using (var outZip = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true)) + using (var inZip = new ZipArchive(readableTemplate, ZipArchiveMode.Read, leaveOpen: true)) + { + foreach (var inEntry in inZip.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var outEntry = outZip.CreateEntry(inEntry.FullName); + using var src = inEntry.Open(); + using var dst = outEntry.Open(); + if (overrides.TryGetValue(inEntry.FullName, out var replace)) + { + await dst.WriteAsync(replace, 0, replace.Length, cancellationToken).ConfigureAwait(false); + } + else + { + await src.CopyToAsync(dst, 81920, cancellationToken).ConfigureAwait(false); + } + } + } + } + finally + { + if (!ReferenceEquals(readableTemplate, templateStream)) + readableTemplate.Dispose(); + } + } + + private static async Task EnsureSeekableAsync(Stream templateStream, CancellationToken cancellationToken) + { + if (!templateStream.CanRead) throw new ArgumentException("templateStream must be readable.", nameof(templateStream)); + if (templateStream.CanSeek) + { + templateStream.Position = 0; + return templateStream; + } + + var buffer = new MemoryStream(); + await templateStream.CopyToAsync(buffer, 81920, cancellationToken).ConfigureAwait(false); + buffer.Position = 0; + return buffer; + } + + private static bool IsTextEntry(string name) => + name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".rels", StringComparison.OrdinalIgnoreCase); + + private static bool NeedsProcessing(string name) => + name.StartsWith("xl/worksheets/", StringComparison.OrdinalIgnoreCase) + || name.Equals("xl/sharedStrings.xml", StringComparison.OrdinalIgnoreCase) + || name.Equals("xl/workbook.xml", StringComparison.OrdinalIgnoreCase); + + private static string ProcessTemplateEntry(string name, string xml, T data) + { + var processed = ProcessTemplateSheet(xml, data); + if (name.Equals("xl/workbook.xml", StringComparison.OrdinalIgnoreCase)) + processed = TemplateSheetNameRegex.Replace(processed, m => XmlHelper.EscapeXmlAttr(m.Groups[1].Value)); + return processed; + } + + private static string ProcessTemplateSheet(string sheetXml, T data) + { + var matches = TemplateListBlockRegex.Matches(sheetXml); + for (int i = matches.Count - 1; i >= 0; i--) + { + var match = matches[i]; + string listName = match.Groups[1].Value; + string innerTemplate = match.Groups[2].Value; + var prop = typeof(T).GetProperty(listName); + var rows = GetRowBounds(innerTemplate); + int rowSpan = rows.HasValue ? Math.Max(1, rows.Value.Max - rows.Value.Min + 1) : 0; + int itemCount = 0; + var replacement = new StringBuilder(); + + if (prop?.GetValue(data) is IEnumerable list) + { + int insertionRow = GetMaxRowNumber(sheetXml.Substring(0, match.Index)) + 1; + foreach (var item in list) + { + string itemXml = TemplateSingleVarRegex.Replace(innerTemplate, mm => ReplaceTemplateVar(mm, item, innerTemplate)); + if (rows.HasValue) + itemXml = ShiftRowReferences(itemXml, insertionRow + itemCount * rowSpan - rows.Value.Min); + replacement.Append(itemXml); + itemCount++; + } + } + + int rowDelta = rowSpan == 0 ? 0 : (itemCount - 1) * rowSpan; + string prefix = sheetXml.Substring(0, match.Index); + string suffix = sheetXml.Substring(match.Index + match.Length); + if (rowDelta != 0) suffix = ShiftRowReferences(suffix, rowDelta); + sheetXml = prefix + replacement + suffix; + } + + sheetXml = TemplateSingleVarRegex.Replace(sheetXml, m => ReplaceTemplateVar(m, data, sheetXml)); + + return sheetXml; + } + + private static (int Min, int Max)? GetRowBounds(string xml) + { + var matches = RowNumberAttributeRegex.Matches(xml); + if (matches.Count == 0) return null; + + int min = int.MaxValue; + int max = 0; + foreach (Match match in matches) + { + int row = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + min = Math.Min(min, row); + max = Math.Max(max, row); + } + return (min, max); + } + + private static int GetMaxRowNumber(string xml) + { + var bounds = GetRowBounds(xml); + return bounds?.Max ?? 0; + } + + private static string ShiftRowReferences(string xml, int delta) + { + if (delta == 0 || xml.Length == 0) return xml; + + xml = RowNumberAttributeRegex.Replace(xml, match => + match.Groups[1].Value + + (int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture) + delta).ToString(CultureInfo.InvariantCulture) + + match.Groups[3].Value); + xml = ReferenceAttributeRegex.Replace(xml, match => + match.Value.Substring(0, match.Groups[1].Index - match.Index) + + ShiftA1References(match.Groups[1].Value, delta) + + match.Value.Substring(match.Groups[1].Index - match.Index + match.Groups[1].Length)); + return FormulaRegex.Replace(xml, match => + match.Groups[1].Value + + ShiftFormulaReferences(match.Groups[2].Value, delta) + + match.Groups[3].Value); + } + + private static string ShiftA1References(string value, int delta) => + A1ReferenceRegex.Replace(value, match => + match.Groups[1].Value + + (int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture) + delta).ToString(CultureInfo.InvariantCulture)); + + private static string ShiftFormulaReferences(string formula, int delta) + { + var result = new StringBuilder(formula.Length); + int start = 0; + for (int i = 0; i < formula.Length; i++) + { + if (formula[i] != '"') continue; + if (i > start) + result.Append(ShiftA1References(formula.Substring(start, i - start), delta)); + + int end = ++i; + while (end < formula.Length) + { + if (formula[end] != '"') + { + end++; + continue; + } + if (end + 1 < formula.Length && formula[end + 1] == '"') + { + end += 2; + continue; + } + break; + } + + if (end >= formula.Length) + { + result.Append(formula.Substring(i - 1)); + return result.ToString(); + } + + result.Append(formula.Substring(i - 1, end - i + 2)); + i = end; + start = end + 1; + } + + if (start < formula.Length) + result.Append(ShiftA1References(formula.Substring(start), delta)); + return result.ToString(); + } + + private static string ReplaceTemplateVar(Match m, object? item, string source) + { + if (item is null) return ""; + string name = m.Groups[1].Value.Trim(); + var prop = item.GetType().GetProperty(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (prop is null) return m.Value; + var value = prop.GetValue(item); + if (value is null) return ""; + string text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + int lastOpen = source.LastIndexOf('<', m.Index); + int lastClose = source.LastIndexOf('>', m.Index); + return lastOpen > lastClose + ? XmlHelper.EscapeXmlAttr(text) + : XmlHelper.EscapeXmlText(text); + } + + private static async Task ReadEntryTextAsync(ZipArchiveEntry entry, CancellationToken cancellationToken) + { + using var es = entry.Open(); + using var sr = new StreamReader(es, Encoding.UTF8); + cancellationToken.ThrowIfCancellationRequested(); + return await sr.ReadToEndAsync().ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/Magicodes.IE.IO/Engine/XlsxWritePipeline.cs b/src/Magicodes.IE.IO/Engine/XlsxWritePipeline.cs new file mode 100644 index 00000000..adca0b22 --- /dev/null +++ b/src/Magicodes.IE.IO/Engine/XlsxWritePipeline.cs @@ -0,0 +1,422 @@ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Magicodes.IE.IO +{ + internal static class XlsxWritePipeline + { + + public static void Run(Stream output, IEnumerable data, Action>? configure, XlsxWriteOptions? options) + { + var profile = new ExportProfile(); + configure?.Invoke(profile); + Run(output, data, profile, options); + } + + public static void Run(Stream output, IEnumerable data, ExportProfile profile, XlsxWriteOptions? options) + { + var compression = options?.Compression ?? CompressionLevel.Fastest; + var strictCellReferences = options?.StrictCellReferences ?? true; + + profile.Freeze(); + + var plan = RowPlanBuilder.BuildTyped(profile); + var actualSheetName = profile.SheetName ?? typeof(T).Name; + + IEnumerable filtered = profile.RowFilter is null ? data : FilterRows(data, profile.RowFilter); + + bool autoSst = profile.AutoSst; + // SST needs a short probe before the writer is created; after that the sequence is + // replayed so the probe does not consume rows from the actual export. + if (autoSst && compression != CompressionLevel.NoCompression) + filtered = AutoSstBufferAndDetect(filtered, plan, out autoSst); + else if (autoSst) + autoSst = false; + + var numFmts = plan.BuildNumFmts(); + + using var writer = new XlsxWriter(output, actualSheetName, compression, defaultRowHeight: 0, strictCellReferences); + if (profile.DefaultRowHeight is double dh) + writer.SetDefaultRowHeight(dh); + if (autoSst) writer.EnableSharedStrings(); + ApplySheetFeatures(writer, profile); + writer.SetNumFmts(numFmts); + writer.ResolveColumnStyles(plan.Columns); + writer.WriteSheetMeta(plan.Columns, profile.FreezeHeader); + writer.WriteHeader(plan.Columns); + writer.WriteRows(filtered, plan); + writer.Complete(); + } + + public static Task RunAsync(XlsxWriter writer, IAsyncEnumerable data, Action>? configure, CancellationToken cancellationToken) + { + var profile = new ExportProfile(); + configure?.Invoke(profile); + return RunAsync(writer, data, profile, cancellationToken); + } + + public static async Task RunAsync(XlsxWriter writer, IAsyncEnumerable data, ExportProfile profile, CancellationToken cancellationToken) + { + profile.Freeze(); + + var plan = RowPlanBuilder.BuildTyped(profile); + var actualSheetName = profile.SheetName ?? typeof(T).Name; + IAsyncEnumerable preparedData = profile.RowFilter is null + ? data + : FilterRowsAsync(data, profile.RowFilter, cancellationToken); + bool autoSst = false; + if (profile.AutoSst && writer.SupportsAutoSharedStrings) + { + var prepared = await AutoSstBufferAndDetectAsync(data, plan, cancellationToken).ConfigureAwait(false); + preparedData = prepared.Data; + autoSst = prepared.Enable; + } + try + { + if (autoSst) writer.EnableSharedStrings(); + + var numFmts = plan.BuildNumFmts(); + + if (profile.DefaultRowHeight is double dh) writer.SetDefaultRowHeight(dh); + writer.AddSheet(actualSheetName); + ApplySheetFeatures(writer, profile); + writer.SetNumFmts(numFmts); + writer.ResolveColumnStyles(plan.Columns); + writer.WriteSheetMeta(plan.Columns, profile.FreezeHeader); + writer.WriteHeader(plan.Columns); + await writer.WriteRowsAsync(preparedData, plan, cancellationToken).ConfigureAwait(false); + await writer.CompleteAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + if (preparedData is IAsyncDisposable disposable) + await disposable.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + public static Task RunAsync(XlsxWriter writer, IEnumerable data, Action>? configure, CancellationToken cancellationToken) + { + var profile = new ExportProfile(); + configure?.Invoke(profile); + return RunAsync(writer, data, profile, cancellationToken); + } + + public static Task RunAsync(XlsxWriter writer, IEnumerable data, ExportProfile profile, CancellationToken cancellationToken) + { + if (data is IList list) + return RunFromListAsync(writer, list, profile, cancellationToken); + + return RunAsync(writer, ToAsyncEnumerable(data), profile, cancellationToken); + } + + private static async Task RunFromListAsync(XlsxWriter writer, IList data, ExportProfile profile, CancellationToken cancellationToken) + { + profile.Freeze(); + + var plan = RowPlanBuilder.BuildTyped(profile); + var actualSheetName = profile.SheetName ?? typeof(T).Name; + + IList filtered = data; + if (profile.RowFilter is { } rowFilter) + { + var list = new List(data.Count); + for (int i = 0; i < data.Count; i++) + { + var x = data[i]; + if (rowFilter(x)) list.Add(x); + } + filtered = list; + } + + bool autoSst = false; + if (profile.AutoSst && writer.SupportsAutoSharedStrings) + { + int probeRows = Math.Min(64, filtered.Count); + if (probeRows >= 16) + DetectSharedStringRatio(filtered, plan, ref autoSst, probeRows); + } + if (autoSst) writer.EnableSharedStrings(); + + var numFmts = plan.BuildNumFmts(); + + if (profile.DefaultRowHeight is double dh) writer.SetDefaultRowHeight(dh); + writer.AddSheet(actualSheetName); + ApplySheetFeatures(writer, profile); + writer.SetNumFmts(numFmts); + writer.ResolveColumnStyles(plan.Columns); + writer.WriteSheetMeta(plan.Columns, profile.FreezeHeader); + writer.WriteHeader(plan.Columns); + await writer.WriteRowsFromListAsync(filtered, plan, cancellationToken).ConfigureAwait(false); + await writer.CompleteAsync(cancellationToken).ConfigureAwait(false); + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source) + { + foreach (var item in source) yield return item; + } + + private static async IAsyncEnumerable FilterRowsAsync( + IAsyncEnumerable source, + Func predicate, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var enumerator = source.GetAsyncEnumerator(cancellationToken); + try + { + while (await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (predicate(enumerator.Current)) + yield return enumerator.Current; + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + + public static void BuildAndWrite(XlsxWriter writer, SheetBase sheet) + { + if (writer is null) throw new ArgumentNullException(nameof(writer)); + if (sheet is null) throw new ArgumentNullException(nameof(sheet)); + sheet.WriteTo(writer); + } + + public static void WriteSheet(XlsxWriter writer, string sheetName, IEnumerable data, ExportProfile? profile) + { + if (writer is null) throw new ArgumentNullException(nameof(writer)); + if (data is null) throw new ArgumentNullException(nameof(data)); + profile ??= new ExportProfile().Sheet(sheetName); + profile.Freeze(); + var plan = RowPlanBuilder.BuildTyped(profile); + IEnumerable filtered = profile.RowFilter is null ? data : FilterRows(data, profile.RowFilter); + bool autoSst = profile.AutoSst && writer.SupportsAutoSharedStrings; + if (autoSst) + filtered = AutoSstBufferAndDetect(filtered, plan, out autoSst); + writer.AddSheet(sheetName); + if (autoSst) writer.EnableSharedStrings(); + ApplySheetFeatures(writer, profile); + ApplyProfileToWriter(writer, profile, plan); + writer.WriteRows(filtered, plan); + } + + public static void WriteSheetUntyped(XlsxWriter writer, string sheetName, IEnumerable data) + { + if (writer is null) throw new ArgumentNullException(nameof(writer)); + if (data is null) throw new ArgumentNullException(nameof(data)); + var values = new List(); + Type? elementType = null; + foreach (var item in data) + { + values.Add(item); + if (item is null) continue; + var itemType = item.GetType(); + if (elementType is null) + { + elementType = itemType; + continue; + } + if (itemType != elementType) + throw new ArgumentException("All non-null items in a non-generic Sheet must have the same runtime type.", nameof(data)); + } + + if (elementType is null) + { + writer.AddSheet(sheetName); + return; + } + if (elementType.IsValueType && values.Any(static value => value is null)) + throw new ArgumentException("Null items are not supported for value-type rows in a non-generic Sheet.", nameof(data)); + + var method = typeof(XlsxWritePipeline).GetMethod(nameof(BoxMultiSheet), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(elementType); + try + { + method.Invoke(null, new object?[] { writer, sheetName, values }); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + } + + private static void BoxMultiSheet(XlsxWriter writer, string sheetName, IEnumerable data) + { + var typed = CastEnumerable(data); + var profile = new ExportProfile().Sheet(sheetName); + profile.Freeze(); + var plan = RowPlanBuilder.BuildTyped(profile); + + writer.AddSheet(sheetName); + ApplySheetFeatures(writer, profile); + ApplyProfileToWriter(writer, profile, plan); + writer.WriteRows(typed, plan); + } + + private static IEnumerable CastEnumerable(IEnumerable src) + { + foreach (var item in src) yield return (T)item!; + } + + private static void ApplyProfileToWriter(XlsxWriter writer, ExportProfile profile, TypedRowPlan plan) + { + if (profile.DefaultRowHeight is double dh) writer.SetDefaultRowHeight(dh); + writer.SetNumFmts(plan.BuildNumFmts()); + writer.ResolveColumnStyles(plan.Columns); + writer.WriteSheetMeta(plan.Columns, profile.FreezeHeader); + writer.WriteHeader(plan.Columns); + } + + private static void ApplySheetFeatures(XlsxWriter writer, ExportProfile profile) + { + if (profile.MergeCells is not null) + foreach (var r in profile.MergeCells) writer.MergeCells(r); + if (profile.AutoFilterRef is not null) writer.SetAutoFilter(profile.AutoFilterRef); + foreach (var (refH, uri) in profile.Hyperlinks) writer.AddHyperlink(refH, uri); + } + + private static IEnumerable FilterRows(IEnumerable data, Func predicate) + { + foreach (var item in data) + if (predicate(item)) yield return item; + } + + private static IEnumerable AutoSstBufferAndDetect(IEnumerable data, TypedRowPlan plan, out bool enableSst) + { + enableSst = false; + if (data is IList list) + { + int probeRows = Math.Min(64, list.Count); + if (probeRows >= 16) + DetectSharedStringRatio(list, plan, ref enableSst, probeRows); + return list; + } + + var enumerator = data.GetEnumerator(); + var probe = new List(64); + try + { + while (probe.Count < 64 && enumerator.MoveNext()) + probe.Add(enumerator.Current); + } + catch + { + enumerator.Dispose(); + throw; + } + + try + { + DetectSharedStringRatio(probe, plan, ref enableSst); + return ContinueAfterProbe(probe, enumerator); + } + catch + { + enumerator.Dispose(); + throw; + } + } + + private static void DetectSharedStringRatio(IList rows, TypedRowPlan plan, ref bool enableSst, int? rowCount = null) + { + var getters = plan.TypedGetters; + var distinct = new HashSet(StringComparer.Ordinal); + int total = 0; + int count = rowCount ?? rows.Count; + for (int i = 0; i < count; i++) + { + var item = rows[i]; + if (item is null) continue; + foreach (var g in getters) + { + var cv = g(item); + if (cv.Type == CellType.String && cv.StringValue is { } s) + { + distinct.Add(s); + total++; + } + } + } + if (total > 0) + enableSst = (double)distinct.Count / total < 0.7; + } + + private static IEnumerable ContinueAfterProbe(IReadOnlyList probe, IEnumerator enumerator) + { + using (enumerator) + { + for (int i = 0; i < probe.Count; i++) + yield return probe[i]; + while (enumerator.MoveNext()) + yield return enumerator.Current; + } + } + + private static async Task<(IAsyncEnumerable Data, bool Enable)> AutoSstBufferAndDetectAsync( + IAsyncEnumerable data, TypedRowPlan plan, CancellationToken cancellationToken) + { + var enumerator = data.GetAsyncEnumerator(cancellationToken); + var probe = new List(64); + try + { + while (probe.Count < 64 && await enumerator.MoveNextAsync().ConfigureAwait(false)) + probe.Add(enumerator.Current); + } + catch + { + await enumerator.DisposeAsync().ConfigureAwait(false); + throw; + } + + try + { + bool enable = false; + if (probe.Count >= 16) + DetectSharedStringRatio(probe, plan, ref enable); + return (ContinueAfterProbeAsync(probe, enumerator, cancellationToken), enable); + } + catch + { + await enumerator.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private static async IAsyncEnumerable ContinueAfterProbeAsync( + IReadOnlyList probe, IAsyncEnumerator enumerator, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + try + { + for (int i = 0; i < probe.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return probe[i]; + } + + while (await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return enumerator.Current; + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/src/Magicodes.IE.IO/Engine/XlsxWriter.Helpers.cs b/src/Magicodes.IE.IO/Engine/XlsxWriter.Helpers.cs new file mode 100644 index 00000000..4b2090ce --- /dev/null +++ b/src/Magicodes.IE.IO/Engine/XlsxWriter.Helpers.cs @@ -0,0 +1,865 @@ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Magicodes.IE.IO +{ + public sealed partial class XlsxWriter + { + + private void ValidateTables() + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var displayNames = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int sheetIndex = 0; sheetIndex < _sheets.Count; sheetIndex++) + { + foreach (var table in _sheets[sheetIndex].Tables) + { + ValidateTableDefinition(table); + if (!names.Add(table.Name)) throw new ArgumentException($"Duplicate table name '{table.Name}'."); + if (!displayNames.Add(table.DisplayName)) throw new ArgumentException($"Duplicate table displayName '{table.DisplayName}'."); + } + } + } + + private static void ValidateTableDefinition(TableDefinition table) + { + if (string.IsNullOrWhiteSpace(table.Name)) throw new ArgumentException("Table name cannot be empty.", nameof(table)); + if (string.IsNullOrWhiteSpace(table.DisplayName)) throw new ArgumentException("Table displayName cannot be empty.", nameof(table)); + if (table.Columns.Count == 0) throw new ArgumentException("A table must define at least one column.", nameof(table)); + int colon = table.Ref.IndexOf(':'); + if (colon <= 0 || colon == table.Ref.Length - 1 || table.Ref.IndexOf(':', colon + 1) >= 0) + throw new ArgumentException($"Invalid table ref '{table.Ref}'.", nameof(table)); + int firstCol = CellRefHelper.CellRefToCol(table.Ref.Substring(0, colon)); + int lastCol = CellRefHelper.CellRefToCol(table.Ref.Substring(colon + 1)); + if (firstCol < 0 || lastCol < 0) + throw new ArgumentException($"Invalid table ref '{table.Ref}'.", nameof(table)); + int expectedCount = lastCol - firstCol + 1; + if (expectedCount != table.Columns.Count) + throw new ArgumentException($"Table ref '{table.Ref}' spans {expectedCount} columns, but {table.Columns.Count} columns were defined.", nameof(table)); + var columnNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var column in table.Columns) + { + if (string.IsNullOrWhiteSpace(column.Name)) throw new ArgumentException("Table column name cannot be empty.", nameof(table)); + if (!columnNames.Add(column.Name)) throw new ArgumentException($"Duplicate table column name '{column.Name}'.", nameof(table)); + } + } + + private void EnsureNotDisposed() + { + if (_disposed) throw new ObjectDisposedException(nameof(XlsxWriter)); + } + + private void RecordFault(Exception exception) + { + _completionFault ??= exception; + } + + private static void ValidateCellRange(string value, string parameterName) + { + if (!CellRefHelper.IsCellRange(value)) + throw new ArgumentException($"Invalid cell range '{value}'. Expected A1:B2 format.", parameterName); + } + + private void ValidateSheetName(string name) + { + if (name.Length == 0) throw new ArgumentException("Sheet name cannot be empty", nameof(name)); + if (name.Length > MaxSheetNameLength) + throw new ArgumentException($"Sheet name length {name.Length} exceeds Excel limit of {MaxSheetNameLength} characters", nameof(name)); + if (name[0] == '\'' || name[name.Length - 1] == '\'') + throw new ArgumentException("Sheet name cannot start or end with single quote", nameof(name)); + if (name.Equals("History", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Sheet name cannot be the reserved name 'History'", nameof(name)); + foreach (var c in InvalidSheetNameChars) + { + if (name.IndexOf(c) >= 0) + throw new ArgumentException($"Sheet name contains invalid character '{c}'", nameof(name)); + } + } + + private void EnsureSheetCapacity(int sheetIdx) + { + while (_sheets.Count <= sheetIdx) _sheets.Add(new SheetState()); + } + + private void WriteEntryBytes(string name, byte[] bytes) + { + using var es = _zip.OpenEntry(name, _compression); + es.Write(bytes, 0, bytes.Length); + } + + private async Task WriteEntryBytesAsync(string name, byte[] bytes, CancellationToken cancellationToken) + { + var es = _zip.OpenEntry(name, _compression); + try + { + await es.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); + } + finally + { + await ForwardOnlyZipWriter.DisposeEntryStreamAsync(es).ConfigureAwait(false); + } + } + + private void WriteEntryString(string name, string content) + { + int maxBytes = Encoding.UTF8.GetMaxByteCount(content.Length); + byte[] buf = System.Buffers.ArrayPool.Shared.Rent(maxBytes); + try + { + int n = Encoding.UTF8.GetBytes(content, 0, content.Length, buf, 0); + using var es = _zip.OpenEntry(name, _compression); + es.Write(buf, 0, n); + } + finally + { + System.Buffers.ArrayPool.Shared.Return(buf); + } + } + + private async Task WriteEntryStringAsync(string name, string content, CancellationToken cancellationToken) + { + int maxBytes = Encoding.UTF8.GetMaxByteCount(content.Length); + byte[] buf = System.Buffers.ArrayPool.Shared.Rent(maxBytes); + try + { + int n = Encoding.UTF8.GetBytes(content, 0, content.Length, buf, 0); + var es = _zip.OpenEntry(name, _compression); + try + { + await es.WriteAsync(buf, 0, n, cancellationToken).ConfigureAwait(false); + } + finally + { + await ForwardOnlyZipWriter.DisposeEntryStreamAsync(es).ConfigureAwait(false); + } + } + finally + { + System.Buffers.ArrayPool.Shared.Return(buf); + } + } + + private void WriteEntryStringBuilder(string name, StringBuilder sb) + { +#if NETSTANDARD2_0 + WriteEntryString(name, sb.ToString()); +#else + using var es = _zip.OpenEntry(name, _compression); + _sink.WriteUtf8(sb); + _sink.FlushTo(es); +#endif + } + + private async Task WriteEntryStringBuilderAsync(string name, StringBuilder sb, CancellationToken cancellationToken) + { +#if NETSTANDARD2_0 + await WriteEntryStringAsync(name, sb.ToString(), cancellationToken).ConfigureAwait(false); +#else + await using var es = _zip.OpenEntry(name, _compression); + _sink.WriteUtf8(sb); + await _sink.FlushToAsync(es, cancellationToken).ConfigureAwait(false); +#endif + } + + private async Task WriteEntryFromSinkAsync(string name, CancellationToken cancellationToken) + { + var es = _zip.OpenEntry(name, _compression); + try + { + await _sink.FlushToAsync(es, cancellationToken).ConfigureAwait(false); + } + finally + { + await ForwardOnlyZipWriter.DisposeEntryStreamAsync(es).ConfigureAwait(false); + } + } + + private void WriteRels() + { + // This relationship is fixed for every workbook; sheet relationships are written later. + using var es = _zip.OpenEntry("_rels/.rels", _compression); + _sink.WriteUtf8("\r\n"u8); + _sink.WriteUtf8("\r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + _sink.WriteUtf8("\r\n"u8); + _sink.FlushTo(es); + } + + private void BuildWorkbookXml() + { + // Workbook metadata is kept small and is emitted only after all sheets are known. + _sink.WriteUtf8("\r\n"u8); + _sink.WriteUtf8("\r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + for (int i = 0; i < _sheetNames.Count; i++) + { + _sink.WriteUtf8(" \r\n"u8); + } + _sink.WriteUtf8(" \r\n"u8); + if (_namedRanges.Count > 0) + { + _sink.WriteUtf8(" \r\n"u8); + for (int i = 0; i < _namedRanges.Count; i++) + { + var nr = _namedRanges[i]; + _sink.WriteUtf8(" "u8); + _sink.WriteUtf8(XmlHelper.EscapeXmlAttr(nr.Ref)); + _sink.WriteUtf8("\r\n"u8); + } + _sink.WriteUtf8(" \r\n"u8); + } + _sink.WriteUtf8("\r\n"u8); + } + + private void WriteWorkbookFinal() + { + using var es = _zip.OpenEntry("xl/workbook.xml", _compression); + BuildWorkbookXml(); + _sink.FlushTo(es); + } + + private Task WriteWorkbookFinalAsync(CancellationToken cancellationToken) + { + BuildWorkbookXml(); + return WriteEntryFromSinkAsync("xl/workbook.xml", cancellationToken); + } + + private void BuildWorkbookRelsXml() + { + _sink.WriteUtf8("\r\n"u8); + _sink.WriteUtf8("\r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + for (int i = 0; i < _sheetNames.Count; i++) + { + _sink.WriteUtf8(" \r\n"u8); + } + if (_useSharedStrings && _sharedStrings.Count > 0) + { + _sink.WriteUtf8(" \r\n"u8); + } + _sink.WriteUtf8("\r\n"u8); + } + + private void WriteWorkbookRelsFinal() + { + using var es = _zip.OpenEntry("xl/_rels/workbook.xml.rels", _compression); + BuildWorkbookRelsXml(); + _sink.FlushTo(es); + } + + private Task WriteWorkbookRelsFinalAsync(CancellationToken cancellationToken) + { + BuildWorkbookRelsXml(); + return WriteEntryFromSinkAsync("xl/_rels/workbook.xml.rels", cancellationToken); + } + + private void BuildContentTypesXml() + { + _sink.WriteUtf8("\r\n"u8); + _sink.WriteUtf8("\r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + for (int i = 0; i < _sheetNames.Count; i++) + { + _sink.WriteUtf8(" \r\n"u8); + } + _sink.WriteUtf8(" \r\n"u8); + _sink.WriteUtf8(" \r\n"u8); + if (_useSharedStrings && _sharedStrings.Count > 0) + { + _sink.WriteUtf8(" \r\n"u8); + } + for (int i = 0; i < _sheets.Count; i++) + { + if (_sheets[i].Images.Count > 0) + { + _sink.WriteUtf8(" \r\n"u8); + } + if (_sheets[i].Tables.Count > 0) + { + for (int t = 0; t < _sheets[i].Tables.Count; t++) + { + _sink.WriteUtf8(" \r\n"u8); + } + } + } + for (int i = 0; i < _sheets.Count; i++) + { + if (_sheets[i].Comments.Count > 0) + { + _sink.WriteUtf8(" \r\n"u8); + } + } + _sink.WriteUtf8("\r\n"u8); + } + + private void WriteContentTypesFinal() + { + using var es = _zip.OpenEntry("[Content_Types].xml", _compression); + BuildContentTypesXml(); + _sink.FlushTo(es); + } + + private Task WriteContentTypesFinalAsync(CancellationToken cancellationToken) + { + BuildContentTypesXml(); + return WriteEntryFromSinkAsync("[Content_Types].xml", cancellationToken); + } + + private StringBuilder BuildTableXml(TableDefinition tbl, int tableId) + { + var sb = new StringBuilder(256); + sb.Append("\r\n"); + sb.Append("\r\n"); + if (tbl.ShowAutoFilter) + { + sb.Append(" \r\n"); + } + if (tbl.Columns is { Count: > 0 }) + { + sb.Append(" \r\n"); + for (int c = 0; c < tbl.Columns.Count; c++) + { + sb.Append(" \r\n"); + } + sb.Append(" \r\n"); + } + sb.Append(" \r\n"); + sb.Append("
\r\n"); + return sb; + } + + private void WriteTables() + { + int tableId = 1; + for (int sheetIdx = 0; sheetIdx < _sheets.Count; sheetIdx++) + { + var tables = _sheets[sheetIdx].Tables; + for (int t = 0; t < tables.Count; t++) + { + WriteEntryStringBuilder($"xl/tables/table{sheetIdx + 1}_{t + 1}.xml", BuildTableXml(tables[t], tableId++)); + } + } + } + + private async Task WriteTablesAsync(CancellationToken cancellationToken) + { + int tableId = 1; + for (int sheetIdx = 0; sheetIdx < _sheets.Count; sheetIdx++) + { + var tables = _sheets[sheetIdx].Tables; + for (int t = 0; t < tables.Count; t++) + { + await WriteEntryStringBuilderAsync($"xl/tables/table{sheetIdx + 1}_{t + 1}.xml", BuildTableXml(tables[t], tableId++), cancellationToken).ConfigureAwait(false); + } + } + } + + private StringBuilder BuildDrawingXml(List images, int drawingNo) + { + var dwSb = new StringBuilder(256); + dwSb.Append("\r\n"); + dwSb.Append("\r\n"); + for (int i = 0; i < images.Count; i++) + { + var img = images[i]; + dwSb.Append(" \r\n"); + dwSb.Append(" ").Append(CellRefToCol(img.FromCell)).Append("0").Append(CellRefToRow(img.FromCell)).Append("0\r\n"); + dwSb.Append(" ").Append(CellRefToCol(img.ToCell)).Append("0").Append(CellRefToRow(img.ToCell)).Append("0\r\n"); + dwSb.Append(" \r\n"); + dwSb.Append(" \r\n"); + dwSb.Append(" \r\n"); + dwSb.Append(" \r\n"); + dwSb.Append(" \r\n"); + dwSb.Append(" \r\n"); + dwSb.Append(" \r\n"); + } + dwSb.Append("\r\n"); + return dwSb; + } + + private StringBuilder BuildDrawingRelsXml(List images, int drawingNo) + { + var relSb = new StringBuilder(256); + relSb.Append("\r\n"); + relSb.Append("\r\n"); + for (int i = 0; i < images.Count; i++) + { + relSb.Append(" \r\n"); + } + relSb.Append("\r\n"); + return relSb; + } + + private void WriteDrawingsAndImages() + { + for (int i = 0; i < _imageBytes.Count; i++) + { + var (path, bytes) = _imageBytes[i]; + WriteEntryBytes(path, bytes); + } + for (int sheetIdx = 0; sheetIdx < _sheets.Count; sheetIdx++) + { + var images = _sheets[sheetIdx].Images; + if (images.Count == 0) continue; + int drawingNo = sheetIdx + 1; + WriteEntryStringBuilder($"xl/drawings/drawing{drawingNo}.xml", BuildDrawingXml(images, drawingNo)); + WriteEntryStringBuilder($"xl/drawings/_rels/drawing{drawingNo}.xml.rels", BuildDrawingRelsXml(images, drawingNo)); + } + } + + private async Task WriteDrawingsAndImagesAsync(CancellationToken cancellationToken) + { + for (int i = 0; i < _imageBytes.Count; i++) + { + var (path, bytes) = _imageBytes[i]; + await WriteEntryBytesAsync(path, bytes, cancellationToken).ConfigureAwait(false); + } + for (int sheetIdx = 0; sheetIdx < _sheets.Count; sheetIdx++) + { + var images = _sheets[sheetIdx].Images; + if (images.Count == 0) continue; + int drawingNo = sheetIdx + 1; + await WriteEntryStringBuilderAsync($"xl/drawings/drawing{drawingNo}.xml", BuildDrawingXml(images, drawingNo), cancellationToken).ConfigureAwait(false); + await WriteEntryStringBuilderAsync($"xl/drawings/_rels/drawing{drawingNo}.xml.rels", BuildDrawingRelsXml(images, drawingNo), cancellationToken).ConfigureAwait(false); + } + } + + private StringBuilder BuildSheetRelsXml(int sheetIdx) + { + // A sheet may have several optional relationship parts, so build this part in one pass. + int sheetNo = sheetIdx + 1; + var st = _sheets[sheetIdx]; + var sb = new StringBuilder(256); + bool any = false; + + if (st.Hyperlinks.Count > 0) + { + if (!any) { sb.Append("\r\n"); sb.Append("\r\n"); any = true; } + var hlinks = st.Hyperlinks; + for (int i = 0; i < hlinks.Count; i++) + { + sb.Append(" \r\n"); + } + } + if (st.Images.Count > 0) + { + if (!any) { sb.Append("\r\n"); sb.Append("\r\n"); any = true; } + sb.Append(" \r\n"); + } + if (st.Comments.Count > 0) + { + if (!any) { sb.Append("\r\n"); sb.Append("\r\n"); any = true; } + sb.Append(" \r\n"); + sb.Append(" \r\n"); + } + if (st.Tables.Count > 0) + { + if (!any) { sb.Append("\r\n"); sb.Append("\r\n"); any = true; } + for (int t = 0; t < st.Tables.Count; t++) + { + sb.Append(" \r\n"); + } + } + if (any) + { + sb.Append("\r\n"); + } + return sb; + } + + private void WriteSheetRels(int sheetIdx) + { + var sb = BuildSheetRelsXml(sheetIdx); + if (sb.Length > 0) WriteEntryStringBuilder($"xl/worksheets/_rels/sheet{sheetIdx + 1}.xml.rels", sb); + } + + private async Task WriteSheetRelsAsync(int sheetIdx, CancellationToken cancellationToken) + { + var sb = BuildSheetRelsXml(sheetIdx); + if (sb.Length > 0) await WriteEntryStringBuilderAsync($"xl/worksheets/_rels/sheet{sheetIdx + 1}.xml.rels", sb, cancellationToken).ConfigureAwait(false); + } + + private void WriteSheetPr(OutlineSettings? o, PageSetup? ps) + { + bool hasOutline = o is not null; + // fit-to-page only takes effect when is set; + // alone is ignored by Excel. + bool fitToPage = ps is not null && (ps.FitToWidth.HasValue || ps.FitToHeight.HasValue); + if (!hasOutline && !fitToPage) return; + var sb = new StringBuilder(96); + sb.Append(""); + // CT_SheetPr child order: tabColor?, outlinePr?, pageSetUpPr? + if (hasOutline) + { + sb.Append(""); + } + if (fitToPage) + sb.Append(""); + sb.Append("\r\n"); + _sink.WriteUtf8(sb); + } + + private void WriteDataValidationsToSink(List validations) + { + if (validations.Count == 0) return; + var sb = new StringBuilder(256); + sb.Append("\r\n"); + foreach (var dv in validations) + { + sb.Append(" \r\n"); + if (!string.IsNullOrEmpty(dv.Formula1)) + sb.Append(" ").Append(XmlHelper.EscapeXmlAttr(dv.Formula1!)).Append("\r\n"); + if (!string.IsNullOrEmpty(dv.Formula2)) + sb.Append(" ").Append(XmlHelper.EscapeXmlAttr(dv.Formula2!)).Append("\r\n"); + sb.Append(" \r\n"); + } + sb.Append("\r\n"); + _sink.WriteUtf8(sb); + } + + private void WriteTablePartsToSink(List tables, int sheetNo) + { + if (tables.Count == 0) return; + var sb = new StringBuilder(); + sb.Append("\r\n"); + for (int t = 0; t < tables.Count; t++) + sb.Append(" \r\n"); + sb.Append("\r\n"); + _sink.WriteUtf8(sb); + } + + private void WriteSheetProtection(SheetProtection? p) + { + if (p is null) return; + var sb = new StringBuilder(128); + sb.Append("\r\n"); + _sink.WriteUtf8(sb); + } + + private void WritePageSetup(PageSetup? ps) + { + if (ps is null) return; + var sb = new StringBuilder(128); + sb.Append("\r\n"); + _sink.WriteUtf8(sb); + } + + private void WritePageMargins(PageSetup? ps) + { + var margins = ps ?? new PageSetup(); + var sb = new StringBuilder(160); + sb.Append("\r\n"); + _sink.WriteUtf8(sb); + } + + private void WriteHeaderFooter(PageSetup? ps) + { + if (ps is null || (ps.OddHeader is null && ps.OddFooter is null && ps.EvenHeader is null && ps.EvenFooter is null)) return; + var sb = new StringBuilder(160); + if (ps.EvenHeader is not null || ps.EvenFooter is not null) + sb.Append(""); + else + sb.Append(""); + if (ps.OddHeader is string oddHeader) sb.Append("").Append(XmlHelper.EscapeXmlText(oddHeader)).Append(""); + if (ps.OddFooter is string oddFooter) sb.Append("").Append(XmlHelper.EscapeXmlText(oddFooter)).Append(""); + if (ps.EvenHeader is string evenHeader) sb.Append("").Append(XmlHelper.EscapeXmlText(evenHeader)).Append(""); + if (ps.EvenFooter is string evenFooter) sb.Append("").Append(XmlHelper.EscapeXmlText(evenFooter)).Append(""); + sb.Append("\r\n"); + _sink.WriteUtf8(sb); + } + + private StringBuilder BuildCommentsXml(List comments, int sheetNo) + { + var sb = new StringBuilder(256); + sb.Append("\r\n"); + sb.Append("\r\n"); + sb.Append(" \r\n"); + var authorIds = new Dictionary(StringComparer.Ordinal); + int nextId = 0; + foreach (var c in comments) + { + if (!authorIds.ContainsKey(c.Author)) + authorIds[c.Author] = nextId++; + } + foreach (var kv in authorIds) + sb.Append(" ").Append(XmlHelper.EscapeXmlAttr(kv.Key)).Append("\r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + foreach (var c in comments) + { + int authorId = authorIds[c.Author]; + string cellRef = CellRefToA1(c.Row, c.Col); + sb.Append(" ") + .Append(XmlHelper.EscapeXmlText(c.Text)).Append("\r\n"); + } + sb.Append(" \r\n"); + sb.Append("\r\n"); + return sb; + } + + private void WriteComments(List comments, int sheetNo) + { + if (comments.Count == 0) return; + WriteEntryStringBuilder($"xl/comments{sheetNo}.xml", BuildCommentsXml(comments, sheetNo)); + } + + private async Task WriteCommentsAsync(List comments, int sheetNo, CancellationToken cancellationToken) + { + if (comments.Count == 0) return; + await WriteEntryStringBuilderAsync($"xl/comments{sheetNo}.xml", BuildCommentsXml(comments, sheetNo), cancellationToken).ConfigureAwait(false); + } + + private StringBuilder BuildCommentsVmlXml(List comments, int sheetNo) + { + var sb = new StringBuilder(1024); + sb.Append("\r\n"); + sb.Append("\r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + + for (int i = 0; i < comments.Count; i++) + { + var c = comments[i]; + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append("
\r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + sb.Append(" ").Append(c.Col).Append(", 15, ").Append(c.Row).Append(", 2, ") + .Append(c.Col + 2).Append(", 31, ").Append(c.Row + 4).Append(", 1\r\n"); + sb.Append(" False\r\n"); + sb.Append(" ").Append(c.Row).Append("\r\n"); + sb.Append(" ").Append(c.Col).Append("\r\n"); + sb.Append(" \r\n"); + sb.Append(" \r\n"); + } + + sb.Append("\r\n"); + return sb; + } + + private void WriteCommentsVml(List comments, int sheetNo) + { + if (comments.Count == 0) return; + WriteEntryStringBuilder($"xl/drawings/vmlDrawing{sheetNo}.vml", BuildCommentsVmlXml(comments, sheetNo)); + } + + private async Task WriteCommentsVmlAsync(List comments, int sheetNo, CancellationToken cancellationToken) + { + if (comments.Count == 0) return; + await WriteEntryStringBuilderAsync($"xl/drawings/vmlDrawing{sheetNo}.vml", BuildCommentsVmlXml(comments, sheetNo), cancellationToken).ConfigureAwait(false); + } + + private void WriteConditionalFormatting(List cfs) + { + if (cfs.Count == 0) return; + var sb = new StringBuilder(256); + int priority = 1; + foreach (var cf in cfs) + { + sb.Append("\r\n"); + foreach (var rule in cf.Rules) + { + sb.Append(" \r\n"); + sb.Append(" ").Append(XmlHelper.EscapeXmlAttr(rule.Formula1)).Append("\r\n"); + if (!string.IsNullOrEmpty(rule.Formula2)) + sb.Append(" ").Append(XmlHelper.EscapeXmlAttr(rule.Formula2!)).Append("\r\n"); + sb.Append(" \r\n"); + } + sb.Append("\r\n"); + } + _sink.WriteUtf8(sb); + } + + private static int CellRefToCol(string cellRef) => CellRefHelper.CellRefToCol(cellRef); + + private static int CellRefToRow(string cellRef) => CellRefHelper.CellRefToRow(cellRef); + + private static string CellRefToA1(int row, int col) + { + if (col < 0) return "A1"; + row += 1; +#if NETSTANDARD2_0 + char[] buf = new char[12]; +#else + Span buf = stackalloc char[12]; +#endif + int p = 0; + int n = col; +#if NETSTANDARD2_0 + char[] letters = new char[4]; +#else + Span letters = stackalloc char[4]; +#endif + int lp = 0; + do + { + letters[lp++] = (char)('A' + n % 26); + n = n / 26 - 1; + } while (n >= 0); + for (int i = lp - 1; i >= 0; i--) buf[p++] = letters[i]; +#if NETSTANDARD2_0 + var rowText = row.ToString(CultureInfo.InvariantCulture); + rowText.AsSpan().CopyTo(buf.AsSpan(p)); + p += rowText.Length; + return new string(buf, 0, p); +#else + if (!row.TryFormat(buf.Slice(p), out int written, provider: CultureInfo.InvariantCulture)) + return "A1"; + p += written; + return new string(buf.Slice(0, p)); +#endif + } + + private static string DvTypeName(DataValidationType t) => t switch + { + DataValidationType.Any => "any", + DataValidationType.Integer => "whole", + DataValidationType.Decimal => "decimal", + DataValidationType.List => "list", + DataValidationType.Date => "date", + DataValidationType.Time => "time", + DataValidationType.TextLength => "textLength", + DataValidationType.Custom => "custom", + _ => "any", + }; + + private static string DvOpName(DataValidationOperator op) => op switch + { + DataValidationOperator.Between => "between", + DataValidationOperator.NotBetween => "notBetween", + DataValidationOperator.Equal => "equal", + DataValidationOperator.NotEqual => "notEqual", + DataValidationOperator.LessThan => "lessThan", + DataValidationOperator.LessThanOrEqual => "lessThanOrEqual", + DataValidationOperator.GreaterThan => "greaterThan", + DataValidationOperator.GreaterThanOrEqual => "greaterThanOrEqual", + _ => "equal", + }; + + } +} diff --git a/src/Magicodes.IE.IO/Engine/XlsxWriter.cs b/src/Magicodes.IE.IO/Engine/XlsxWriter.cs new file mode 100644 index 00000000..2ad5b44c --- /dev/null +++ b/src/Magicodes.IE.IO/Engine/XlsxWriter.cs @@ -0,0 +1,1962 @@ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + + /// + /// Advanced streaming writer for xlsx workbooks. + /// For ordinary exports, use . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed partial class XlsxWriter : IDisposable, IAsyncDisposable + { + private readonly ForwardOnlyZipWriter _zip; + private readonly ByteBufferWriter _sink; + private Stream? _sheetStream; + private int _rowIndex; + private readonly byte[] _rowNumberBuf = new byte[12]; + private int _rowNumberLen; + private ReadOnlySpan RowNumberRef => _rowNumberBuf.AsSpan(0, _rowNumberLen); + private int _currentColIndex; + private bool _disposed; + private IReadOnlyList _numFmts = Array.Empty(); + private ColumnMeta[]? _currentColumns; + private readonly List _sheetNames = new(); + private int _currentSheetIndex = -1; + private CompressionLevel _compression = CompressionLevel.Fastest; + private double? _defaultRowHeight; + private bool _strictCellReferences = true; + private bool _sheetDataStarted; + private byte[][]? _colLetterCache; + private readonly List _sheets = new(); + private readonly List<(string Path, byte[] Bytes)> _imageBytes = new(); + private int _imageCounter = 0; + private double? _nextRowHeight; + private bool _useSharedStrings; + private readonly Dictionary _sharedStringLookup = new(); + private readonly List _sharedStrings = new(); + private int _sstRefCount; + private readonly List _namedRanges = new(); + + /// + /// Initializes a new writer. The caller owns and disposes the output stream. When is supplied, the first sheet is created immediately. + /// + public XlsxWriter(Stream output, string? sheetName = null, CompressionLevel compression = CompressionLevel.Fastest, double defaultRowHeight = 0, bool strictCellReferences = true) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + + _zip = new ForwardOnlyZipWriter(output, leaveOpen: true); + _sink = new ByteBufferWriter(initialSize: SheetStreamBufferBytes); + _compression = compression; + _strictCellReferences = strictCellReferences; + if (defaultRowHeight > 0) _defaultRowHeight = defaultRowHeight; + + try + { + WriteRels(); + + if (sheetName is not null) + AddSheet(sheetName); + } + catch + { + _zip.Dispose(); + _sink.Dispose(); + throw; + } + } + + /// + /// Gets the one-based row index of the current sheet. + /// + public int CurrentRow => _rowIndex; + + /// + /// Gets the number of sheets created so far. + /// + public int SheetCount => _sheetNames.Count; + + /// + /// Gets the names of the sheets created so far. + /// + public IReadOnlyList SheetNames => _sheetNames; + + internal bool SupportsAutoSharedStrings => _compression != CompressionLevel.NoCompression; + + /// + /// Registers the numeric formats used in the workbook. + /// + public void SetNumFmts(IReadOnlyList numFmts) + { + EnsureNotDisposed(); + var existing = _numFmts.ToList(); + foreach (var fmt in numFmts ?? Array.Empty()) + { + if (!string.IsNullOrEmpty(fmt) && !existing.Contains(fmt)) + existing.Add(fmt); + } + _numFmts = existing; + } + + /// + /// Sets the default row height used by subsequent sheets. + /// + public void SetDefaultRowHeight(double height) + { + EnsureNotDisposed(); + if (height <= 0 || double.IsNaN(height)) throw new ArgumentOutOfRangeException(nameof(height), "Row height must be greater than 0."); + _defaultRowHeight = height; + } + + /// + /// Sets the row height for the next row only; it reverts to the default after that row is written. + /// + public void SetNextRowHeight(double height) + { + EnsureNotDisposed(); + if (height <= 0 || double.IsNaN(height)) throw new ArgumentOutOfRangeException(nameof(height), "Row height must be greater than 0."); + _nextRowHeight = height; + } + + /// + /// Enables the shared string table. Call this before writing the header or any data. + /// + public void EnableSharedStrings() + { + EnsureNotDisposed(); + _useSharedStrings = true; + } + + /// + /// Builds the style pool for the current sheet from the supplied column metadata. + /// + public void ResolveColumnStyles(ColumnMeta[] columns) + { + EnsureNotDisposed(); + if (columns is null) throw new ArgumentNullException(nameof(columns)); + EnsureSheetCapacity(_currentSheetIndex); + var st = _sheets[_currentSheetIndex]; + st.Columns = columns; + st.StyleIds = BuildStylePool(columns); + } + + private const int MaxSheetNameLength = 31; + private const int MaxRows = 1_048_576; + private const int MaxColumns = 16_384; + private static readonly char[] InvalidSheetNameChars = { ':', '\\', '/', '?', '*', '[', ']' }; + private const int SheetStreamBufferBytes = 64 * 1024; + private const int SheetStreamFlushThresholdBytes = 16 * 1024; + + /// + /// Creates a new worksheet with the given name and makes it the current sheet. + /// + public void AddSheet(string name) + { + EnsureNotDisposed(); + try + { + if (name is null) throw new ArgumentNullException(nameof(name)); + name = name.Trim(); + ValidateSheetName(name); + if (_sheetNames.Any(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException($"A worksheet named '{name}' already exists.", nameof(name)); + + if (_sheetStream is not null) + CloseCurrentSheet(_currentSheetIndex); + + _sheetNames.Add(name); + _currentSheetIndex++; + EnsureSheetCapacity(_currentSheetIndex); + int sheetNo = _currentSheetIndex + 1; + + _sheetStream = _zip.OpenEntry($"xl/worksheets/sheet{sheetNo}.xml", _compression); + + _sink.WriteUtf8("\r\n"u8); + _sink.WriteUtf8("\r\n"u8); + _sink.FlushTo(_sheetStream); + } + catch (Exception ex) + { + RecordFault(ex); + throw; + } + } + + /// + /// Writes sheet-level metadata such as column widths, hidden columns, and the frozen header. + /// + public void WriteSheetMeta(ColumnMeta[] columns, bool freezeHeader) + { + EnsureNotDisposed(); + if (_sheetStream is null) throw new InvalidOperationException("Must call AddSheet before WriteSheetMeta"); + if (columns is null) throw new ArgumentNullException(nameof(columns)); + + _sink.FlushTo(_sheetStream); + + WriteSheetPr(_sheets[_currentSheetIndex].Outline, _sheets[_currentSheetIndex].PageSetup); + + var sb = new System.Text.StringBuilder(256); + + if (freezeHeader) + { + sb.Append("\r\n"); + } + + if (_defaultRowHeight is double h2) + { + double pt = h2 * 0.75; + sb.Append("\r\n"); + } + + bool hasColsMeta = false; + for (int i = 0; i < columns.Length; i++) + { + if (columns[i].Width.HasValue || columns[i].Hidden) { hasColsMeta = true; break; } + } + if (hasColsMeta) + { + sb.Append("\r\n"); + int runStart = -1; + double runWidth = 0; + bool runHidden = false; + int runMin = 0, runMax = 0; + void FlushRun() + { + if (runStart == -1) return; + if (runHidden) + sb.Append("\r\n"); + else + sb.Append("\r\n"); + } + for (int i = 0; i < columns.Length; i++) + { + var c = columns[i]; + int colNo = i + 1; + bool isHidden = c.Hidden; + if (!isHidden && !c.Width.HasValue) + { + FlushRun(); + runStart = -1; + continue; + } + if (runStart == -1) + { + runStart = colNo; + runWidth = c.Width ?? 0; + runHidden = isHidden; + runMin = colNo; + runMax = colNo; + } + else if (isHidden == runHidden && (isHidden || c.Width == runWidth)) + { + runMax = colNo; + } + else + { + FlushRun(); + runStart = colNo; + runWidth = c.Width ?? 0; + runHidden = isHidden; + runMin = colNo; + runMax = colNo; + } + } + FlushRun(); + sb.Append("\r\n"); + } + + if (sb.Length > 0) + { + _sink.WriteUtf8(sb); + } + + _sink.FlushTo(_sheetStream); + } + + /// + /// Writes the header row from the supplied column metadata. + /// + public void WriteHeader(ColumnMeta[] columns) + { + EnsureNotDisposed(); + if (columns is null) throw new ArgumentNullException(nameof(columns)); + _currentColumns = columns; + + EnsureColumnLetters(columns.Length); + + if (!_sheetDataStarted) + { + _sink.WriteUtf8("\r\n"u8); + _sheetDataStarted = true; + } + + double? headerRowHeight = null; + for (int i = 0; i < columns.Length; i++) + { + if (columns[i].RowHeight.HasValue) { headerRowHeight = columns[i].RowHeight; break; } + } + BeginRow(headerRowHeight); + for (int i = 0; i < columns.Length; i++) + { + var c = columns[i]; + var colLetter = _colLetterCache![_currentColIndex]; + _currentColIndex++; + _sink.WriteInlineStringCell(0, c.DisplayName, colLetter, RowNumberRef, _strictCellReferences); + } + EndRow(); + } + + /// + /// Merges the given cell range in the current sheet. + /// + public void MergeCells(string range) + { + EnsureNotDisposed(); + if (range is null) throw new ArgumentNullException(nameof(range)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet first"); + ValidateCellRange(range, nameof(range)); + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].MergeCells.Add(range); + } + + /// + /// Sets the auto-filter range for the current sheet. + /// + public void SetAutoFilter(string cellRange) + { + EnsureNotDisposed(); + if (cellRange is null) throw new ArgumentNullException(nameof(cellRange)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet before setting autoFilter"); + ValidateCellRange(cellRange, nameof(cellRange)); + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].AutoFilter = cellRange; + } + + /// + /// Adds a hyperlink to a cell in the current sheet. + /// + public void AddHyperlink(string cellRef, string uri) + { + EnsureNotDisposed(); + if (cellRef is null) throw new ArgumentNullException(nameof(cellRef)); + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (CellRefToCol(cellRef) < 0) throw new ArgumentException($"invalid cellRef: '{cellRef}'", nameof(cellRef)); + if (CellRefToRow(cellRef) < 0) throw new ArgumentException($"invalid cellRef: '{cellRef}'", nameof(cellRef)); + if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed) || + (parsed.Scheme != "http" && parsed.Scheme != "https" && parsed.Scheme != "mailto" && parsed.Scheme != "file")) + throw new ArgumentException($"URI must be an absolute http/https/mailto/file URI: '{uri}'", nameof(uri)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet before adding hyperlink"); + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].Hyperlinks.Add((cellRef, uri)); + } + + /// + /// Adds a data-validation rule to the current sheet. + /// + public void AddDataValidation(DataValidation validation) + { + EnsureNotDisposed(); + if (validation is null) throw new ArgumentNullException(nameof(validation)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet before adding data validation"); + ValidateCellRange(validation.CellRange, nameof(validation)); + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].DataValidations.Add(validation); + } + + /// + /// Adds an Excel table definition to the current sheet. + /// + public void AddTable(TableDefinition table) + { + EnsureNotDisposed(); + if (table is null) throw new ArgumentNullException(nameof(table)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet before adding table"); + ValidateTableDefinition(table); + var refStr = table.Ref; + int colon = refStr.IndexOf(':'); + if (colon < 0 + || CellRefToCol(refStr.Substring(0, colon)) < 0 + || CellRefToRow(refStr.Substring(0, colon)) < 0 + || CellRefToCol(refStr.Substring(colon + 1)) < 0 + || CellRefToRow(refStr.Substring(colon + 1)) < 0) + { + throw new ArgumentException($"Table Ref invalid (expected A1:B10 format): '{refStr}'", nameof(table)); + } + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].Tables.Add(table); + } + + /// + /// Applies protection options to the current sheet. + /// + public void SetSheetProtection(SheetProtection protection) + { + EnsureNotDisposed(); + if (protection is null) throw new ArgumentNullException(nameof(protection)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet first"); + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].Protection = protection; + } + + /// + /// Sets the print page setup for the current sheet. + /// + public void SetPageSetup(PageSetup ps) + { + EnsureNotDisposed(); + if (ps is null) throw new ArgumentNullException(nameof(ps)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet first"); + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].PageSetup = ps; + } + + /// + /// Sets the outline (grouping) options for the current sheet. + /// + public void SetOutline(OutlineSettings outline) + { + EnsureNotDisposed(); + if (outline is null) throw new ArgumentNullException(nameof(outline)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet first"); + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].Outline = outline; + } + + /// + /// Adds a cell comment to the current sheet. + /// + public void AddComment(Comment comment) + { + EnsureNotDisposed(); + if (comment is null) throw new ArgumentNullException(nameof(comment)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet first"); + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].Comments.Add(comment); + } + + /// + /// Adds a workbook- or sheet-scoped named range. + /// + public void AddNamedRange(NamedRange range) + { + EnsureNotDisposed(); + if (range is null) throw new ArgumentNullException(nameof(range)); + _namedRanges.Add(range); + } + + /// + /// Adds conditional formatting to the current sheet. + /// + public void AddConditionalFormatting(ConditionalFormatting cf) + { + EnsureNotDisposed(); + if (cf is null) throw new ArgumentNullException(nameof(cf)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet first"); + ValidateCellRange(cf.CellRange, nameof(cf)); + if (cf.Rules.Length == 0) throw new ArgumentException("At least one conditional formatting rule is required.", nameof(cf)); + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].ConditionalFormattings.Add(cf); + } + + /// + /// Adds an image to the current sheet and returns its one-based picture number. + /// + public int AddImage(byte[] imageBytes, string extension, string anchorFromCell, string anchorToCell) + { + EnsureNotDisposed(); + if (imageBytes is null || imageBytes.Length == 0) throw new ArgumentException("imageBytes empty", nameof(imageBytes)); + if (extension is null) throw new ArgumentNullException(nameof(extension)); + if (string.IsNullOrEmpty(extension)) extension = "png"; +#if NETSTANDARD2_0 + if (extension.StartsWith(".")) extension = extension.Substring(1); +#else + if (extension.StartsWith('.')) extension = extension[1..]; +#endif + if (extension != "png" && extension != "jpeg" && extension != "jpg" && extension != "gif" && extension != "bmp") + throw new ArgumentException($"unsupported image extension '{extension}'(supported: png, jpeg, jpg, gif, bmp)", nameof(extension)); + if (_currentSheetIndex < 0) throw new InvalidOperationException("Must call AddSheet before AddImage"); + + if (CellRefToCol(anchorFromCell) < 0) throw new ArgumentException($"invalid cellRef: '{anchorFromCell}'", nameof(anchorFromCell)); + if (CellRefToRow(anchorFromCell) < 0) throw new ArgumentException($"invalid cellRef: '{anchorFromCell}'", nameof(anchorFromCell)); + if (CellRefToCol(anchorToCell) < 0) throw new ArgumentException($"invalid cellRef: '{anchorToCell}'", nameof(anchorToCell)); + if (CellRefToRow(anchorToCell) < 0) throw new ArgumentException($"invalid cellRef: '{anchorToCell}'", nameof(anchorToCell)); + + _imageCounter++; + int imageNo = _imageCounter; + string imageName = $"image{imageNo}.{extension}"; + + _imageBytes.Add(($"xl/media/{imageName}", imageBytes)); + + EnsureSheetCapacity(_currentSheetIndex); + _sheets[_currentSheetIndex].Images.Add(new ImageAnchor(imageNo, imageName, anchorFromCell, anchorToCell)); + return imageNo; + } + + /// + /// Writes the data rows using a reflection-based row plan. + /// + public void WriteRows(IEnumerable data, RowPlan plan) + { + EnsureNotDisposed(); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (plan is null) throw new ArgumentNullException(nameof(plan)); + + try + { + int colCount = plan.Columns.Length; + var getters = plan.Getters; + var styleIds = GetCurrentSheetStyleIds(colCount); + var columns = plan.Columns; + EnsureColumnLetters(colCount); + + foreach (var item in data) + { + if (item is null) continue; + BeginRow(); + for (int i = 0; i < colCount; i++) + { + var cv = getters[i](item); + WriteCell(cv, styleIds[i]); + } + EndRow(); + } + } + catch (Exception ex) + { + RecordFault(ex); + throw; + } + } + + /// + /// Writes the data rows using a strongly typed row plan. + /// + public void WriteRows(IEnumerable data, TypedRowPlan plan) + { + EnsureNotDisposed(); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (plan is null) throw new ArgumentNullException(nameof(plan)); + + try + { + int colCount = plan.Columns.Length; + var getters = plan.TypedGetters; + var cellWriters = plan.TypedCellWriters; + var columns = plan.Columns; + var styleIds = GetCurrentSheetStyleIds(colCount); + EnsureColumnLetters(colCount); + var formulas = new string?[colCount]; + for (int i = 0; i < colCount; i++) formulas[i] = columns[i].Formula; + + bool fastPath = cellWriters is not null && cellWriters.Length == colCount; + // Generated writers avoid CellValue boxing. Formulas and unsupported columns use + // the general path below. + if (fastPath) + { + for (int i = 0; i < colCount; i++) + { + if (formulas![i] is not null || cellWriters![i] is null) + { + fastPath = false; + break; + } + } + } + + if (fastPath) + { + foreach (var item in data) + { + if (item is null) continue; + var row = BeginRow(); + for (int i = 0; i < colCount; i++) + { + cellWriters![i]!(row, item, styleIds[i]); + } + row.EndRow(); + } + } + else + { + foreach (var item in data) + { + if (item is null) continue; + var row = BeginRow(); + for (int i = 0; i < colCount; i++) + { + if (formulas[i] is { } tpl) + { + row.WriteFormula(styleIds[i], tpl); + } + else if (cellWriters is not null && cellWriters[i] is { } cw) + { + cw(row, item, styleIds[i]); + } + else + { + var cv = getters[i](item); + row.WriteCell(cv, styleIds[i]); + } + } + row.EndRow(); + } + } + } + catch (Exception ex) + { + RecordFault(ex); + throw; + } + } + + /// + /// Writes data rows from an asynchronous enumeration using a strongly typed row plan. + /// + public async Task WriteRowsAsync(IAsyncEnumerable data, TypedRowPlan plan, CancellationToken cancellationToken = default) + { + EnsureNotDisposed(); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (plan is null) throw new ArgumentNullException(nameof(plan)); + + try + { + int colCount = plan.Columns.Length; + var getters = plan.TypedGetters; + var columns = plan.Columns; + var styleIds = GetCurrentSheetStyleIds(colCount); + EnsureColumnLetters(colCount); + var cellWriters = plan.TypedCellWriters; + var formulas = new string?[colCount]; + for (int i = 0; i < colCount; i++) formulas[i] = columns[i].Formula; + + bool fastPath = cellWriters is not null && cellWriters.Length == colCount; + // Keep the generated path allocation-free when every column has a direct writer. + if (fastPath) + { + for (int i = 0; i < colCount; i++) + { + if (formulas![i] is not null || cellWriters![i] is null) + { + fastPath = false; + break; + } + } + } + + if (fastPath) + { + var enumerator = data.GetAsyncEnumerator(cancellationToken); + try + { + while (true) + { + var moveNext = enumerator.MoveNextAsync(); + bool hasMore = moveNext.IsCompletedSuccessfully + ? moveNext.Result + : await moveNext.ConfigureAwait(false); + if (!hasMore) break; + cancellationToken.ThrowIfCancellationRequested(); + var item = enumerator.Current; + if (item is null) continue; + var row = BeginRow(); + for (int i = 0; i < colCount; i++) + { + cellWriters![i]!(row, item, styleIds[i]); + } + if (EndRowCheckFlush()) + await _sink.FlushToAsync(_sheetStream!, cancellationToken).ConfigureAwait(false); + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + else + { + var enumerator = data.GetAsyncEnumerator(cancellationToken); + try + { + while (true) + { + var moveNext = enumerator.MoveNextAsync(); + bool hasMore = moveNext.IsCompletedSuccessfully + ? moveNext.Result + : await moveNext.ConfigureAwait(false); + if (!hasMore) break; + cancellationToken.ThrowIfCancellationRequested(); + var item = enumerator.Current; + if (item is null) continue; + var row = BeginRow(); + for (int i = 0; i < colCount; i++) + { + if (formulas[i] is { } tpl) + { + row.WriteFormula(styleIds[i], tpl); + } + else if (cellWriters is not null && cellWriters[i] is { } cw) + { + cw(row, item, styleIds[i]); + } + else + { + var cv = getters[i](item); + row.WriteCell(cv, styleIds[i]); + } + } + if (EndRowCheckFlush()) + await _sink.FlushToAsync(_sheetStream!, cancellationToken).ConfigureAwait(false); + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + + if (_sheetStream is not null) + { + await _sink.FlushToAsync(_sheetStream, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + RecordFault(ex); + throw; + } + } + + /// + /// Writes data rows from a materialized list using a strongly typed row plan, avoiding extra enumerator allocation. + /// + public async Task WriteRowsFromListAsync(IList data, TypedRowPlan plan, CancellationToken cancellationToken = default) + { + EnsureNotDisposed(); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (plan is null) throw new ArgumentNullException(nameof(plan)); + + try + { + int colCount = plan.Columns.Length; + var getters = plan.TypedGetters; + var columns = plan.Columns; + var styleIds = GetCurrentSheetStyleIds(colCount); + EnsureColumnLetters(colCount); + var cellWriters = plan.TypedCellWriters; + var formulas = new string?[colCount]; + for (int i = 0; i < colCount; i++) formulas[i] = columns[i].Formula; + + bool fastPath = cellWriters is not null && cellWriters.Length == colCount; + if (fastPath) + { + for (int i = 0; i < colCount; i++) + { + if (formulas![i] is not null || cellWriters![i] is null) + { + fastPath = false; + break; + } + } + } + + int count = data.Count; + for (int idx = 0; idx < count; idx++) + { + cancellationToken.ThrowIfCancellationRequested(); + var item = data[idx]; + if (item is null) continue; + var row = BeginRow(); + if (fastPath) + { + for (int i = 0; i < colCount; i++) + cellWriters![i]!(row, item, styleIds[i]); + } + else + { + for (int i = 0; i < colCount; i++) + { + if (formulas[i] is { } tpl) + row.WriteFormula(styleIds[i], tpl); + else if (cellWriters is not null && cellWriters[i] is { } cw) + cw(row, item, styleIds[i]); + else + row.WriteCell(getters[i](item), styleIds[i]); + } + } + if (EndRowCheckFlush()) + await _sink.FlushToAsync(_sheetStream!, cancellationToken).ConfigureAwait(false); + } + + if (_sheetStream is not null) + await _sink.FlushToAsync(_sheetStream, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + RecordFault(ex); + throw; + } + } + + private void WriteFormulaCell(int styleId, string template, int row) + { + _currentColIndex++; + _sink.WriteUtf8(" 0) + { + _sink.WriteUtf8(" s=\""u8); + _sink.WriteInt32(styleId); + _sink.WriteUtf8("\""u8); + } + _sink.WriteUtf8(" r=\""u8); + var colLetterF = _colLetterCache![_currentColIndex - 1]; + _sink.WriteUtf8(colLetterF); + _sink.WriteUtf8(RowNumberRef); + _sink.WriteUtf8("\">"u8); + _sink.WriteUtf8(""u8); + + Span rowBuf = stackalloc byte[12]; + NumberFormatHelper.WriteInt32(row, rowBuf, out var rw); int rowLen = rw; + + int cursor = 0; + const string Token = "{row}"; + while (true) + { + int idx = template.IndexOf(Token, cursor, StringComparison.Ordinal); + if (idx < 0) + { + if (cursor < template.Length) _sink.WriteEscaped(template.Substring(cursor)); + break; + } + if (idx > cursor) _sink.WriteEscaped(template.Substring(cursor, idx - cursor)); + if (rowLen > 0) _sink.WriteUtf8(rowBuf.Slice(0, rowLen)); + cursor = idx + Token.Length; + } + _sink.WriteUtf8(""u8); + } + + /// + /// Begins a new row. You must call EndRow on the returned writer when finished. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public XlsxRowWriter BeginRow(double? height = null) + { + if (_rowIndex >= MaxRows) + throw new InvalidOperationException($"A worksheet cannot contain more than {MaxRows} rows."); + _rowIndex++; + _currentColIndex = 0; + NumberFormatHelper.WriteInt32(_rowIndex, _rowNumberBuf, out _rowNumberLen); + var rowRef = RowNumberRef; + _sink.WriteUtf8(" buf = stackalloc byte[32]; + NumberFormatHelper.WriteDouble(pt, 'G', buf, out var w2); + _sink.WriteUtf8(buf.Slice(0, w2)); + _sink.WriteUtf8("\" customHeight=\"1\""u8); + } + _sink.WriteUtf8(">"u8); + return new XlsxRowWriter(this); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + private bool EndRowCheckFlush() + { + _sink.WriteUtf8(""u8); + return _sink.RemainingCapacity < SheetStreamFlushThresholdBytes && _sheetStream is not null; + } + + private void EndRow() + { + if (EndRowCheckFlush()) + { + _sink.FlushTo(_sheetStream!); + } + } + + private async Task EndRowAsync(CancellationToken cancellationToken) + { + if (EndRowCheckFlush()) + { + await _sink.FlushToAsync(_sheetStream!, cancellationToken).ConfigureAwait(false); + } + } + + [EditorBrowsable(EditorBrowsableState.Never)] + public readonly struct XlsxRowWriter + { + private readonly XlsxWriter _w; + internal XlsxRowWriter(XlsxWriter w) => _w = w; + + /// + /// Writes a string cell. + /// + public void WriteStringCell(int styleId, string? s) => _w.WriteStringCell(styleId, s); + + /// + /// Writes a numeric cell. + /// + public void WriteNumberCell(int styleId, double d) => _w.WriteNumberCell(styleId, d); + + /// + /// Writes a Boolean cell. + /// + public void WriteBoolCell(int styleId, bool b) => _w.WriteBoolCell(styleId, b); + + /// + /// Writes an empty cell. + /// + public void WriteEmptyCell(int styleId) => _w.WriteEmptyCell(styleId); + + /// + /// Writes a cell using the kind and value of the supplied . + /// + public void WriteCell(CellValue cv, int styleId) => _w.WriteCell(cv, styleId); + + /// + /// Writes a formula that substitutes the current row number for {row}. + /// + public void WriteFormula(int styleId, string template) => _w.WriteFormulaCell(styleId, template, _w._rowIndex); + + /// + /// Ends the current row. + /// + public void EndRow() => _w.EndRow(); + + /// + /// Asynchronously ends the current row. + /// + public Task EndRowAsync(CancellationToken cancellationToken = default) => _w.EndRowAsync(cancellationToken); + } + + private void BeginCell(int styleId) + { + int colIdx = _currentColIndex; + _currentColIndex++; + Span buf = stackalloc byte[32]; + int p = 0; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'c'; + buf[p++] = (byte)' '; + buf[p++] = (byte)'r'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + var colLetter = _colLetterCache![colIdx]; + colLetter.CopyTo(buf.Slice(p)); + p += colLetter.Length; + RowNumberRef.CopyTo(buf.Slice(p)); p += _rowNumberLen; + buf[p++] = (byte)'"'; + if (styleId > 0) + { + buf[p++] = (byte)' '; + buf[p++] = (byte)'s'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + NumberFormatHelper.WriteInt32(styleId, buf.Slice(p), out int w); p += w; + buf[p++] = (byte)'"'; + } + buf[p++] = (byte)'>'; + _sink.WriteUtf8(buf.Slice(0, p)); + } + + private void EndCell() => _sink.WriteUtf8(""u8); + + private int ResolveSharedStringIndex(string s) + { + _sstRefCount++; + if (_sharedStringLookup.TryGetValue(s, out var idx)) return idx; + idx = _sharedStrings.Count; + _sharedStrings.Add(s); + _sharedStringLookup[s] = idx; + return idx; + } + + private void WriteSstCell(int styleId, int sstIndex) + { + int colIdx = _currentColIndex; + _currentColIndex++; + Span buf = stackalloc byte[48]; + int p = 0; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'c'; + buf[p++] = (byte)' '; + buf[p++] = (byte)'t'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + buf[p++] = (byte)'s'; + buf[p++] = (byte)'"'; + buf[p++] = (byte)' '; + buf[p++] = (byte)'r'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + var colLetterSst = _colLetterCache![colIdx]; + colLetterSst.CopyTo(buf.Slice(p)); + p += colLetterSst.Length; + RowNumberRef.CopyTo(buf.Slice(p)); p += _rowNumberLen; + buf[p++] = (byte)'"'; + if (styleId > 0) + { + buf[p++] = (byte)' '; + buf[p++] = (byte)'s'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + NumberFormatHelper.WriteInt32(styleId, buf.Slice(p), out int w); p += w; + buf[p++] = (byte)'"'; + } + buf[p++] = (byte)'>'; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'v'; + buf[p++] = (byte)'>'; + NumberFormatHelper.WriteInt32(sstIndex, buf.Slice(p), out int w2); p += w2; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'/'; + buf[p++] = (byte)'v'; + buf[p++] = (byte)'>'; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'/'; + buf[p++] = (byte)'c'; + buf[p++] = (byte)'>'; + _sink.WriteUtf8(buf.Slice(0, p)); + } + + private void WriteCell(CellValue cv, int styleId) + { + if (_useSharedStrings && cv.Type == CellType.String && cv.StringValue is not null) + { + int sstIdx = ResolveSharedStringIndex(cv.StringValue); + WriteSstCell(styleId, sstIdx); + return; + } + if (cv.Type == CellType.String && cv.StringValue is not null) + { + var colLetter = _colLetterCache![_currentColIndex]; + _currentColIndex++; + _sink.WriteInlineStringCell(styleId, cv.StringValue, colLetter, RowNumberRef, _strictCellReferences); + return; + } + if (cv.Type == CellType.Number) + { + if (double.IsNaN(cv.NumberValue) || double.IsInfinity(cv.NumberValue)) + { + BeginCell(styleId); + EndCell(); + return; + } + BeginNumberCell(styleId); + WriteNumber(cv.NumberValue); + EndCell(); + return; + } + if (cv.Type == CellType.Boolean) + { + WriteBoolCell(styleId, cv.BoolValue); + return; + } + BeginCell(styleId); + WriteCellValue(cv); + EndCell(); + } + + private void WriteCellValue(CellValue cv) + { + switch (cv.Type) + { + case CellType.Null: + break; + case CellType.String: + if (cv.StringValue is not null) WriteInlineString(cv.StringValue); + break; + case CellType.Number: + WriteNumber(cv.NumberValue); + break; + case CellType.Boolean: + _sink.WriteUtf8(cv.BoolValue ? "1"u8 : "0"u8); + break; + case CellType.Formula: + _sink.WriteUtf8(""u8); + _sink.WriteEscaped(cv.StringValue ?? ""); + _sink.WriteUtf8(""u8); + break; + } + } + + private void WriteInlineString(string s) + { + _sink.WriteUtf8(" 0 && (char.IsWhiteSpace(s[0]) || char.IsWhiteSpace(s[s.Length - 1])); + if (preserve) _sink.WriteUtf8(" xml:space=\"preserve\""u8); + _sink.WriteUtf8(">"u8); + _sink.WriteEscaped(s); + _sink.WriteUtf8(""u8); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + private void WriteStringCell(int styleId, string? s) + { + if (s is null) + { + BeginCell(styleId); + EndCell(); + return; + } + if (_useSharedStrings) + { + int sstIdx = ResolveSharedStringIndex(s); + WriteSstCell(styleId, sstIdx); + return; + } + var colLetter = _colLetterCache![_currentColIndex]; + _currentColIndex++; + _sink.WriteInlineStringCell(styleId, s, colLetter, RowNumberRef, _strictCellReferences); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + private void WriteNumberCell(int styleId, double d) + { + if (double.IsNaN(d) || double.IsInfinity(d)) + { + BeginCell(styleId); + EndCell(); + return; + } + int colIdx = _currentColIndex; + _currentColIndex++; + var colLetter = _colLetterCache![colIdx]; + _sink.WriteNumberCell(styleId, colLetter, RowNumberRef, d, _strictCellReferences); + } + + private void BeginNumberCell(int styleId) + { + int colIdx = _currentColIndex; + _currentColIndex++; + Span buf = stackalloc byte[40]; + int p = 0; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'c'; + buf[p++] = (byte)' '; + buf[p++] = (byte)'r'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + var colLetterNum = _colLetterCache![colIdx]; + colLetterNum.CopyTo(buf.Slice(p)); + p += colLetterNum.Length; + RowNumberRef.CopyTo(buf.Slice(p)); p += _rowNumberLen; + buf[p++] = (byte)'"'; + buf[p++] = (byte)' '; + buf[p++] = (byte)'t'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + buf[p++] = (byte)'n'; + buf[p++] = (byte)'"'; + if (styleId > 0) + { + buf[p++] = (byte)' '; + buf[p++] = (byte)'s'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + NumberFormatHelper.WriteInt32(styleId, buf.Slice(p), out int w); p += w; + buf[p++] = (byte)'"'; + } + buf[p++] = (byte)'>'; + _sink.WriteUtf8(buf.Slice(0, p)); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + private void WriteBoolCell(int styleId, bool b) + { + int colIdx = _currentColIndex; + _currentColIndex++; + Span buf = stackalloc byte[48]; + int p = 0; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'c'; + buf[p++] = (byte)' '; + buf[p++] = (byte)'t'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + buf[p++] = (byte)'b'; + buf[p++] = (byte)'"'; + buf[p++] = (byte)' '; + buf[p++] = (byte)'r'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + var colLetterBool = _colLetterCache![colIdx]; + colLetterBool.CopyTo(buf.Slice(p)); + p += colLetterBool.Length; + RowNumberRef.CopyTo(buf.Slice(p)); p += _rowNumberLen; + buf[p++] = (byte)'"'; + if (styleId > 0) + { + buf[p++] = (byte)' '; + buf[p++] = (byte)'s'; + buf[p++] = (byte)'='; + buf[p++] = (byte)'"'; + NumberFormatHelper.WriteInt32(styleId, buf.Slice(p), out int w); p += w; + buf[p++] = (byte)'"'; + } + buf[p++] = (byte)'>'; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'v'; + buf[p++] = (byte)'>'; + buf[p++] = b ? (byte)'1' : (byte)'0'; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'/'; + buf[p++] = (byte)'v'; + buf[p++] = (byte)'>'; + buf[p++] = (byte)'<'; + buf[p++] = (byte)'/'; + buf[p++] = (byte)'c'; + buf[p++] = (byte)'>'; + _sink.WriteUtf8(buf.Slice(0, p)); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + private void WriteEmptyCell(int styleId) + { + BeginCell(styleId); + EndCell(); + } + + private void WriteNumber(double d) + { + if (double.IsNaN(d) || double.IsInfinity(d)) return; + _sink.WriteUtf8(""u8); + _sink.WriteDouble(d); + _sink.WriteUtf8(""u8); + } + + private void CloseCurrentSheetCore(int closingSheetIdx) + { + if (_sheetStream is null) return; + var st = _sheets[closingSheetIdx]; + bool hasImages = st.Images.Count > 0; + + if (_sheetDataStarted) + { + _sink.WriteUtf8("\r\n"u8); + _sheetDataStarted = false; + } + WriteSheetProtection(st.Protection); + if (st.AutoFilter is { } afRef) + { + _sink.WriteUtf8("\r\n"u8); + } + if (st.MergeCells.Count > 0) + { + var cells = st.MergeCells; + _sink.WriteUtf8("\r\n"u8); + foreach (var range in cells) + { + _sink.WriteUtf8("\r\n"u8); + } + _sink.WriteUtf8("\r\n"u8); + } + WriteConditionalFormatting(st.ConditionalFormattings); + if (st.DataValidations.Count > 0) + WriteDataValidationsToSink(st.DataValidations); + if (st.Hyperlinks.Count > 0) + { + var hlinks = st.Hyperlinks; + _sink.WriteUtf8("\r\n"u8); + for (int hi = 0; hi < hlinks.Count; hi++) + { + int rid = hi + 1; + _sink.WriteUtf8("\r\n"u8); + } + _sink.WriteUtf8("\r\n"u8); + } + WritePageMargins(st.PageSetup); + WritePageSetup(st.PageSetup); + WriteHeaderFooter(st.PageSetup); + if (hasImages) + { + _sink.WriteUtf8("\r\n"u8); + } + if (st.Comments.Count > 0) + { + _sink.WriteUtf8("\r\n"u8); + } + if (st.Tables.Count > 0) + WriteTablePartsToSink(st.Tables, closingSheetIdx + 1); + + _sink.WriteUtf8(""u8); + } + + private void CloseCurrentSheet(int closingSheetIdx) + { + if (_sheetStream is null) return; + CloseCurrentSheetCore(closingSheetIdx); + _sink.FlushTo(_sheetStream); + _sheetStream.Dispose(); + _sheetStream = null; + _rowIndex = 0; + } + + private async Task CloseCurrentSheetAsync(int closingSheetIdx, CancellationToken cancellationToken) + { + if (_sheetStream is null) return; + CloseCurrentSheetCore(closingSheetIdx); + await _sink.FlushToAsync(_sheetStream, cancellationToken).ConfigureAwait(false); + await ForwardOnlyZipWriter.DisposeEntryStreamAsync(_sheetStream).ConfigureAwait(false); + _sheetStream = null; + _rowIndex = 0; + } + + private int[] GetCurrentSheetStyleIds(int colCount) + { + if (_currentSheetIndex >= 0 && _sheets.Count > _currentSheetIndex && _sheets[_currentSheetIndex].StyleIds is { } ids) + return ids; + return new int[colCount]; + } + + private void EnsureColumnLetters(int colCount) + { + if (colCount > MaxColumns) + throw new ArgumentOutOfRangeException(nameof(colCount), $"A worksheet cannot contain more than {MaxColumns} columns."); + if (_colLetterCache is not null && _colLetterCache.Length >= colCount) return; + var cache = new byte[colCount][]; + Span tmp = stackalloc byte[4]; + for (int c = 0; c < colCount; c++) + { + int len = ColumnLetter(c, tmp); + var arr = new byte[len]; + tmp.Slice(0, len).CopyTo(arr); + cache[c] = arr; + } + _colLetterCache = cache; + } + + internal static int ColumnLetter(int col0, Span dest) + { + int n = col0; + int p = dest.Length; + do + { + dest[--p] = (byte)('A' + n % 26); + n = n / 26 - 1; + } while (n >= 0); + int written = dest.Length - p; + dest.Slice(p, written).CopyTo(dest); + return written; + } + + private static string CfOpName(CfOperator op) => op switch + { + CfOperator.NotEqual => "notEqual", + CfOperator.GreaterThan => "greaterThan", + CfOperator.GreaterThanOrEqual => "greaterThanOrEqual", + CfOperator.LessThan => "lessThan", + CfOperator.LessThanOrEqual => "lessThanOrEqual", + CfOperator.Between => "between", + CfOperator.NotBetween => "notBetween", + _ => "equal", + }; + + private void BuildSharedStringsXml(out StringBuilder sb) + { + sb = new StringBuilder(4096); + sb.Append("\r\n"); + sb.Append("\r\n"); + for (int i = 0; i < _sharedStrings.Count; i++) + { + var s = _sharedStrings[i]; + sb.Append(" 0 && (char.IsWhiteSpace(s[0]) || char.IsWhiteSpace(s[s.Length - 1])); + if (preserve) sb.Append(" xml:space=\"preserve\""); + sb.Append(">").Append(XmlHelper.EscapeXmlAttr(s)).Append("\r\n"); + } + sb.Append("\r\n"); + } + + private void WriteSharedStringsTable() + { + BuildSharedStringsXml(out var sb); + WriteEntryStringBuilder("xl/sharedStrings.xml", sb); + } + + private async Task WriteSharedStringsTableAsync(CancellationToken cancellationToken) + { + BuildSharedStringsXml(out var sb); + await WriteEntryStringBuilderAsync("xl/sharedStrings.xml", sb, cancellationToken).ConfigureAwait(false); + } + + private static void ValidateColumnMeta(ColumnMeta column) + { + if (column.Width is double width && (double.IsNaN(width) || double.IsInfinity(width) || width <= 0)) + throw new ArgumentOutOfRangeException(nameof(column.Width), "Column width must be finite and greater than 0."); + if (column.FontSize is float fontSize && (float.IsNaN(fontSize) || float.IsInfinity(fontSize) || fontSize <= 0)) + throw new ArgumentOutOfRangeException(nameof(column.FontSize), "Font size must be finite and greater than 0."); + if (column.RowHeight is double rowHeight && (double.IsNaN(rowHeight) || double.IsInfinity(rowHeight) || rowHeight <= 0)) + throw new ArgumentOutOfRangeException(nameof(column.RowHeight), "Row height must be finite and greater than 0."); + ValidateColor(column.BackgroundColor, nameof(column.BackgroundColor)); + ValidateColor(column.FontColor, nameof(column.FontColor)); + ValidateColor(column.BorderColor, nameof(column.BorderColor)); + } + + private static void ValidateColor(string? color, string parameterName) + { + if (color is null) return; + if ((color.Length != 6 && color.Length != 8) || color.Any(c => !Uri.IsHexDigit(c))) + throw new ArgumentException("Color must be a 6- or 8-digit hexadecimal value.", parameterName); + } + + private int[] BuildStylePool(ColumnMeta[] columns) + { + var xfIds = new int[columns.Length]; + _stylePool ??= new List(); + _stylePoolLookup ??= new Dictionary(); + _fontSet ??= new(); + _fillSet ??= new(); + _borderSet ??= new(); + _fontLookup ??= new Dictionary(); + _fillLookup ??= new Dictionary(); + _borderLookup ??= new Dictionary(); + _numFmtLookup ??= new Dictionary(); + + if (_stylePool.Count == 0) + { + const string defaultXfKey = "0|0|0|0|0|0|0"; + _stylePool.Add(defaultXfKey); + _stylePoolLookup[defaultXfKey] = 0; + } + if (_fontSet.Count == 0) + { + var defaultFont = (false, 11f, (string?)null, "Calibri", false, false, false); + _fontSet.Add(defaultFont); + _fontLookup[new FontKey(defaultFont.Item1, defaultFont.Item2, defaultFont.Item3, + defaultFont.Item4, defaultFont.Item5, defaultFont.Item6, defaultFont.Item7)] = 0; + } + // fills: index 0 = none, index 1 = gray125 (serialized specially in BuildFullStylePoolXml); + // ensure both slots exist so background-color fills start at index 2. + while (_fillSet.Count < 2) _fillSet.Add(null); + + if (_fillLookup.Count == 0) + { + _fillLookup[""] = 0; + } + + for (int n = 0; n < _numFmts.Count; n++) + { + if (!_numFmtLookup!.ContainsKey(_numFmts[n])) + _numFmtLookup[_numFmts[n]] = n; + } + if (_borderSet.Count == 0) _borderSet.Add(null); + + for (int i = 0; i < columns.Length; i++) + { + var c = columns[i]; + + var font = (Bold: c.Bold == true, Size: c.FontSize ?? 11f, Color: c.FontColor, Name: c.FontName ?? "Calibri", + Italic: c.Italic == true, Underline: c.Underline == true, Strike: c.StrikeThrough == true); + var fontKey = new FontKey(font.Bold, font.Size, font.Color, font.Name, font.Italic, font.Underline, font.Strike); + int fontId; + if (_fontLookup.TryGetValue(fontKey, out var fid)) fontId = fid; + else + { + fontId = _fontSet.Count; + _fontSet.Add(font); + _fontLookup[fontKey] = fontId; + } + + int fillId = 0; + if (!string.IsNullOrEmpty(c.BackgroundColor)) + { + var fillKey = c.BackgroundColor!; + if (_fillLookup.TryGetValue(fillKey, out var fillid)) fillId = fillid; + else + { + fillId = _fillSet.Count; + _fillSet.Add(c.BackgroundColor); + _fillLookup[fillKey] = fillId; + } + } + + int borderId = 0; + if (c.BorderStyle.HasValue) + { + var bk = $"{(int)c.BorderStyle.Value}|{c.BorderColor ?? ""}"; + if (_borderLookup.TryGetValue(bk, out var bid)) borderId = bid; + else + { + borderId = _borderSet.Count; + _borderSet.Add(bk); + _borderLookup[bk] = borderId; + } + } + + int numFmtId = 0; + var format = c.Format; + if (format is { Length: > 0 }) + { + if (_numFmtLookup!.TryGetValue(format, out var nid)) numFmtId = 164 + nid; + } + + int wrap = c.Wrap == true ? 1 : 0; + int autoCenter = c.AutoCenter == true ? 1 : 0; + int vAlign = c.VerticalAlignment.HasValue ? (int)c.VerticalAlignment.Value : 0; + var xk = $"{fontId}|{fillId}|{borderId}|{numFmtId}|{wrap}|{autoCenter}|{vAlign}"; + int xfId; + if (_stylePoolLookup.TryGetValue(xk, out var xid)) xfId = xid; + else + { + xfId = _stylePool.Count; + _stylePool.Add(xk); + _stylePoolLookup[xk] = xfId; + } + xfIds[i] = xfId; + } + return xfIds; + } + + private void WriteStyles() + { + EmitStylePoolXml(); + } + + private Task WriteStylesAsync(CancellationToken cancellationToken) + { + return EmitStylePoolXmlAsync(cancellationToken); + } + + private List? _stylePool; + private Dictionary? _stylePoolLookup; + private List<(bool Bold, float Size, string? Color, string Name, bool Italic, bool Underline, bool Strike)>? _fontSet; + private Dictionary? _fontLookup; + private List? _fillSet; + private Dictionary? _fillLookup; + private List? _borderSet; + private Dictionary? _borderLookup; + private Dictionary? _numFmtLookup; + + private readonly struct FontKey : IEquatable + { + public readonly bool Bold; + public readonly float Size; + public readonly string? Color; + public readonly string Name; + public readonly bool Italic; + public readonly bool Underline; + public readonly bool Strike; + public FontKey(bool bold, float size, string? color, string name, bool italic, bool underline, bool strike) + { Bold = bold; Size = size; Color = color; Name = name; Italic = italic; Underline = underline; Strike = strike; } + public bool Equals(FontKey other) => + Bold == other.Bold && Size == other.Size && Color == other.Color && Name == other.Name && + Italic == other.Italic && Underline == other.Underline && Strike == other.Strike; + public override bool Equals(object? obj) => obj is FontKey f && Equals(f); + public override int GetHashCode() => +#if NETSTANDARD2_0 + ((Bold ? 1 : 0) * 397) ^ Size.GetHashCode() ^ (Color?.GetHashCode() ?? 0) + ^ (Name?.GetHashCode() ?? 0) ^ ((Italic ? 1 : 0) * 17) + ^ ((Underline ? 1 : 0) * 23) ^ ((Strike ? 1 : 0) * 31); +#else + HashCode.Combine(Bold, Size, Color, Name, Italic, Underline, Strike); +#endif + } + + private static readonly string _defaultStylesXml = + "\r\n" + + "\r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + "\r\n"; + + private void WriteDefaultStylesToSink() + { + using var es = _zip.OpenEntry("xl/styles.xml", _compression); + _sink.WriteUtf8(_defaultStylesXml); + _sink.FlushTo(es); + } + + private async Task WriteDefaultStylesToSinkAsync(CancellationToken cancellationToken) + { + var es = _zip.OpenEntry("xl/styles.xml", _compression); + try + { + _sink.WriteUtf8(_defaultStylesXml); + await _sink.FlushToAsync(es, cancellationToken).ConfigureAwait(false); + } + finally + { + await ForwardOnlyZipWriter.DisposeEntryStreamAsync(es).ConfigureAwait(false); + } + } + + private void BuildFullStylePoolXml(out StringBuilder sb) + { + sb = new StringBuilder(2048); + sb.Append("\r\n"); + sb.Append("\r\n"); + + if (_numFmts.Count > 0) + { + sb.Append(" \r\n"); + for (int i = 0; i < _numFmts.Count; i++) + { + sb.Append(" \r\n"); + } + sb.Append(" \r\n"); + } + + sb.Append(" \r\n"); + foreach (var font in _fontSet) + { + var bold = font.Bold; + var size = font.Size; + var color = font.Color; + var name = font.Name; + var italic = font.Italic; + var underline = font.Underline; + var strike = font.Strike; + sb.Append(" "); + sb.Append(""); + sb.Append(""); + if (bold) sb.Append(""); + if (italic) sb.Append(""); + if (strike) sb.Append(""); + if (color is not null) + { + sb.Append(""); + } + if (underline) sb.Append(""); + sb.Append("\r\n"); + } + sb.Append(" \r\n"); + + sb.Append(" \r\n"); + for (int i = 0; i < _fillSet.Count; i++) + { + if (i == 1) + { + sb.Append(" \r\n"); + continue; + } + var color = _fillSet[i]; + if (color is null) + { + sb.Append(" \r\n"); + } + else + { + sb.Append(" \r\n"); + } + } + sb.Append(" \r\n"); + + sb.Append(" \r\n"); + for (int i = 0; i < _borderSet.Count; i++) + { + var bk = _borderSet[i]; + if (i == 0 || bk is null) + { + sb.Append(" \r\n"); + } + else + { + var parts = bk.Split('|'); + var styleName = parts[0] switch + { + "1" => "thin", + "2" => "medium", + "3" => "thick", + "4" => "dashed", + "5" => "dotted", + "6" => "double", + _ => "thin", + }; + string colorAttr = $""; + sb.Append(" "); + sb.Append("").Append(colorAttr).Append(""); + sb.Append("").Append(colorAttr).Append(""); + sb.Append("").Append(colorAttr).Append(""); + sb.Append("").Append(colorAttr).Append(""); + sb.Append("\r\n"); + } + } + sb.Append(" \r\n"); + + sb.Append(" \r\n"); + + sb.Append(" \r\n"); + sb.Append(" \r\n"); + for (int i = 1; i < _stylePool.Count; i++) + { + var parts = _stylePool[i].Split('|'); + int fontId = int.Parse(parts[0]); + int fillId = int.Parse(parts[1]); + int borderId = int.Parse(parts[2]); + int numFmtId = int.Parse(parts[3]); + bool wrap = parts[4] == "1"; + bool autoCenter = parts[5] == "1"; + int vAlign = parts.Length > 6 ? int.Parse(parts[6]) : 0; + string align = autoCenter ? "center" : "general"; + bool hasAlign = wrap || autoCenter || vAlign > 0; + sb.Append(" 0) sb.Append(" applyNumberFormat=\"1\""); + if (fontId > 0) sb.Append(" applyFont=\"1\""); + if (fillId > 0) sb.Append(" applyFill=\"1\""); + if (borderId > 0) sb.Append(" applyBorder=\"1\""); + if (hasAlign) sb.Append(" applyAlignment=\"1\""); + if (hasAlign) + { + sb.Append("> 0) + { + string va = vAlign switch { 2 => "center", 3 => "bottom", _ => "top" }; + sb.Append(" vertical=\"").Append(va).Append("\""); + } + sb.Append("/>\r\n"); + } + else + { + sb.Append("/>\r\n"); + } + } + sb.Append(" \r\n"); + + sb.Append(" \r\n"); + sb.Append("\r\n"); + } + + private void EmitStylePoolXml() + { + if (_stylePool is null || _fontSet is null || _fillSet is null || _borderSet is null) + { + WriteDefaultStylesToSink(); + return; + } + +#if !DEBUG + if (_numFmts.Count == 0 && _stylePool.Count == 1) + { + WriteDefaultStylesToSink(); + return; + } +#endif + BuildFullStylePoolXml(out var sb); + WriteEntryStringBuilder("xl/styles.xml", sb); + +#if DEBUG + if (_numFmts.Count == 0 && _stylePool.Count == 1 && + !string.Equals(sb.ToString(), _defaultStylesXml, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Default styles.xml template is out of sync with the builder."); + } +#endif + } + + private async Task EmitStylePoolXmlAsync(CancellationToken cancellationToken) + { + if (_stylePool is null || _fontSet is null || _fillSet is null || _borderSet is null) + { + await WriteDefaultStylesToSinkAsync(cancellationToken).ConfigureAwait(false); + return; + } + +#if !DEBUG + if (_numFmts.Count == 0 && _stylePool.Count == 1) + { + await WriteDefaultStylesToSinkAsync(cancellationToken).ConfigureAwait(false); + return; + } +#endif + BuildFullStylePoolXml(out var sb); + await WriteEntryStringBuilderAsync("xl/styles.xml", sb, cancellationToken).ConfigureAwait(false); + } + + private bool _completed; + private Exception? _completionFault; + + /// + /// Finalizes the workbook and writes the ZIP footer. No further writes are permitted. + /// + public void Complete() + { + if (_completionFault is not null) + ExceptionDispatchInfo.Capture(_completionFault).Throw(); + if (_completed) return; + try + { + ValidateTables(); + if (_sheetNames.Count == 0) + throw new InvalidOperationException("At least one worksheet must be added before completing the workbook."); + if (_sheetStream is not null) + CloseCurrentSheet(_currentSheetIndex); + WriteWorkbookFinal(); + WriteWorkbookRelsFinal(); + WriteContentTypesFinal(); + WriteStyles(); + + if (_useSharedStrings && _sharedStrings.Count > 0) + WriteSharedStringsTable(); + + WriteTables(); + + for (int i = 0; i < _sheetNames.Count; i++) + WriteSheetRels(i); + + for (int i = 0; i < _sheetNames.Count; i++) + { + WriteComments(_sheets[i].Comments, i + 1); + WriteCommentsVml(_sheets[i].Comments, i + 1); + } + + WriteDrawingsAndImages(); + _completed = true; + } + catch (Exception ex) + { + _completionFault = ex; + throw; + } + } + + /// + /// Asynchronously finalizes the workbook and writes the ZIP footer. No further writes are permitted. + /// + public async Task CompleteAsync(CancellationToken cancellationToken = default) + { + if (_completionFault is not null) + ExceptionDispatchInfo.Capture(_completionFault).Throw(); + try + { + await CompleteAsyncCore(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _completionFault = ex; + throw; + } + } + + private async Task CompleteAsyncCore(CancellationToken cancellationToken) + { + if (_completed) return; + ValidateTables(); + + if (_sheetStream is not null) + { + await CloseCurrentSheetAsync(_currentSheetIndex, cancellationToken).ConfigureAwait(false); + } + + if (_sheetNames.Count == 0) + throw new InvalidOperationException("At least one worksheet must be added before completing the workbook."); + await WriteWorkbookFinalAsync(cancellationToken).ConfigureAwait(false); + await WriteWorkbookRelsFinalAsync(cancellationToken).ConfigureAwait(false); + await WriteContentTypesFinalAsync(cancellationToken).ConfigureAwait(false); + await WriteStylesAsync(cancellationToken).ConfigureAwait(false); + + if (_useSharedStrings && _sharedStrings.Count > 0) + { + await WriteSharedStringsTableAsync(cancellationToken).ConfigureAwait(false); + } + + await WriteTablesAsync(cancellationToken).ConfigureAwait(false); + + for (int i = 0; i < _sheetNames.Count; i++) + { + await WriteSheetRelsAsync(i, cancellationToken).ConfigureAwait(false); + } + + for (int i = 0; i < _sheetNames.Count; i++) + { + await WriteCommentsAsync(_sheets[i].Comments, i + 1, cancellationToken).ConfigureAwait(false); + await WriteCommentsVmlAsync(_sheets[i].Comments, i + 1, cancellationToken).ConfigureAwait(false); + } + + await WriteDrawingsAndImagesAsync(cancellationToken).ConfigureAwait(false); + + _completed = true; + } + + /// + /// Finalizes the workbook if needed and releases all resources. + /// + public void Dispose() + { + if (_disposed) return; + + try + { + if (!_completed && _completionFault is null && _sheetNames.Count > 0) Complete(); + } + finally + { + _disposed = true; + try { _zip.Dispose(); } + catch when (_completionFault is not null) { } + _sink.Dispose(); + } + } + + /// + /// Asynchronously finalizes the workbook if needed and releases all resources. + /// + public async ValueTask DisposeAsync() + { + if (_disposed) return; + + try + { + if (!_completed && _completionFault is null && _sheetNames.Count > 0) await CompleteAsync().ConfigureAwait(false); + } + finally + { + _disposed = true; + try { await _zip.DisposeAsync().ConfigureAwait(false); } + catch when (_completionFault is not null) { } + _sink.Dispose(); + } + } + + } +} diff --git a/src/Magicodes.IE.IO/ExportProfile.cs b/src/Magicodes.IE.IO/ExportProfile.cs new file mode 100644 index 00000000..73d79e9e --- /dev/null +++ b/src/Magicodes.IE.IO/ExportProfile.cs @@ -0,0 +1,1035 @@ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace Magicodes.IE.IO +{ + + /// + /// Configuration used to build an export. + /// The profile is frozen before the first worksheet is written. + /// + public sealed class ExportProfile + { + private string? _sheetName; + private float? _fontSize; + private bool _freezeHeader = true; + private double? _defaultRowHeight; + private bool _autoSst; + private Action? _headerFilter; + private Func? _rowFilter; + private List? _mergeCells; + private string? _autoFilterRef; + private readonly List<(string Ref, string Uri)> _hyperlinks = new(); + private readonly Dictionary _columns = new(StringComparer.Ordinal); + private readonly HashSet _ignored = new(StringComparer.Ordinal); + private bool _frozen; + + /// + /// Gets the name of the worksheet. + /// + public string? SheetName => _sheetName; + /// + /// Gets the column configurations, keyed by property name. + /// + public IReadOnlyDictionary Columns => _columns; + /// + /// Gets the property names that are excluded from the export. + /// + public IReadOnlyCollection Ignored => _ignored; + /// + /// Gets the default font size, in points, applied to all columns. + /// + public float? FontSize => _fontSize; + /// + /// Gets a value indicating whether the header row is frozen. The default is . + /// + public bool FreezeHeader => _freezeHeader; + /// + /// Gets the default row height, in points. + /// + public double? DefaultRowHeight => _defaultRowHeight; + /// + /// Gets a value indicating whether the shared string table is enabled. + /// + public bool AutoSst => _autoSst; + /// + /// Gets the cell ranges that are merged. + /// + public IReadOnlyList? MergeCells => _mergeCells; + /// + /// Gets the auto-filter range. + /// + public string? AutoFilterRef => _autoFilterRef; + /// + /// Gets the hyperlinks to write. + /// + public IReadOnlyList<(string Ref, string Uri)> Hyperlinks => _hyperlinks; + /// + /// Gets the callback invoked after the header columns are resolved, to further adjust column metadata. + /// + public Action? HeaderFilter => _headerFilter; + /// + /// Gets the predicate that selects which rows are exported; rows for which it returns are skipped. + /// + public Func? RowFilter => _rowFilter; + + /// + /// Sets the worksheet name. Returns this instance for chaining. + /// + public ExportProfile Sheet(string name) + { + EnsureNotFrozen(); + _sheetName = name; + return this; + } + + /// + /// Configures a single column. The must be a direct property access, for example x => x.Name. Returns this instance for chaining. + /// + public ExportProfile Column(Expression> selector, Action configure) + { + EnsureNotFrozen(); + if (selector is null) throw new ArgumentNullException(nameof(selector)); + if (configure is null) throw new ArgumentNullException(nameof(configure)); + + var propName = ExtractPropertyName(selector); + var cfg = new ColumnConfig(); + configure(cfg); + _columns[propName] = cfg; + return this; + } + + /// + /// Excludes the specified property from the export. Returns this instance for chaining. + /// + public ExportProfile Ignore(Expression> selector) + { + EnsureNotFrozen(); + if (selector is null) throw new ArgumentNullException(nameof(selector)); + var propName = ExtractPropertyName(selector); + _ignored.Add(propName); + return this; + } + + /// + /// Sets the default font size for all columns. Returns this instance for chaining. + /// + public ExportProfile WithFontSize(float size) + { + EnsureNotFrozen(); + _fontSize = size; + return this; + } + + /// + /// Sets whether the header row is frozen. Returns this instance for chaining. + /// + public ExportProfile WithFreezeHeader(bool value = true) + { + EnsureNotFrozen(); + _freezeHeader = value; + return this; + } + + /// + /// Sets the default row height. Returns this instance for chaining. + /// + public ExportProfile WithDefaultRowHeight(double height) + { + EnsureNotFrozen(); + _defaultRowHeight = height; + return this; + } + + /// + /// Sets whether the shared string table is enabled. Returns this instance for chaining. + /// + public ExportProfile WithAutoSst(bool value = true) + { + EnsureNotFrozen(); + _autoSst = value; + return this; + } + + /// + /// Adds a cell range to merge. Returns this instance for chaining. + /// + public ExportProfile WithMergeCells(string range) + { + EnsureNotFrozen(); + _mergeCells ??= new List(); + _mergeCells.Add(range); + return this; + } + + /// + /// Sets the auto-filter range. Returns this instance for chaining. + /// + public ExportProfile WithAutoFilter(string cellRange) + { + EnsureNotFrozen(); + _autoFilterRef = cellRange; + return this; + } + + /// + /// Adds a hyperlink to a cell. Returns this instance for chaining. + /// + public ExportProfile WithHyperlink(string cellRef, string uri) + { + EnsureNotFrozen(); + _hyperlinks.Add((cellRef, uri)); + return this; + } + + /// + /// Registers a callback that adjusts the resolved header columns. Returns this instance for chaining. + /// + public ExportProfile WithHeader(Action filter) + { + EnsureNotFrozen(); + _headerFilter = filter ?? throw new ArgumentNullException(nameof(filter)); + return this; + } + + /// + /// Exports only the rows that satisfy the predicate. Returns this instance for chaining. + /// + public ExportProfile Where(Func predicate) + { + EnsureNotFrozen(); + _rowFilter = predicate ?? throw new ArgumentNullException(nameof(predicate)); + return this; + } + + /// + /// Freezes the configuration. Called by the write pipeline; once frozen, the profile cannot be modified. + /// + public void Freeze() + { + _frozen = true; + } + + /// + /// Gets a value indicating whether the configuration is frozen. + /// + public bool IsFrozen => _frozen; + + private void EnsureNotFrozen() + { + if (_frozen) throw new InvalidOperationException("ExportProfile is frozen and cannot be mutated. Complete configuration before freezing."); + } + + private static string ExtractPropertyName(Expression> selector) + { + + if (selector.Body is MemberExpression m && m.Expression is ParameterExpression) + return m.Member.Name; + + if (selector.Body is UnaryExpression { Operand: MemberExpression m2 } && m2.Expression is ParameterExpression) + return m2.Member.Name; + throw new ArgumentException("selector must be a direct property access, e.g. x => x.Foo", nameof(selector)); + } + } + + /// + /// Column accessors and styles prepared for the writer. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public class RowPlan + { + /// + /// Columns included in the export. + /// + public ColumnMeta[] Columns { get; } + /// + /// Object-based accessors, in column order. + /// + public Func[] Getters { get; } + /// + /// Style IDs, in column order. + /// + public int[] StyleIds { get; } + + /// + /// Creates a row plan from the prepared column accessors and styles. + /// + public RowPlan(ColumnMeta[] columns, Func[] getters, int[] styleIds) + { + Columns = columns; Getters = getters; StyleIds = styleIds; + } + + public string[] BuildNumFmts() + { + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + for (int i = 0; i < Columns.Length; i++) + { + if (Columns[i].Format is string fmt && seen.Add(fmt)) + result.Add(fmt); + } + return result.ToArray(); + } + } + + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class TypedRowPlan : RowPlan + { + /// + /// Strongly typed accessors, in column order. + /// + public Func[] TypedGetters { get; } + + /// + /// Generated cell writers, when available. + /// + public Action?[] TypedCellWriters { get; } + + /// + /// Indicates whether any column writes a formula. + /// + public bool HasFormulas { get; } + + /// + /// Creates a strongly typed row plan. + /// + public TypedRowPlan(ColumnMeta[] columns, Func[] objectGetters, Func[] typedGetters, int[] styleIds, Action?[] typedCellWriters, bool hasFormulas) + : base(columns, objectGetters, styleIds) + { + TypedGetters = typedGetters; + TypedCellWriters = typedCellWriters; + HasFormulas = hasFormulas; + } + } + + /// + /// Builds and caches row plans for export profiles. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static class RowPlanBuilder + { + private static class PlanCache + { + internal static readonly System.Runtime.CompilerServices.ConditionalWeakTable, RowPlan> Untyped = new(); + internal static readonly System.Runtime.CompilerServices.ConditionalWeakTable, TypedRowPlan> Typed = new(); + } + + private static readonly bool _isDynamicCodeAvailable = +#if NETSTANDARD2_0 + true; +#else + System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeCompiled; +#endif + + /// + /// Builds the reflection-based row plan for . + /// +#if !NETSTANDARD2_0 + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection over T's properties is intentional; user opts in by calling Build")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Expression.Compile used for typed fast path; fallback to reflection path in AOT")] +#endif + public static RowPlan Build(ExportProfile profile) + { + return PlanCache.Untyped.GetValue(profile, static p => ReflectColumns(p, typed: false)); + } + + /// + /// Builds the strongly typed row plan for . + /// +#if !NETSTANDARD2_0 + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection over T's properties is intentional; user opts in by calling BuildTyped")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Expression.Compile used for typed fast path; fallback to reflection path in AOT")] +#endif + public static TypedRowPlan BuildTyped(ExportProfile profile) + { + return PlanCache.Typed.GetValue(profile, static p => (TypedRowPlan)ReflectColumns(p, typed: true)); + } + + private static RowPlan ReflectColumns(ExportProfile profile, bool typed) + { + var generated = XlsxGeneratedTypeMetadataRegistry.TryGet(); + if (generated is not null) + return BuildGeneratedColumns(profile, generated, typed); + + var type = typeof(T); + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + var columns = new List(props.Length); + var objectGetters = new List>(props.Length); + var typedGetters = new List>(props.Length); + var propUnderlyings = new List(props.Length); + var propInfos = new List(props.Length); + var styleIds = new List(props.Length); + bool hasFormulas = false; + + var formatToId = new Dictionary(StringComparer.Ordinal); + var ordered = new List<(PropertyInfo Prop, ColumnConfig? Cfg, int DeclOrder)>(props.Length); + int declOrder = 0; + foreach (var p in props) + { + if (!p.CanRead) continue; + var name = p.Name; + var exporterAttr = p.GetCustomAttribute(inherit: true); + if (exporterAttr is { IsIgnore: true }) continue; + if (profile.Ignored.Contains(name)) continue; + profile.Columns.TryGetValue(name, out var cfg); + ordered.Add((p, cfg, declOrder++)); + } + ordered.Sort((a, b) => + { + int ai = a.Cfg?.Index + ?? (a.Prop.GetCustomAttribute()?.Index is int ix && ix >= 0 ? ix : int.MaxValue); + int bi = b.Cfg?.Index + ?? (b.Prop.GetCustomAttribute()?.Index is int iy && iy >= 0 ? iy : int.MaxValue); + // Stable tiebreaker by declaration order — List.Sort is not stable, so without + // this, columns sharing an Index (or all defaulting to int.MaxValue) would have + // non-deterministic order across runs/platforms. + int c = ai.CompareTo(bi); + return c != 0 ? c : a.DeclOrder.CompareTo(b.DeclOrder); + }); + + int index = 0; + foreach (var (p, cfg, _) in ordered) + { + var exporterAttr = p.GetCustomAttribute(inherit: true); + + var format = cfg?.Format ?? exporterAttr?.Format; + if (format is null) + { + var attr = p.GetCustomAttribute(inherit: true); + if (attr is not null && !string.IsNullOrEmpty(attr.DataFormatString)) + format = attr.DataFormatString; + } + + int styleId = 0; + if (format is not null) + { + if (!formatToId.TryGetValue(format, out styleId)) + { + styleId = formatToId.Count + 1; + formatToId[format] = styleId; + } + } + + string displayName = cfg?.Name + ?? exporterAttr?.Name + ?? p.GetCustomAttribute(inherit: true)?.GetName() + ?? p.GetCustomAttribute(inherit: true)?.Description + ?? p.Name; + + double? width = cfg?.Width; + if (width is null && exporterAttr is { Width: > 0 }) + { + width = exporterAttr.Width; + } + + var hidden = cfg?.Hidden ?? false; + bool? bold = cfg?.Bold; + bool? wrap = cfg?.Wrap; + float? fontSize = cfg?.FontSize ?? profile.FontSize; + bool? autoCenter = cfg?.AutoCenter; + string? bgColor = cfg?.BackgroundColor; + string? fontColor = cfg?.FontColor; + string? fontName = cfg?.FontName; + Magicodes.IE.IO.BorderStyle? borderStyle = cfg?.BorderStyle; + string? borderColor = cfg?.BorderColor; + bool? italic = cfg?.Italic; + bool? underline = cfg?.Underline; + bool? strikeThrough = cfg?.StrikeThrough; + Magicodes.IE.IO.VerticalAlignment? verticalAlignment = cfg?.VerticalAlignment; + double? rowHeight = cfg?.RowHeight; + + var meta = new ColumnMeta(p.Name, displayName, format, width, hidden, styleId, index, + bold: bold, wrap: wrap, fontSize: fontSize, autoCenter: autoCenter, + backgroundColor: bgColor, fontColor: fontColor, fontName: fontName, + borderStyle: borderStyle, borderColor: borderColor, + italic: italic, underline: underline, strikeThrough: strikeThrough, + verticalAlignment: verticalAlignment, rowHeight: rowHeight, formula: cfg?.Formula); + profile.HeaderFilter?.Invoke(meta); + + columns.Add(meta); + var (objGetter, tyGetter) = BuildGettersPair(p); + objectGetters.Add(objGetter); + typedGetters.Add(tyGetter); + propUnderlyings.Add(p.PropertyType); + propInfos.Add(p); + styleIds.Add(styleId); + if (meta.Formula is not null) hasFormulas = true; + index++; + } + + if (typed) + { + var cellUnderlying = new Type[typedGetters.Count]; + for (int i = 0; i < cellUnderlying.Length; i++) + cellUnderlying[i] = Nullable.GetUnderlyingType(propUnderlyings[i]) ?? propUnderlyings[i]; + var cellWriters = BuildTypedCellWriters(propInfos.ToArray(), cellUnderlying); + return new TypedRowPlan( + columns.ToArray(), + objectGetters.ToArray(), + typedGetters.ToArray(), + styleIds.ToArray(), + cellWriters, + hasFormulas); + } + return new RowPlan(columns.ToArray(), objectGetters.ToArray(), styleIds.ToArray()); + } + + private static RowPlan BuildGeneratedColumns( + ExportProfile profile, + IReadOnlyList> properties, + bool typed) + { + var ordered = new List>(properties.Count); + for (int i = 0; i < properties.Count; i++) + { + var property = properties[i]; + if (property.IsIgnored || profile.Ignored.Contains(property.Name)) + continue; + ordered.Add(property); + } + + ordered.Sort((a, b) => + { + int ai = profile.Columns.TryGetValue(a.Name, out var ac) && ac?.Index is int aix + ? aix + : a.ExportIndex >= 0 ? a.ExportIndex : int.MaxValue; + int bi = profile.Columns.TryGetValue(b.Name, out var bc) && bc?.Index is int bix + ? bix + : b.ExportIndex >= 0 ? b.ExportIndex : int.MaxValue; + return ai.CompareTo(bi); + }); + + var columns = new List(ordered.Count); + var objectGetters = new List>(ordered.Count); + var typedGetters = new List>(ordered.Count); + var styleIds = new List(ordered.Count); + var formatToId = new Dictionary(StringComparer.Ordinal); + bool hasFormulas = false; + + for (int i = 0; i < ordered.Count; i++) + { + var property = ordered[i]; + profile.Columns.TryGetValue(property.Name, out var cfg); + + var format = cfg?.Format ?? property.Format; + int styleId = 0; + if (format is not null) + { + if (!formatToId.TryGetValue(format, out styleId)) + { + styleId = formatToId.Count + 1; + formatToId[format] = styleId; + } + } + + var displayName = cfg?.Name ?? property.DisplayName ?? property.Description ?? property.Name; + var width = cfg?.Width ?? property.Width; + var meta = new ColumnMeta( + property.Name, + displayName, + format, + width, + cfg?.Hidden ?? false, + styleId, + i, + bold: cfg?.Bold, + wrap: cfg?.Wrap, + fontSize: cfg?.FontSize ?? profile.FontSize, + autoCenter: cfg?.AutoCenter, + backgroundColor: cfg?.BackgroundColor, + fontColor: cfg?.FontColor, + fontName: cfg?.FontName, + borderStyle: cfg?.BorderStyle, + borderColor: cfg?.BorderColor, + italic: cfg?.Italic, + underline: cfg?.Underline, + strikeThrough: cfg?.StrikeThrough, + verticalAlignment: cfg?.VerticalAlignment, + rowHeight: cfg?.RowHeight, + formula: cfg?.Formula); + profile.HeaderFilter?.Invoke(meta); + + columns.Add(meta); + objectGetters.Add(property.ObjectGetter); + typedGetters.Add(property.Getter); + styleIds.Add(styleId); + if (meta.Formula is not null) hasFormulas = true; + } + + if (!typed) + return new RowPlan(columns.ToArray(), objectGetters.ToArray(), styleIds.ToArray()); + + var cellWriters = new Action?[ordered.Count]; + for (int i = 0; i < ordered.Count; i++) + cellWriters[i] = ordered[i].CellWriter; + + return new TypedRowPlan( + columns.ToArray(), + objectGetters.ToArray(), + typedGetters.ToArray(), + styleIds.ToArray(), + cellWriters, + hasFormulas); + } + + private static Action?[] BuildTypedCellWriters(PropertyInfo[] props, Type[] underlyingTypes) + { + var generated = XlsxGeneratedRowWritersRegistry.TryGet(); + if (generated is not null) + { + var arr = new Action?[underlyingTypes.Length]; + for (int i = 0; i < underlyingTypes.Length; i++) + { + if (generated.TryGetValue(props[i].Name, out var cw)) + arr[i] = cw; + } + return arr; + } + + int n = props.Length; + var writers = new Action?[n]; + for (int i = 0; i < n; i++) + { + var prop = props[i]; + var underlying = underlyingTypes[i]; + var nullableUnderlying = Nullable.GetUnderlyingType(prop.PropertyType); + if (nullableUnderlying is not null) + { + if (nullableUnderlying == typeof(int)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + var value = getter(item); + if (value.HasValue) w.WriteNumberCell(sid, value.Value); + else w.WriteEmptyCell(sid); + }; + } + else if (nullableUnderlying == typeof(long)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + var value = getter(item); + if (value.HasValue) w.WriteNumberCell(sid, value.Value); + else w.WriteEmptyCell(sid); + }; + } + else if (nullableUnderlying == typeof(double)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + var value = getter(item); + if (value.HasValue) w.WriteNumberCell(sid, value.Value); + else w.WriteEmptyCell(sid); + }; + } + else if (nullableUnderlying == typeof(decimal)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + var value = getter(item); + if (value.HasValue) w.WriteNumberCell(sid, (double)value.Value); + else w.WriteEmptyCell(sid); + }; + } + else if (nullableUnderlying == typeof(bool)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + var value = getter(item); + if (value.HasValue) w.WriteBoolCell(sid, value.Value); + else w.WriteEmptyCell(sid); + }; + } + else if (nullableUnderlying == typeof(DateTime)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + var value = getter(item); + if (value.HasValue) w.WriteNumberCell(sid, value.Value.ToOADate()); + else w.WriteEmptyCell(sid); + }; + } + else if (nullableUnderlying == typeof(DateTimeOffset)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + var value = getter(item); + if (value.HasValue) w.WriteStringCell(sid, value.Value.ToString("O", System.Globalization.CultureInfo.InvariantCulture)); + else w.WriteEmptyCell(sid); + }; + } + else if (nullableUnderlying.IsEnum) + { + var getter = BuildNullableEnumNumberGetter(prop); + writers[i] = (w, item, sid) => + { + var value = getter(item); + if (value.HasValue) w.WriteNumberCell(sid, value.Value); + else w.WriteEmptyCell(sid); + }; + } + } + else if (underlying == typeof(string)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + var value = getter(item); + if (value is null) w.WriteEmptyCell(sid); + else w.WriteStringCell(sid, value); + }; + } + else if (underlying == typeof(int)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => w.WriteNumberCell(sid, getter(item)); + } + else if (underlying == typeof(long)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => w.WriteNumberCell(sid, getter(item)); + } + else if (underlying == typeof(double)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + w.WriteNumberCell(sid, getter(item)); + }; + } + else if (underlying == typeof(decimal)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + w.WriteNumberCell(sid, (double)getter(item)); + }; + } + else if (underlying == typeof(bool)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => + { + w.WriteBoolCell(sid, getter(item)); + }; + } + else if (underlying == typeof(DateTime)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => w.WriteNumberCell(sid, getter(item).ToOADate()); + } + else if (underlying == typeof(DateTimeOffset)) + { + var getter = BuildTypedGetter(prop); + writers[i] = (w, item, sid) => w.WriteStringCell(sid, getter(item).ToString("O", System.Globalization.CultureInfo.InvariantCulture)); + } + else if (underlying.IsEnum) + { + var getter = BuildEnumNumberGetter(prop); + writers[i] = (w, item, sid) => + { + w.WriteNumberCell(sid, getter(item)); + }; + } + } + return writers; + } + + private static (Func objectGetter, Func typedGetter) BuildGettersPair(PropertyInfo p) + { + var generated = XlsxGeneratedGettersRegistry.TryGet(typeof(T)); + if (generated is not null && generated.TryGetValue(p.Name, out var genGetter)) + { + if (XlsxGeneratedTypedGettersRegistry.TryGet(typeof(T)) is { } typedReg + && typedReg.TryGetValue(p.Name, out var typedDel)) + { + return (genGetter, (Func)typedDel); + } + + var typed = BuildTypedBridge(genGetter); + return (genGetter, typed); + } + + var propertyType = p.PropertyType; + var underlying = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + var isNullableValueType = Nullable.GetUnderlyingType(propertyType) is not null; + + if (!_isDynamicCodeAvailable) + { + return BuildReflectionGettersPair(p, underlying); + } + + var objectCompiled = BuildExpressionGetter(p); + Func typedGetter = default!; + if (p.DeclaringType!.IsValueType) + { + var objFromT = BuildExpressionGetterBoxT(p); + typedGetter = item => + { + var boxed = objFromT(item); + return boxed is null ? CellValue.Null : ToCellValue(boxed, underlying); + }; + } + else if (isNullableValueType) + { + if (underlying == typeof(int)) + { + typedGetter = BuildNullableValueGetter(p, v => CellValue.FromInteger(v)); + } + else if (underlying == typeof(long)) + { + typedGetter = BuildNullableValueGetter(p, CellValue.FromInteger); + } + else if (underlying == typeof(ulong)) + { + typedGetter = BuildNullableValueGetter(p, v => CellValue.FromNumber((double)v)); + } + else if (underlying == typeof(double)) + { + typedGetter = BuildNullableValueGetter(p, CellValue.FromNumber); + } + else if (underlying == typeof(decimal)) + { + typedGetter = BuildNullableValueGetter(p, v => CellValue.FromNumber((double)v)); + } + else if (underlying == typeof(DateTime)) + { + typedGetter = BuildNullableValueGetter(p, CellValue.FromDateTime); + } + else if (underlying == typeof(bool)) + { + typedGetter = BuildNullableValueGetter(p, CellValue.FromBool); + } + else + { + typedGetter = item => + { + var boxed = objectCompiled(item); + return boxed is null ? CellValue.Null : ToCellValue(boxed, underlying); + }; + } + } + else if (underlying == typeof(string)) + { + var strGetter = (Func)BuildExpressionGetterTyped(p); + typedGetter = item => strGetter(item) is { } s ? CellValue.FromString(s) : CellValue.Null; + } + else if (underlying == typeof(int)) + { + var intGetter = (Func)BuildExpressionGetterTyped(p); + typedGetter = item => CellValue.FromInteger(intGetter(item)); + } + else if (underlying == typeof(long)) + { + var lngGetter = (Func)BuildExpressionGetterTyped(p); + typedGetter = item => CellValue.FromInteger(lngGetter(item)); + } + else if (underlying == typeof(ulong)) + { + var ulngGetter = (Func)BuildExpressionGetterTyped(p); + typedGetter = item => CellValue.FromNumber((double)ulngGetter(item)); + } + else if (underlying == typeof(double)) + { + var dblGetter = (Func)BuildExpressionGetterTyped(p); + typedGetter = item => CellValue.FromNumber(dblGetter(item)); + } + else if (underlying == typeof(decimal)) + { + var decGetter = (Func)BuildExpressionGetterTyped(p); + typedGetter = item => CellValue.FromNumber((double)decGetter(item)); + } + else if (underlying == typeof(DateTime)) + { + var dtGetter = (Func)BuildExpressionGetterTyped(p); + typedGetter = item => CellValue.FromDateTime(dtGetter(item)); + } + else if (underlying == typeof(bool)) + { + var bGetter = (Func)BuildExpressionGetterTyped(p); + typedGetter = item => CellValue.FromBool(bGetter(item)); + } + else if (underlying.IsEnum) + { + var getter = BuildEnumNumberGetter(p); + typedGetter = item => CellValue.FromNumber(getter(item)); + } + else + { + typedGetter = item => + { + var boxed = objectCompiled(item); + return boxed is null ? CellValue.Null : ToCellValue(boxed, underlying); + }; + } + var objectGetter = (Func)(item => + { + var boxed = objectCompiled(item); + if (boxed is null) return CellValue.Null; + return ToCellValue(boxed, underlying); + }); + return (objectGetter, typedGetter); + } + + private static Func BuildExpressionGetter(PropertyInfo p) + { + var itemParam = Expression.Parameter(typeof(object), "item"); + Expression body; + if (p.DeclaringType!.IsValueType) + { + var unboxed = Expression.Unbox(itemParam, p.DeclaringType); + var access = Expression.Property(unboxed, p); + body = Expression.Convert(access, typeof(object)); + } + else + { + var cast = Expression.Convert(itemParam, p.DeclaringType); + var access = Expression.Property(cast, p); + body = Expression.Convert(access, typeof(object)); + } + return Expression.Lambda>(body, itemParam).Compile(); + } + + private static Func BuildExpressionGetterBoxT(PropertyInfo p) + { + var itemParam = Expression.Parameter(typeof(T), "item"); + var access = Expression.Property(itemParam, p); + var body = Expression.Convert(access, typeof(object)); + return Expression.Lambda>(body, itemParam).Compile(); + } + + private static Func BuildNullableValueGetter(PropertyInfo p, Func convert) + where TValue : struct + { + var getter = (Func)BuildExpressionGetterTyped(p); + return item => + { + var value = getter(item); + return value.HasValue ? convert(value.Value) : CellValue.Null; + }; + } + + private static Delegate BuildExpressionGetterTyped(PropertyInfo p) + { + var itemParam = Expression.Parameter(typeof(T), "item"); + var access = Expression.Property(itemParam, p); + return Expression.Lambda(access, itemParam).Compile(); + } + + private static Func BuildTypedGetter(PropertyInfo p) + { + var itemParam = Expression.Parameter(typeof(T), "item"); + var access = Expression.Property(itemParam, p); + return Expression.Lambda>(access, itemParam).Compile(); + } + + private static (Func objectGetter, Func typedGetter) BuildReflectionGettersPair(PropertyInfo p, Type underlying) + { + Func objectGetter = item => + { + if (item is null) return CellValue.Null; + object? boxed; + try { boxed = p.GetValue(item); } + catch (System.Reflection.TargetException) { return CellValue.Null; } + return boxed is null ? CellValue.Null : ToCellValue(boxed, underlying); + }; + Func typedGetter; + if (typeof(T).IsValueType) + { + typedGetter = item => + { + object? boxed = item; + return objectGetter(boxed); + }; + } + else + { + typedGetter = item => objectGetter(item); + } + return (objectGetter, typedGetter); + } + + private static Func BuildTypedBridge(Func objGetter) + { + if (typeof(T).IsValueType) + { + return item => + { + object? boxed = item; + return objGetter(boxed); + }; + } + return item => objGetter(item); + } + + private static Func BuildEnumNumberGetter(PropertyInfo p) + { + var itemParam = Expression.Parameter(typeof(T), "item"); + var access = Expression.Property(itemParam, p); + // Route enums through double: Excel stores numbers as IEEE754 double anyway, and + // this avoids the (long) overflow for ulong-backed enums with values > long.MaxValue. + var body = Expression.Convert(access, typeof(double)); + return Expression.Lambda>(body, itemParam).Compile(); + } + + private static Func BuildNullableEnumNumberGetter(PropertyInfo p) + { + var itemParam = Expression.Parameter(typeof(T), "item"); + var access = Expression.Property(itemParam, p); + var hasValue = Expression.Property(access, "HasValue"); + var value = Expression.Property(access, "Value"); + var doubleValue = Expression.Convert(value, typeof(double)); + var body = Expression.Condition( + hasValue, + Expression.Convert(doubleValue, typeof(double?)), + Expression.Constant(null, typeof(double?))); + return Expression.Lambda>(body, itemParam).Compile(); + } + + internal static CellValue ToCellValue(object boxed, Type underlying) + { + if (underlying.IsEnum) return CellValue.FromNumber(Convert.ToDouble(boxed, System.Globalization.CultureInfo.InvariantCulture)); + if (underlying == typeof(string)) return CellValue.FromString((string)boxed); + if (underlying == typeof(bool)) return CellValue.FromBool((bool)boxed); + if (underlying == typeof(DateTime)) return CellValue.FromDateTime((DateTime)boxed); + if (underlying == typeof(DateTimeOffset)) return CellValue.FromString(((DateTimeOffset)boxed).ToString("O", System.Globalization.CultureInfo.InvariantCulture)); + if (underlying == typeof(int) || underlying == typeof(long) || underlying == typeof(short) || underlying == typeof(byte)) + return CellValue.FromInteger(Convert.ToInt64(boxed, System.Globalization.CultureInfo.InvariantCulture)); + if (underlying == typeof(ulong)) + // Excel stores numbers as IEEE754 double; routing through double avoids the + // (long) overflow for values > long.MaxValue. + return CellValue.FromNumber((double)(ulong)boxed); + if (underlying == typeof(uint) || underlying == typeof(ushort) || underlying == typeof(sbyte)) + { + long l = underlying switch + { + Type t when t == typeof(uint) => (long)(uint)boxed!, + Type t when t == typeof(ushort) => (long)(ushort)boxed!, + Type t when t == typeof(sbyte) => (long)(sbyte)boxed!, + _ => 0L, + }; + return CellValue.FromInteger(l); + } + if (underlying == typeof(float) || underlying == typeof(double) || underlying == typeof(decimal)) + return CellValue.FromNumber(Convert.ToDouble(boxed, System.Globalization.CultureInfo.InvariantCulture)); + if (underlying == typeof(Guid)) return CellValue.FromString(boxed.ToString()); + return CellValue.FromString(boxed.ToString()); + } + } +} diff --git a/src/Magicodes.IE.IO/Features/Comment.cs b/src/Magicodes.IE.IO/Features/Comment.cs new file mode 100644 index 00000000..af970d7e --- /dev/null +++ b/src/Magicodes.IE.IO/Features/Comment.cs @@ -0,0 +1,46 @@ + +using System; + +namespace Magicodes.IE.IO +{ + + /// + /// A cell comment in a worksheet. + /// + public sealed class Comment + { + + /// + /// Gets the zero-based row of the cell. + /// + public int Row { get; } + + /// + /// Gets the zero-based column of the cell. + /// + public int Col { get; } + + /// + /// Gets the author of the comment. + /// + public string Author { get; } + + /// + /// Gets the comment text. + /// + public string Text { get; } + + /// + /// Initializes a new instance of the class. + /// + public Comment(int row, int col, string author, string text) + { + if (row < 0) throw new ArgumentOutOfRangeException(nameof(row)); + if (col < 0) throw new ArgumentOutOfRangeException(nameof(col)); + Row = row; + Col = col; + Author = author ?? throw new ArgumentNullException(nameof(author)); + Text = text ?? throw new ArgumentNullException(nameof(text)); + } + } +} diff --git a/src/Magicodes.IE.IO/Features/ConditionalFormatting.cs b/src/Magicodes.IE.IO/Features/ConditionalFormatting.cs new file mode 100644 index 00000000..45a56740 --- /dev/null +++ b/src/Magicodes.IE.IO/Features/ConditionalFormatting.cs @@ -0,0 +1,75 @@ +using System; + +namespace Magicodes.IE.IO +{ + /// + /// The comparison operator for a conditional-formatting rule. + /// + public enum CfOperator + { + Equal, + NotEqual, + GreaterThan, + GreaterThanOrEqual, + LessThan, + LessThanOrEqual, + Between, + NotBetween, + } + + /// + /// A single conditional-formatting rule. + /// + public sealed class CfRule + { + /// + /// Gets the comparison operator. + /// + public CfOperator Operator { get; } + + /// + /// Gets the first formula or value. + /// + public string Formula1 { get; } + + /// + /// Gets the second formula or value, used only for range comparisons. + /// + public string? Formula2 { get; } + + /// + /// Creates a conditional-formatting rule. + /// + public CfRule(CfOperator op, string formula1, string? formula2 = null) + { + Operator = op; + Formula1 = formula1 ?? throw new ArgumentNullException(nameof(formula1)); + Formula2 = formula2; + } + } + + /// + /// Conditional formatting applied to a cell range. + /// + public sealed class ConditionalFormatting + { + /// + /// Gets the cell range the rules apply to. + /// + public string CellRange { get; } + + /// + /// Gets the rules, evaluated in order. + /// + public CfRule[] Rules { get; } + + /// + /// Creates a set of conditional-formatting rules for the specified range. + /// + public ConditionalFormatting(string cellRange, params CfRule[] rules) + { + CellRange = cellRange ?? throw new ArgumentNullException(nameof(cellRange)); + Rules = rules ?? throw new ArgumentNullException(nameof(rules)); + } + } +} diff --git a/src/Magicodes.IE.IO/Features/DataValidation.cs b/src/Magicodes.IE.IO/Features/DataValidation.cs new file mode 100644 index 00000000..f0c5f57f --- /dev/null +++ b/src/Magicodes.IE.IO/Features/DataValidation.cs @@ -0,0 +1,128 @@ +using System; +using System.Globalization; + +namespace Magicodes.IE.IO +{ + /// + /// The type of input a data-validation rule accepts. + /// + public enum DataValidationType + { + Any = 0, + Integer = 1, + Decimal = 2, + List = 3, + Date = 4, + Time = 5, + TextLength = 6, + Custom = 7, + } + + /// + /// The comparison operator used by a data-validation rule. + /// + public enum DataValidationOperator + { + Between = 0, + NotBetween = 1, + Equal = 2, + NotEqual = 3, + LessThan = 4, + LessThanOrEqual = 5, + GreaterThan = 6, + GreaterThanOrEqual = 7, + } + + /// + /// A data-validation rule applied to a range of cells on a worksheet. + /// + public sealed class DataValidation + { + /// + /// Gets the cell range the rule applies to, for example C2:C1000. + /// + public string CellRange { get; } + + /// + /// Gets the validation type. + /// + public DataValidationType Type { get; } + + /// + /// Gets the comparison operator, or for list validation. + /// + public DataValidationOperator? Operator { get; } + + /// + /// Gets the first formula or value. + /// + public string? Formula1 { get; } + + /// + /// Gets the second formula or value, used only for range comparisons. + /// + public string? Formula2 { get; } + + /// + /// Gets a value indicating whether empty cells are allowed. + /// + public bool AllowBlank { get; } + + /// + /// Gets a value indicating whether the input prompt is shown. + /// + public bool ShowInputMessage { get; } + + /// + /// Gets a value indicating whether the error alert is shown. + /// + public bool ShowErrorMessage { get; } + + /// + /// Gets the title of the input prompt. + /// + public string? PromptTitle { get; } + + /// + /// Gets the body text of the input prompt. + /// + public string? PromptBody { get; } + + /// + /// Creates a data-validation rule. + /// + public DataValidation(string cellRange, DataValidationType type, string? formula1, string? formula2 = null, + DataValidationOperator? op = null, bool allowBlank = true, bool showInput = true, bool showError = true, + string? promptTitle = null, string? promptBody = null) + { + CellRange = cellRange ?? throw new ArgumentNullException(nameof(cellRange)); + Type = type; + Formula1 = formula1; + Formula2 = formula2; + Operator = op; + AllowBlank = allowBlank; + ShowInputMessage = showInput; + ShowErrorMessage = showError; + PromptTitle = promptTitle; + PromptBody = promptBody; + } + + /// + /// Creates a list (drop-down) validation. may be a quoted list or a cell range. + /// + public static DataValidation CreateList(string cellRange, string listFormula, bool allowBlank = true) + => new(cellRange, DataValidationType.List, listFormula, null, op: null, allowBlank: allowBlank); + + /// + /// Creates an integer range validation between and . + /// + public static DataValidation CreateInteger(string cellRange, int min, int max) + => new(cellRange, DataValidationType.Integer, min.ToString(CultureInfo.InvariantCulture), max.ToString(CultureInfo.InvariantCulture), DataValidationOperator.Between); + + /// + /// Creates a decimal range validation between and . + /// + public static DataValidation CreateDecimal(string cellRange, double min, double max) + => new(cellRange, DataValidationType.Decimal, min.ToString(CultureInfo.InvariantCulture), max.ToString(CultureInfo.InvariantCulture), DataValidationOperator.Between); + } +} diff --git a/src/Magicodes.IE.IO/Features/ImageAnchor.cs b/src/Magicodes.IE.IO/Features/ImageAnchor.cs new file mode 100644 index 00000000..35f7a566 --- /dev/null +++ b/src/Magicodes.IE.IO/Features/ImageAnchor.cs @@ -0,0 +1,47 @@ + +using System; + +namespace Magicodes.IE.IO +{ + + /// + /// The anchor range for an image in a worksheet. + /// + public sealed class ImageAnchor + { + + /// + /// Gets the one-based picture number. + /// + public int ImageNo { get; } + + /// + /// Gets the image file name. + /// + public string ImageName { get; } + + /// + /// Gets the top-left cell of the anchor, for example A1. + /// + public string FromCell { get; } + + /// + /// Gets the bottom-right cell of the anchor, for example D8. + /// + public string ToCell { get; } + + /// + /// Initializes a new instance of the class. + /// + public ImageAnchor(int imageNo, string imageName, string fromCell, string toCell) + { + if (imageName is null) throw new ArgumentNullException(nameof(imageName)); + if (fromCell is null) throw new ArgumentNullException(nameof(fromCell)); + if (toCell is null) throw new ArgumentNullException(nameof(toCell)); + ImageNo = imageNo; + ImageName = imageName; + FromCell = fromCell; + ToCell = toCell; + } + } +} diff --git a/src/Magicodes.IE.IO/Features/NamedRange.cs b/src/Magicodes.IE.IO/Features/NamedRange.cs new file mode 100644 index 00000000..423ba700 --- /dev/null +++ b/src/Magicodes.IE.IO/Features/NamedRange.cs @@ -0,0 +1,44 @@ + +using System; + +namespace Magicodes.IE.IO +{ + + /// + /// A workbook- or sheet-scoped named range. + /// + public sealed class NamedRange + { + + /// + /// Gets the range name. + /// + public string Name { get; } + + /// + /// Gets the cell range reference, for example Sheet1!$A$1:$B$10. + /// + public string Ref { get; } + + /// + /// Gets the range comment. + /// + public string? Comment { get; } + + /// + /// Gets the zero-based local sheet index when scoped to a single sheet; for a workbook scope. + /// + public int? LocalSheetId { get; } + + /// + /// Initializes a new instance of the class. + /// + public NamedRange(string name, string ref_, string? comment = null, int? localSheetId = null) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Ref = ref_ ?? throw new ArgumentNullException(nameof(ref_)); + Comment = comment; + LocalSheetId = localSheetId; + } + } +} diff --git a/src/Magicodes.IE.IO/Features/OutlineSettings.cs b/src/Magicodes.IE.IO/Features/OutlineSettings.cs new file mode 100644 index 00000000..5f1360d9 --- /dev/null +++ b/src/Magicodes.IE.IO/Features/OutlineSettings.cs @@ -0,0 +1,22 @@ + + +namespace Magicodes.IE.IO +{ + + /// + /// Display settings for the worksheet outline (grouping). + /// + public sealed class OutlineSettings + { + + /// + /// Gets or sets a value indicating whether summary rows appear below the detail rows. + /// + public bool SummaryBelow { get; init; } = true; + + /// + /// Gets or sets a value indicating whether summary columns appear to the right of the detail columns. + /// + public bool SummaryRight { get; init; } = true; + } +} diff --git a/src/Magicodes.IE.IO/Features/PageSetup.cs b/src/Magicodes.IE.IO/Features/PageSetup.cs new file mode 100644 index 00000000..0608d1a9 --- /dev/null +++ b/src/Magicodes.IE.IO/Features/PageSetup.cs @@ -0,0 +1,80 @@ +namespace Magicodes.IE.IO +{ + /// + /// Print page setup for a worksheet. Lengths are in inches. + /// + public sealed class PageSetup + { + /// + /// Gets or sets the paper size code (for example, 1 = Letter). + /// + public int? PaperSize { get; init; } + + /// + /// Gets or sets the orientation, for example portrait or landscape. + /// + public string? Orientation { get; init; } + /// + /// Gets or sets the scale percentage. + /// + public int? Scale { get; init; } + /// + /// Gets or sets the number of pages to fit the width to. + /// + public int? FitToWidth { get; init; } + /// + /// Gets or sets the number of pages to fit the height to. + /// + public int? FitToHeight { get; init; } + + /// + /// Gets or sets a value indicating whether to print in black and white. + /// + public bool? BlackAndWhite { get; init; } + + /// + /// Gets or sets the left margin, in inches. + /// + public double MarginLeft { get; init; } = 0.7; + /// + /// Gets or sets the right margin, in inches. + /// + public double MarginRight { get; init; } = 0.7; + /// + /// Gets or sets the top margin, in inches. + /// + public double MarginTop { get; init; } = 0.75; + /// + /// Gets or sets the bottom margin, in inches. + /// + public double MarginBottom { get; init; } = 0.75; + /// + /// Gets or sets the header margin, in inches. + /// + public double MarginHeader { get; init; } = 0.3; + /// + /// Gets or sets the footer margin, in inches. + /// + public double MarginFooter { get; init; } = 0.3; + + /// + /// Gets or sets the header text on odd pages. + /// + public string? OddHeader { get; init; } + + /// + /// Gets or sets the footer text on odd pages. + /// + public string? OddFooter { get; init; } + + /// + /// Gets or sets the header text on even pages. + /// + public string? EvenHeader { get; init; } + + /// + /// Gets or sets the footer text on even pages. + /// + public string? EvenFooter { get; init; } + } +} diff --git a/src/Magicodes.IE.IO/Features/SheetProtection.cs b/src/Magicodes.IE.IO/Features/SheetProtection.cs new file mode 100644 index 00000000..33385317 --- /dev/null +++ b/src/Magicodes.IE.IO/Features/SheetProtection.cs @@ -0,0 +1,73 @@ +namespace Magicodes.IE.IO +{ + /// + /// Worksheet protection options. With the exception of , each property is when the corresponding action is allowed. + /// + public sealed class SheetProtection + { + /// + /// Gets or sets the password hash. This is a hash, not the plain-text password. + /// + public string? PasswordHash { get; init; } + + /// + /// Gets or sets a value indicating whether the sheet is protected. + /// + public bool Sheet { get; init; } = true; + + /// + /// Gets or sets a value indicating whether formatting cells is allowed. + /// + public bool FormatCells { get; init; } = true; + + /// + /// Gets or sets a value indicating whether formatting columns is allowed. + /// + public bool FormatColumns { get; init; } = true; + + /// + /// Gets or sets a value indicating whether formatting rows is allowed. + /// + public bool FormatRows { get; init; } = true; + + /// + /// Gets or sets a value indicating whether inserting columns is allowed. + /// + public bool InsertColumns { get; init; } = true; + + /// + /// Gets or sets a value indicating whether inserting rows is allowed. + /// + public bool InsertRows { get; init; } = true; + + /// + /// Gets or sets a value indicating whether deleting columns is allowed. + /// + public bool DeleteColumns { get; init; } = true; + + /// + /// Gets or sets a value indicating whether deleting rows is allowed. + /// + public bool DeleteRows { get; init; } = true; + + /// + /// Gets or sets a value indicating whether sorting is allowed. + /// + public bool Sort { get; init; } = true; + + /// + /// Gets or sets a value indicating whether the auto-filter is allowed. + /// + public bool AutoFilter { get; init; } = true; + + /// + /// Gets or sets a value indicating whether selecting locked cells is allowed. + /// + public bool SelectLockedCells { get; init; } = false; + + /// + /// Gets or sets a value indicating whether selecting unlocked cells is allowed. + /// + public bool SelectUnlockedCells { get; init; } = false; + } +} diff --git a/src/Magicodes.IE.IO/Features/TableDefinition.cs b/src/Magicodes.IE.IO/Features/TableDefinition.cs new file mode 100644 index 00000000..8dbd07d3 --- /dev/null +++ b/src/Magicodes.IE.IO/Features/TableDefinition.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; + +namespace Magicodes.IE.IO +{ + /// + /// A single column within an Excel table. + /// + public sealed class TableColumnDefinition + { + /// + /// Gets the column name. + /// + public string Name { get; } + + /// + /// Gets the totals-row function name, for example sum. + /// + public string? TotalsRowFunction { get; } + + /// + /// Creates a column definition. + /// + public TableColumnDefinition(string name, string? totalsRowFunction = null) + { + Name = name; + TotalsRowFunction = totalsRowFunction; + } + } + + /// + /// An Excel table definition. + /// + public sealed class TableDefinition + { + /// + /// Gets the table name. + /// + public string Name { get; } + + /// + /// Gets the display name, or when not specified. + /// + public string DisplayName { get; } + + /// + /// Gets the cell range the table covers. + /// + public string Ref { get; } + + /// + /// Gets a value indicating whether the totals row is shown. + /// + public bool ShowTotalsRow { get; } + + /// + /// Gets a value indicating whether the auto-filter buttons are shown. + /// + public bool ShowAutoFilter { get; } + + /// + /// Gets the built-in table style. Overridden when a custom style name is set via . + /// + public TableStyle TableStyle { get; private set; } + + /// + /// Gets the style name written to the OOXML. A custom string takes precedence; otherwise the name is used. + /// + public string TableStyleName => _tableStyleRaw ?? TableStyle.ToString(); + + private string? _tableStyleRaw; + private List? _columns; + + /// + /// Gets the column definitions. + /// + public IReadOnlyList Columns => _columns ??= new(); + + /// + /// Creates an Excel table definition. + /// + public TableDefinition(string name, string ref_, string? displayName = null, + bool showTotalsRow = false, bool showAutoFilter = true, + TableStyle tableStyle = TableStyle.TableStyleMedium2) + { + Name = name; + Ref = ref_; + DisplayName = displayName ?? name; + ShowTotalsRow = showTotalsRow; + ShowAutoFilter = showAutoFilter; + TableStyle = tableStyle; + _tableStyleRaw = null; + } + + /// + /// Adds a column. Returns this instance for chaining. + /// + public TableDefinition WithColumn(string name, string? totalsRowFunction = null) + { + (_columns ??= new()).Add(new TableColumnDefinition(name, totalsRowFunction)); + return this; + } + + /// + /// Sets a built-in table style. The value is compile-time validated, so prefer this overload when using a built-in style. + /// + public TableDefinition WithTableStyle(TableStyle tableStyle) + { + TableStyle = tableStyle; + _tableStyleRaw = null; + return this; + } + + /// + /// Sets a raw style name not included in . The name is not validated, so a typo may cause Excel to ignore the style. + /// + public TableDefinition WithTableStyle(string tableStyle) + { + _tableStyleRaw = tableStyle; + return this; + } + } +} diff --git a/src/Magicodes.IE.IO/Features/TableStyle.cs b/src/Magicodes.IE.IO/Features/TableStyle.cs new file mode 100644 index 00000000..8b352f6d --- /dev/null +++ b/src/Magicodes.IE.IO/Features/TableStyle.cs @@ -0,0 +1,70 @@ +namespace Magicodes.IE.IO; + +/// +/// The built-in Excel table styles. Member names match the style names used in OOXML. +/// +public enum TableStyle +{ + TableStyleLight1 = 1, + TableStyleLight2, + TableStyleLight3, + TableStyleLight4, + TableStyleLight5, + TableStyleLight6, + TableStyleLight7, + TableStyleLight8, + TableStyleLight9, + TableStyleLight10, + TableStyleLight11, + TableStyleLight12, + TableStyleLight13, + TableStyleLight14, + TableStyleLight15, + TableStyleLight16, + TableStyleLight17, + TableStyleLight18, + TableStyleLight19, + TableStyleLight20, + TableStyleLight21, + + TableStyleMedium1, + TableStyleMedium2, + TableStyleMedium3, + TableStyleMedium4, + TableStyleMedium5, + TableStyleMedium6, + TableStyleMedium7, + TableStyleMedium8, + TableStyleMedium9, + TableStyleMedium10, + TableStyleMedium11, + TableStyleMedium12, + TableStyleMedium13, + TableStyleMedium14, + TableStyleMedium15, + TableStyleMedium16, + TableStyleMedium17, + TableStyleMedium18, + TableStyleMedium19, + TableStyleMedium20, + TableStyleMedium21, + TableStyleMedium22, + TableStyleMedium23, + TableStyleMedium24, + TableStyleMedium25, + TableStyleMedium26, + TableStyleMedium27, + TableStyleMedium28, + + TableStyleDark1, + TableStyleDark2, + TableStyleDark3, + TableStyleDark4, + TableStyleDark5, + TableStyleDark6, + TableStyleDark7, + TableStyleDark8, + TableStyleDark9, + TableStyleDark10, + TableStyleDark11, +} diff --git a/src/Magicodes.IE.IO/Internal/BufferWriterStream.cs b/src/Magicodes.IE.IO/Internal/BufferWriterStream.cs new file mode 100644 index 00000000..1313cdca --- /dev/null +++ b/src/Magicodes.IE.IO/Internal/BufferWriterStream.cs @@ -0,0 +1,95 @@ +using System; +using System.Buffers; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Magicodes.IE.IO +{ + + internal sealed class BufferWriterStream : Stream + { + private readonly IBufferWriter _writer; + private bool _disposed; + + public BufferWriterStream(IBufferWriter writer) + { + _writer = writer ?? throw new ArgumentNullException(nameof(writer)); + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => !_disposed; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + if (buffer is null) throw new ArgumentNullException(nameof(buffer)); + if ((uint)offset > (uint)buffer.Length) throw new ArgumentOutOfRangeException(nameof(offset)); + if ((uint)count > (uint)(buffer.Length - offset)) throw new ArgumentOutOfRangeException(nameof(count)); + if (count == 0) return; + + EnsureNotDisposed(); + var dest = _writer.GetSpan(count); + buffer.AsSpan(offset, count).CopyTo(dest); + _writer.Advance(count); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + Write(buffer, offset, count); + return Task.CompletedTask; + } + +#if !NETSTANDARD2_0 + public override void Write(ReadOnlySpan buffer) + { + if (buffer.Length == 0) return; + + EnsureNotDisposed(); + var dest = _writer.GetSpan(buffer.Length); + buffer.CopyTo(dest); + _writer.Advance(buffer.Length); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled(cancellationToken); + + Write(buffer.Span); + return ValueTask.CompletedTask; + } +#endif + + protected override void Dispose(bool disposing) + { + _disposed = true; + base.Dispose(disposing); + } + + private void EnsureNotDisposed() + { + if (_disposed) throw new ObjectDisposedException(nameof(BufferWriterStream)); + } + } +} diff --git a/src/Magicodes.IE.IO/Internal/ByteBufferWriter.cs b/src/Magicodes.IE.IO/Internal/ByteBufferWriter.cs new file mode 100644 index 00000000..c9675f37 --- /dev/null +++ b/src/Magicodes.IE.IO/Internal/ByteBufferWriter.cs @@ -0,0 +1,742 @@ + +using System; +using System.Buffers; +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Magicodes.IE.IO +{ + internal sealed class ByteBufferWriter : IBufferWriter, IDisposable + { + private byte[] _buffer; + private int _pos; + private const int MinChunkSize = 8 * 1024; + + public ByteBufferWriter(int initialSize) + { + if (initialSize < 256) initialSize = 256; + _buffer = ArrayPool.Shared.Rent(initialSize); + _pos = 0; + } + + public int WrittenCount => _pos; + + public void Advance(int count) => _pos += count; + + public Memory GetMemory(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsMemory(_pos); + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsSpan(_pos); + } + + private void EnsureCapacity(int sizeHint) + { + int need = sizeHint == 0 ? MinChunkSize : sizeHint; + if (_pos + need <= _buffer.Length) return; + int newSize = Math.Max(_buffer.Length * 2, _pos + need); + var nb = ArrayPool.Shared.Rent(newSize); + Buffer.BlockCopy(_buffer, 0, nb, 0, _pos); + ArrayPool.Shared.Return(_buffer); + _buffer = nb; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteUtf8(ReadOnlySpan utf8) + { + int len = utf8.Length; + if (_pos + len > _buffer.Length) + { + EnsureCapacity(len); + } + utf8.CopyTo(_buffer.AsSpan(_pos)); + _pos += len; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteUtf8(string s) + { + if (string.IsNullOrEmpty(s)) return; +#if NETSTANDARD2_0 + WriteUtf8(System.Text.Encoding.UTF8.GetBytes(s)); +#else + var span = s.AsSpan(); + int need = System.Text.Encoding.UTF8.GetByteCount(span); + if (_pos + need > _buffer.Length) EnsureCapacity(need); + System.Text.Encoding.UTF8.GetBytes(span, _buffer.AsSpan(_pos)); + _pos += need; +#endif + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteUtf8(System.Text.StringBuilder sb) + { + int len = sb.Length; + if (len == 0) return; +#if NETSTANDARD2_0 + int maxBytes = System.Text.Encoding.UTF8.GetMaxByteCount(len); + byte[] buf = System.Buffers.ArrayPool.Shared.Rent(maxBytes); + try + { + int n = System.Text.Encoding.UTF8.GetBytes(sb.ToString(), 0, len, buf, 0); + if (_pos + n > _buffer.Length) EnsureCapacity(n); + buf.AsSpan(0, n).CopyTo(_buffer.AsSpan(_pos)); + _pos += n; + } + finally + { + System.Buffers.ArrayPool.Shared.Return(buf); + } +#else + int totalBytes = 0; + foreach (var chunk in sb.GetChunks()) + { + totalBytes += System.Text.Encoding.UTF8.GetByteCount(chunk.Span); + } + if (_pos + totalBytes > _buffer.Length) EnsureCapacity(totalBytes); + var dst = _buffer.AsSpan(_pos, totalBytes); + int written = 0; + foreach (var chunk in sb.GetChunks()) + { + written += System.Text.Encoding.UTF8.GetBytes(chunk.Span, dst.Slice(written)); + } + _pos += written; +#endif + } + + public void WriteEscaped(string s) + { + int spanLen = s.Length; + if (spanLen == 0) return; + + int i = 0; + int plainStart = 0; + while (i < spanLen) + { + char c = s[i]; + if (IsIllegalXmlChar(c)) + { + FlushPlain(s, plainStart, i); + WriteUtf8(ReplacementUtf8); + i++; + plainStart = i; + continue; + } + if (c >= 0x20 || c == '\t' || c == '\n' || c == '\r') + { + if (c < 0x80) + { + int escLen = GetAsciiEscapeLen(c); + if (escLen > 0) + { + FlushPlain(s, plainStart, i); + WriteAsciiEscape(c); + i++; + plainStart = i; + continue; + } + i++; + } + else + { + i++; + } + } + else + { + FlushPlain(s, plainStart, i); + if (_pos >= _buffer.Length) EnsureCapacity(1); + _buffer[_pos++] = 0x20; + i++; + plainStart = i; + } + } + FlushPlain(s, plainStart, spanLen); + } + + private void FlushPlain(string s, int start, int end) + { + if (end <= start) return; +#if NET8_0_OR_GREATER + if (end - start >= VectorEscapeThreshold) + { + var vspan = s.AsSpan(start, end - start); + if (System.Text.Ascii.IsValid(vspan)) + { + int vlen = vspan.Length; + if (_pos + vlen > _buffer.Length) EnsureCapacity(vlen); + System.Text.Ascii.FromUtf16(vspan, _buffer.AsSpan(_pos, vlen), out int vnb); + _pos += vnb; + return; + } + int vneed = Encoding.UTF8.GetByteCount(vspan); + if (_pos + vneed > _buffer.Length) EnsureCapacity(vneed); + _pos += Encoding.UTF8.GetBytes(vspan, _buffer.AsSpan(_pos)); + return; + } +#endif + bool asciiOnly = true; + for (int j = start; j < end; j++) + { + if (s[j] >= 0x80) { asciiOnly = false; break; } + } + if (asciiOnly) + { + int len = end - start; + if (_pos + len > _buffer.Length) EnsureCapacity(len); + for (int j = start; j < end; j++) _buffer[_pos++] = (byte)s[j]; + } + else + { + var span = s.AsSpan(start, end - start); +#if NETSTANDARD2_0 + int need = Encoding.UTF8.GetByteCount(span.ToString()); +#else + int need = Encoding.UTF8.GetByteCount(span); +#endif + if (_pos + need > _buffer.Length) EnsureCapacity(need); +#if NETSTANDARD2_0 + var tmp = Encoding.UTF8.GetBytes(s.Substring(start, end - start)); + tmp.AsSpan().CopyTo(_buffer.AsSpan(_pos)); + _pos += tmp.Length; +#else + int n = Encoding.UTF8.GetBytes(span, _buffer.AsSpan(_pos)); + _pos += n; +#endif + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int GetAsciiEscapeLen(char c) + { + if (c == '&') return 5; + if (c == '<') return 4; + if (c == '>') return 4; + if (c == '"') return 6; + if (c == '\'') return 6; + return 0; + } + + private static readonly byte[] ReplacementUtf8 = Encoding.UTF8.GetBytes("\uFFFD"); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsIllegalXmlChar(char c) => + (c < 0x20 && c != '\t' && c != '\n' && c != '\r') || c is '\uFFFE' or '\uFFFF'; + + private static ReadOnlySpan AmpEsc => new byte[] { (byte)'&', (byte)'a', (byte)'m', (byte)'p', (byte)';' }; + private static ReadOnlySpan LtEsc => new byte[] { (byte)'&', (byte)'l', (byte)'t', (byte)';' }; + private static ReadOnlySpan GtEsc => new byte[] { (byte)'&', (byte)'g', (byte)'t', (byte)';' }; + private static ReadOnlySpan QuotEsc => new byte[] { (byte)'&', (byte)'q', (byte)'u', (byte)'o', (byte)'t', (byte)';' }; + private static ReadOnlySpan AposEsc => new byte[] { (byte)'&', (byte)'a', (byte)'p', (byte)'o', (byte)'s', (byte)';' }; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void WriteAsciiEscape(char c) + { + ReadOnlySpan esc = c switch + { + '&' => AmpEsc, + '<' => LtEsc, + '>' => GtEsc, + '"' => QuotEsc, + '\'' => AposEsc, + _ => default, + }; + int len = esc.Length; + if (_pos + len > _buffer.Length) EnsureCapacity(len); + esc.CopyTo(_buffer.AsSpan(_pos)); + _pos += len; + } + + public void WriteDouble(double d) + { + Span buf = stackalloc byte[32]; + NumberFormatHelper.WriteDouble(d, 'R', buf, out int w); + if (_pos + w > _buffer.Length) EnsureCapacity(w); + buf.Slice(0, w).CopyTo(_buffer.AsSpan(_pos)); + _pos += w; + } + + public void WriteNumberCell(int styleId, ReadOnlySpan colLetter, ReadOnlySpan rowRef, double d, bool strictCellReferences = true) + { + if (!strictCellReferences) + { + WriteUtf8(" 0) + { + WriteUtf8(" s=\""u8); + WriteInt32(styleId); + WriteUtf8("\""u8); + } + WriteUtf8(">"u8); + WriteDouble(d); + WriteUtf8(""u8); + return; + } + int maxBytes = 73 + colLetter.Length; + if (_pos + maxBytes > _buffer.Length) EnsureCapacity(maxBytes); + + var dst = _buffer.AsSpan(_pos, maxBytes); + int p = 0; + dst[p++] = (byte)'<'; + dst[p++] = (byte)'c'; + dst[p++] = (byte)' '; + dst[p++] = (byte)'r'; + dst[p++] = (byte)'='; + dst[p++] = (byte)'"'; + colLetter.CopyTo(dst.Slice(p)); + p += colLetter.Length; + rowRef.CopyTo(dst.Slice(p)); + p += rowRef.Length; + dst[p++] = (byte)'"'; + dst[p++] = (byte)' '; + dst[p++] = (byte)'t'; + dst[p++] = (byte)'='; + dst[p++] = (byte)'"'; + dst[p++] = (byte)'n'; + dst[p++] = (byte)'"'; + if (styleId > 0) + { + dst[p++] = (byte)' '; + dst[p++] = (byte)'s'; + dst[p++] = (byte)'='; + dst[p++] = (byte)'"'; + NumberFormatHelper.WriteInt32(styleId, dst.Slice(p), out int sw); + p += sw; + dst[p++] = (byte)'"'; + } + dst[p++] = (byte)'>'; + dst[p++] = (byte)'<'; + dst[p++] = (byte)'v'; + dst[p++] = (byte)'>'; + NumberFormatHelper.WriteDouble(d, 'R', dst.Slice(p), out int dw); + p += dw; + dst[p++] = (byte)'<'; + dst[p++] = (byte)'/'; + dst[p++] = (byte)'v'; + dst[p++] = (byte)'>'; + dst[p++] = (byte)'<'; + dst[p++] = (byte)'/'; + dst[p++] = (byte)'c'; + dst[p++] = (byte)'>'; + + _pos += p; + } + + public void WriteInt32(int n) + { + Span buf = stackalloc byte[12]; + NumberFormatHelper.WriteInt32(n, buf, out int w); + if (_pos + w > _buffer.Length) EnsureCapacity(w); + buf.Slice(0, w).CopyTo(_buffer.AsSpan(_pos)); + _pos += w; + } + + public void WriteInt64(long n) + { + Span buf = stackalloc byte[20]; + NumberFormatHelper.WriteInt64(n, buf, out int w); + if (_pos + w > _buffer.Length) EnsureCapacity(w); + buf.Slice(0, w).CopyTo(_buffer.AsSpan(_pos)); + _pos += w; + } + + public void WriteInlineStringCell(int styleId, string? s, ReadOnlySpan colLetter, ReadOnlySpan rowRef, bool strictCellReferences = true) + { + if (!strictCellReferences) + { + WriteInlineStringCellFallback(styleId, s ?? string.Empty, ReadOnlySpan.Empty, ReadOnlySpan.Empty); + return; + } + if (s is null) + { + WriteInlineStringCellFallback(styleId, "", colLetter, rowRef); + return; + } + + int sLen = s.Length; + + if (sLen > 0 && IsAsciiNoEscape(s.AsSpan()) + && !(char.IsWhiteSpace(s[0]) || char.IsWhiteSpace(s[sLen - 1]))) + { + int exactBytes = InlineStringCellPrefixLen + + (strictCellReferences ? colLetter.Length + rowRef.Length : 0) + + sLen + + InlineStringCellSuffixLen; + if (styleId > 0) + { + exactBytes += StyleAttrOpeningLen + CountDigits(styleId) + StyleAttrClosingQuoteLen; + } + if (_pos + exactBytes <= _buffer.Length) + { + var dst = _buffer.AsSpan(_pos, exactBytes); + int p = 0; + ReadOnlySpan prefixHead = " rOpen = " r=\""u8; + rOpen.CopyTo(dst.Slice(p)); p += rOpen.Length; + colLetter.CopyTo(dst.Slice(p)); p += colLetter.Length; + rowRef.CopyTo(dst.Slice(p)); p += rowRef.Length; + dst[p++] = (byte)'"'; + } + ReadOnlySpan prefixTail = " t=\"inlineStr\""u8; + prefixTail.CopyTo(dst.Slice(p)); p += prefixTail.Length; + if (styleId > 0) + { + ReadOnlySpan sOpen = " s=\""u8; + sOpen.CopyTo(dst.Slice(p)); p += sOpen.Length; + NumberFormatHelper.WriteInt32(styleId, dst.Slice(p), out int sw); p += sw; + dst[p++] = (byte)'"'; + } + ReadOnlySpan afterStyle = ">"u8; + afterStyle.CopyTo(dst.Slice(p)); p += afterStyle.Length; +#if NET8_0_OR_GREATER + if (sLen >= VectorEscapeThreshold) + { + System.Text.Ascii.FromUtf16(s.AsSpan(), dst.Slice(p, sLen), out int narrowed); + p += narrowed; + } + else + { + for (int i = 0; i < sLen; i++) dst[p++] = (byte)s[i]; + } +#else + for (int i = 0; i < sLen; i++) dst[p++] = (byte)s[i]; +#endif + ReadOnlySpan suffix = ""u8; + suffix.CopyTo(dst.Slice(p)); p += suffix.Length; + _pos += p; + return; + } + WriteInlineStringCellFallback(styleId, s, colLetter, rowRef); + return; + } + + long maxBytes = 96L + colLetter.Length + rowRef.Length + (long)sLen * 6; + if (maxBytes > int.MaxValue || maxBytes > _buffer.Length - _pos) + { + WriteInlineStringCellFallback(styleId, s, colLetter, rowRef); + return; + } + + var dst2 = _buffer.AsSpan(_pos, (int)maxBytes); + int p2 = 0; + + dst2[p2++] = (byte)'<'; + dst2[p2++] = (byte)'c'; + if (!colLetter.IsEmpty) + { + dst2[p2++] = (byte)' '; + dst2[p2++] = (byte)'r'; + dst2[p2++] = (byte)'='; + dst2[p2++] = (byte)'"'; + colLetter.CopyTo(dst2.Slice(p2)); + p2 += colLetter.Length; + rowRef.CopyTo(dst2.Slice(p2)); p2 += rowRef.Length; + dst2[p2++] = (byte)'"'; + } + dst2[p2++] = (byte)' '; + dst2[p2++] = (byte)'t'; + dst2[p2++] = (byte)'='; + dst2[p2++] = (byte)'"'; + dst2[p2++] = (byte)'i'; + dst2[p2++] = (byte)'n'; + dst2[p2++] = (byte)'l'; + dst2[p2++] = (byte)'i'; + dst2[p2++] = (byte)'n'; + dst2[p2++] = (byte)'e'; + dst2[p2++] = (byte)'S'; + dst2[p2++] = (byte)'t'; + dst2[p2++] = (byte)'r'; + dst2[p2++] = (byte)'"'; + if (styleId > 0) + { + dst2[p2++] = (byte)' '; + dst2[p2++] = (byte)'s'; + dst2[p2++] = (byte)'='; + dst2[p2++] = (byte)'"'; + NumberFormatHelper.WriteInt32(styleId, dst2.Slice(p2), out int sw2); p2 += sw2; + dst2[p2++] = (byte)'"'; + } + dst2[p2++] = (byte)'>'; + + dst2[p2++] = (byte)'<'; + dst2[p2++] = (byte)'i'; + dst2[p2++] = (byte)'s'; + dst2[p2++] = (byte)'>'; + dst2[p2++] = (byte)'<'; + dst2[p2++] = (byte)'t'; + bool preserve = sLen > 0 && (char.IsWhiteSpace(s[0]) || char.IsWhiteSpace(s[sLen - 1])); + if (preserve) + { + ReadOnlySpan xs = " xml:space=\"preserve\""u8; + xs.CopyTo(dst2.Slice(p2)); + p2 += xs.Length; + } + dst2[p2++] = (byte)'>'; + + p2 = WriteEscapedInto(s, 0, sLen, dst2, p2); + + ReadOnlySpan tail = ""u8; + tail.CopyTo(dst2.Slice(p2)); + p2 += tail.Length; + + _pos += p2; + } + + private static readonly int InlineStringCellPrefixLen = "".Length; + private static readonly int InlineStringCellSuffixLen = "".Length; + private static readonly int StyleAttrOpeningLen = " s=\"".Length; + private static readonly int StyleAttrClosingQuoteLen = "\"".Length; + +#if NET8_0_OR_GREATER + private static readonly System.Buffers.SearchValues SafeInlineChars = + System.Buffers.SearchValues.Create(BuildSafeInlineChars()); + + private const int VectorEscapeThreshold = 32; + + private static string BuildSafeInlineChars() + { + var sb = new StringBuilder(128); + sb.Append('\t').Append('\n').Append('\r'); + for (int c = 0x20; c < 0x80; c++) + { + if (c == '&' || c == '<' || c == '>' || c == '"' || c == '\'') continue; + sb.Append((char)c); + } + return sb.ToString(); + } +#endif + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAsciiNoEscape(ReadOnlySpan s) + { +#if NET8_0_OR_GREATER + if (s.Length >= VectorEscapeThreshold) + return s.IndexOfAnyExcept(SafeInlineChars) < 0; +#endif + int len = s.Length; + for (int i = 0; i < len; i++) + { + char c = s[i]; + if (c >= 0x80) return false; + if (c < 0x20 && c != '\t' && c != '\n' && c != '\r') return false; + if (c == '&' || c == '<' || c == '>' || c == '"' || c == '\'') return false; + } + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int CountDigits(int n) + { + if (n < 10) return 1; + if (n < 100) return 2; + if (n < 1000) return 3; + if (n < 10000) return 4; + if (n < 100000) return 5; + return 6; + } + + private void WriteInlineStringCellFallback(int styleId, string s, ReadOnlySpan colLetter, ReadOnlySpan rowRef) + { + WriteUtf8(" 0) + { + WriteUtf8(" s=\""u8); + WriteInt32(styleId); + WriteUtf8("\""u8); + } + WriteUtf8("> 0 && (char.IsWhiteSpace(s[0]) || char.IsWhiteSpace(s[sLen - 1])); + if (preserve) WriteUtf8(" xml:space=\"preserve\""u8); + WriteUtf8(">"u8); + WriteEscaped(s); + WriteUtf8(""u8); + } + + private static int WriteEscapedInto(string s, int start, int end, Span dst, int p) + { + int i = start; + int plainStart = start; + int cap = dst.Length; + while (i < end) + { + char c = s[i]; + if (IsIllegalXmlChar(c)) + { + p = FlushPlainInto(s, plainStart, i, dst, p, cap); + if (p + ReplacementUtf8.Length > cap) return p; + ReplacementUtf8.AsSpan().CopyTo(dst.Slice(p)); + p += ReplacementUtf8.Length; + i++; + plainStart = i; + continue; + } + if (c >= 0x20 || c == '\t' || c == '\n' || c == '\r') + { + if (c < 0x80) + { + int escLen = GetAsciiEscapeLen(c); + if (escLen > 0) + { + p = FlushPlainInto(s, plainStart, i, dst, p, cap); + ReadOnlySpan esc = c switch + { + '&' => AmpEsc, + '<' => LtEsc, + '>' => GtEsc, + '"' => QuotEsc, + '\'' => AposEsc, + _ => default, + }; + esc.CopyTo(dst.Slice(p)); + p += esc.Length; + i++; + plainStart = i; + continue; + } + i++; + } + else + { + i++; + } + } + else + { + p = FlushPlainInto(s, plainStart, i, dst, p, cap); + if (p >= cap) return p; + dst[p++] = (byte)0x20; + i++; + plainStart = i; + } + } + return FlushPlainInto(s, plainStart, end, dst, p, cap); + } + + private static int FlushPlainInto(string s, int start, int end, Span dst, int p, int cap) + { + if (end <= start) return p; +#if NET8_0_OR_GREATER + if (end - start >= VectorEscapeThreshold) + { + var vspan = s.AsSpan(start, end - start); + if (System.Text.Ascii.IsValid(vspan)) + { + int vlen = vspan.Length; + if (p + vlen > cap) return p; + System.Text.Ascii.FromUtf16(vspan, dst.Slice(p, vlen), out int vnb); + return p + vnb; + } + if (p >= cap) return p; + return p + Encoding.UTF8.GetBytes(vspan, dst.Slice(p)); + } +#endif + bool asciiOnly = true; + for (int j = start; j < end; j++) + { + if (s[j] >= 0x80) { asciiOnly = false; break; } + } + if (asciiOnly) + { + int len = end - start; + if (p + len > cap) return p; + for (int j = start; j < end; j++) dst[p++] = (byte)s[j]; + return p; + } + else + { + var span = s.AsSpan(start, end - start); + if (p >= cap) return p; + var destAvail = dst.Slice(p); +#if NETSTANDARD2_0 + int n2 = Encoding.UTF8.GetByteCount(span.ToString()); + if (n2 > destAvail.Length) return p; + var tmp = Encoding.UTF8.GetBytes(span.ToString()); + tmp.AsSpan().CopyTo(destAvail); + return p + tmp.Length; +#else + return p + Encoding.UTF8.GetBytes(span, destAvail); +#endif + } + } + + public int RemainingCapacity => _buffer.Length - _pos; + + public void FlushTo(Stream stream) + { + if (_pos == 0) return; + stream.Write(_buffer, 0, _pos); + _pos = 0; + } + + public ValueTask FlushToAsync(Stream stream, CancellationToken cancellationToken = default) + { + if (_pos == 0) return default; +#if NETSTANDARD2_0 + return FlushToAsyncCoreNs20(stream, cancellationToken); +#else + var vt = stream.WriteAsync((ReadOnlyMemory)_buffer.AsMemory(0, _pos), cancellationToken); + if (vt.IsCompletedSuccessfully) + { + _pos = 0; + return default; + } + return AwaitAndResetAsync(vt); +#endif + } + +#if NETSTANDARD2_0 + private async ValueTask FlushToAsyncCoreNs20(Stream stream, CancellationToken cancellationToken) + { + await stream.WriteAsync(_buffer, 0, _pos, cancellationToken).ConfigureAwait(false); + _pos = 0; + } +#else + private async ValueTask AwaitAndResetAsync(ValueTask writeTask) + { + try + { + await writeTask.ConfigureAwait(false); + } + finally + { + _pos = 0; + } + } +#endif + + public int GetCommittedSize() + { + return _pos == 0 ? 256 : _pos; + } + + public void Dispose() + { + if (_buffer.Length > 0) + { + ArrayPool.Shared.Return(_buffer); + _buffer = Array.Empty(); + } + } + } +} diff --git a/src/Magicodes.IE.IO/Internal/CellRefHelper.cs b/src/Magicodes.IE.IO/Internal/CellRefHelper.cs new file mode 100644 index 00000000..63906d6d --- /dev/null +++ b/src/Magicodes.IE.IO/Internal/CellRefHelper.cs @@ -0,0 +1,63 @@ + +using System; + +namespace Magicodes.IE.IO +{ + internal static class CellRefHelper + { + internal static int CellRefToCol(string cellRef) + { + if (string.IsNullOrEmpty(cellRef)) return -1; + int i = 0; + while (i < cellRef.Length && IsAsciiLetter(cellRef[i])) i++; + if (i == 0 || i > 3) return -1; + int colNum = 0; + for (int k = 0; k < i; k++) + colNum = colNum * 26 + (char.ToUpperInvariant(cellRef[k]) - 'A' + 1); + return colNum is >= 1 and <= 16384 ? colNum - 1 : -1; + } + + internal static int CellRefToRow(string cellRef) + { + if (string.IsNullOrEmpty(cellRef)) return -1; + int i = 0; + while (i < cellRef.Length && IsAsciiLetter(cellRef[i])) i++; + if (i >= cellRef.Length) return -1; + int row = 0; + for (; i < cellRef.Length; i++) + { + char c = cellRef[i]; + if (c < '0' || c > '9') return -1; + row = checked(row * 10 + c - '0'); + if (row > 1048576) return -1; + } + return row is >= 1 and <= 1048576 ? row - 1 : -1; + } + + internal static bool IsCellReference(string? cellRef) + { + if (cellRef is null || cellRef.Length == 0) return false; + int i = 0; + while (i < cellRef.Length && IsAsciiLetter(cellRef[i])) i++; + if (i == 0 || i > 3 || i == cellRef.Length) return false; + for (; i < cellRef.Length; i++) + { + char c = cellRef[i]; + if (c < '0' || c > '9') return false; + } + return CellRefToCol(cellRef) >= 0 && CellRefToRow(cellRef) >= 0; + } + + internal static bool IsCellRange(string? range) + { + if (range is null || range.Length == 0) return false; + int separator = range.IndexOf(':'); + return separator > 0 + && separator == range.LastIndexOf(':') + && IsCellReference(range.Substring(0, separator)) + && IsCellReference(range.Substring(separator + 1)); + } + + private static bool IsAsciiLetter(char c) => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + } +} \ No newline at end of file diff --git a/src/Magicodes.IE.IO/Internal/CellValueParser.cs b/src/Magicodes.IE.IO/Internal/CellValueParser.cs new file mode 100644 index 00000000..a4544650 --- /dev/null +++ b/src/Magicodes.IE.IO/Internal/CellValueParser.cs @@ -0,0 +1,130 @@ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; + +namespace Magicodes.IE.IO +{ + internal static class CellValueParser + { + private delegate object? ParseFunc(string cell); + + private static readonly Dictionary _parsers = new() + { + [typeof(string)] = cell => cell, + [typeof(int)] = cell => int.TryParse(cell, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null, + [typeof(long)] = cell => long.TryParse(cell, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null, + [typeof(double)] = cell => double.TryParse(cell, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var v) ? v : null, + [typeof(decimal)] = cell => decimal.TryParse(cell, NumberStyles.Number | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var v) ? v : null, + [typeof(bool)] = ParseBool, + [typeof(DateTime)] = cell => ParseDateTime(cell), + [typeof(DateTimeOffset)] = cell => ParseDateTimeOffset(cell), + [typeof(Guid)] = cell => Guid.TryParse(cell, out var v) ? v : null, + [typeof(ulong)] = ParseUInt64, + }; + + public static bool TryParse(string cell, Type targetType, out object? value, IReadOnlyList? converters = null, bool date1904 = false) + { + if (converters is not null && converters.Count > 0) + { + for (int i = 0; i < converters.Count; i++) + { + var conv = converters[i]; + if (conv.Type == targetType) + { + var result = conv.TryRead(cell, out value); + if (!result) value = null; + return result; + } + } + } + + if (targetType == typeof(DateTime)) + { + value = ParseDateTime(cell, date1904); + return value is not null; + } + + if (targetType == typeof(DateTimeOffset)) + { + value = ParseDateTimeOffset(cell, date1904); + return value is not null; + } + + if (_parsers.TryGetValue(targetType, out var parser)) + { + value = parser(cell); + return value is not null; + } + + if (targetType.IsEnum) + { + value = ParseEnum(cell, targetType); + return value is not null; + } + + try + { + value = Convert.ChangeType(cell, targetType, CultureInfo.InvariantCulture); + return true; + } + catch (Exception ex) when (ex is FormatException or OverflowException or InvalidCastException) + { + value = null; + return false; + } + } + + private static object? ParseBool(string cell) + { + if (cell == "1" || cell.Equals("true", StringComparison.OrdinalIgnoreCase) || cell.Equals("yes", StringComparison.OrdinalIgnoreCase)) + return true; + if (cell == "0" || cell.Equals("false", StringComparison.OrdinalIgnoreCase) || cell.Equals("no", StringComparison.OrdinalIgnoreCase)) + return false; + return null; + } + + private static object? ParseUInt64(string cell) + { + // Numbers are written with 'R' (round-trip) format, so large ulongs serialize as + // scientific notation (e.g. "9.223372036854776E+18"). ulong.TryParse rejects the + // exponent, so fall back to double then Convert — precision is bounded by double + // anyway (Excel stores numbers as IEEE754 double). + if (ulong.TryParse(cell, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)) return v; + if (double.TryParse(cell, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var d) && d >= 0) + { + try { return Convert.ToUInt64(d); } catch (OverflowException) { return null; } + } + return null; + } + + private static object? ParseDateTime(string cell, bool date1904 = false) + { + if (double.TryParse(cell, NumberStyles.Any, CultureInfo.InvariantCulture, out var oa)) + return DateTime.FromOADate(oa + (date1904 ? 1462 : 0)); + if (DateTime.TryParse(cell, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)) + return dt; + return null; + } + + private static object? ParseDateTimeOffset(string cell, bool date1904 = false) + { + if (double.TryParse(cell, NumberStyles.Any, CultureInfo.InvariantCulture, out var oa)) + return new DateTimeOffset(DateTime.SpecifyKind(DateTime.FromOADate(oa + (date1904 ? 1462 : 0)), DateTimeKind.Unspecified)); + if (DateTimeOffset.TryParse(cell, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dto)) + return dto; + return null; + } + + private static object? ParseEnum(string cell, Type enumType) + { +#if NETSTANDARD2_0 + try { return Enum.Parse(enumType, cell, ignoreCase: true); } + catch { return null; } +#else + return Enum.TryParse(enumType, cell, ignoreCase: true, out var ev) ? ev : null; +#endif + } + } +} \ No newline at end of file diff --git a/src/Magicodes.IE.IO/Internal/ForwardOnlyZipWriter.cs b/src/Magicodes.IE.IO/Internal/ForwardOnlyZipWriter.cs new file mode 100644 index 00000000..d376c61d --- /dev/null +++ b/src/Magicodes.IE.IO/Internal/ForwardOnlyZipWriter.cs @@ -0,0 +1,1137 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +#if NET6_0_OR_GREATER +using System.Runtime.Intrinsics.Arm; +#endif + +namespace Magicodes.IE.IO +{ + + internal sealed class ForwardOnlyZipWriter : IDisposable + { + private readonly Stream _output; + private readonly bool _leaveOpen; + private readonly List _entries = new(); + private EntryWriteStream? _activeEntry; + private long _position; + private bool _disposed; + + public ForwardOnlyZipWriter(Stream output, bool leaveOpen = true) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + _leaveOpen = leaveOpen; + } + + public Stream OpenEntry(string name, CompressionLevel compression) + { + if (name is null) throw new ArgumentNullException(nameof(name)); + EnsureNotDisposed(); + if (_activeEntry is not null) + throw new InvalidOperationException("Previous zip entry has not been closed."); + + var utf8Name = RentUtf8Name(name); + try + { + ushort method = compression == CompressionLevel.NoCompression ? (ushort)0 : (ushort)8; + const ushort flags = 0x0808; + DosDateTime dos = DosDateTime.From(DateTime.Now); + uint offset = CheckedToUInt32(_position); + + WriteLocalHeader(utf8Name.Buffer, utf8Name.Length, method, flags, dos.Time, dos.Date); + + var entry = new CentralDirectoryEntry(utf8Name.Buffer, utf8Name.Length, method, flags, dos.Time, dos.Date, offset); + _activeEntry = new EntryWriteStream(this, entry, compression); + return _activeEntry; + } + catch + { + ArrayPool.Shared.Return(utf8Name.Buffer); + throw; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + try + { + if (_activeEntry is not null) + _activeEntry.Dispose(); + + WriteCentralDirectory(); + } + finally + { + for (int i = 0; i < _entries.Count; i++) + _entries[i].ReturnNameBuffer(); + if (!_leaveOpen) + _output.Dispose(); + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + try + { + if (_activeEntry is not null) + await _activeEntry.DisposeAsync().ConfigureAwait(false); + + await WriteCentralDirectoryAsync(CancellationToken.None).ConfigureAwait(false); + } + finally + { + for (int i = 0; i < _entries.Count; i++) + _entries[i].ReturnNameBuffer(); + if (!_leaveOpen) + { +#if NETSTANDARD2_0 + _output.Dispose(); +#else + await _output.DisposeAsync().ConfigureAwait(false); +#endif + } + } + } + + internal static ValueTask DisposeEntryStreamAsync(Stream entryStream) + { +#if NETSTANDARD2_0 + entryStream.Dispose(); + return default; +#else + return entryStream.DisposeAsync(); +#endif + } + + internal void WriteRaw(byte[] buffer, int offset, int count) + { + if (count == 0) return; +#if NETSTANDARD2_0 + _output.Write(buffer, offset, count); +#else + _output.Write(buffer.AsSpan(offset, count)); +#endif + _position += count; + } + + internal async Task WriteRawAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (count == 0) return; +#if NETSTANDARD2_0 + await _output.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); + _position += count; +#else + await _output.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); + _position += count; +#endif + } + +#if !NETSTANDARD2_0 + internal ValueTask WriteRawAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + if (buffer.Length == 0) return default; + var vt = _output.WriteAsync(buffer, cancellationToken); + if (vt.IsCompletedSuccessfully) + { + _position += buffer.Length; + return default; + } + return AwaitAndUpdatePosition(vt, buffer.Length); + } + + private async ValueTask AwaitAndUpdatePosition(ValueTask writeTask, int length) + { + await writeTask.ConfigureAwait(false); + _position += length; + } +#endif + +#if !NETSTANDARD2_0 + internal void WriteRaw(ReadOnlySpan buffer) + { + if (buffer.Length == 0) return; + _output.Write(buffer); + _position += buffer.Length; + } +#endif + + private void CompleteEntry(CentralDirectoryEntry entry, uint crc32, uint compressedSize, uint uncompressedSize) + { + WriteDataDescriptor(crc32, compressedSize, uncompressedSize); + entry.Crc32 = crc32; + entry.CompressedSize = compressedSize; + entry.UncompressedSize = uncompressedSize; + _entries.Add(entry); + _activeEntry = null; + } + + private async Task CompleteEntryAsync(CentralDirectoryEntry entry, uint crc32, uint compressedSize, uint uncompressedSize, CancellationToken cancellationToken) + { + await WriteDataDescriptorAsync(crc32, compressedSize, uncompressedSize, cancellationToken).ConfigureAwait(false); + entry.Crc32 = crc32; + entry.CompressedSize = compressedSize; + entry.UncompressedSize = uncompressedSize; + _entries.Add(entry); + _activeEntry = null; + } + + private void WriteLocalHeader(byte[] nameBytes, int nameLength, ushort method, ushort flags, ushort dosTime, ushort dosDate) + { + int len = 30 + nameLength; + byte[] rented = ArrayPool.Shared.Rent(len); + try + { + var span = rented.AsSpan(0, len); + span.Clear(); + + WriteUInt32(span, 0, 0x04034B50u); + WriteUInt16(span, 4, 20); + WriteUInt16(span, 6, flags); + WriteUInt16(span, 8, method); + WriteUInt16(span, 10, dosTime); + WriteUInt16(span, 12, dosDate); + WriteUInt32(span, 14, 0); + WriteUInt32(span, 18, 0); + WriteUInt32(span, 22, 0); + WriteUInt16(span, 26, CheckedToUInt16(nameLength)); + WriteUInt16(span, 28, 0); + nameBytes.AsSpan(0, nameLength).CopyTo(span.Slice(30)); + +#if NETSTANDARD2_0 + WriteRaw(rented, 0, len); +#else + WriteRaw(span); +#endif + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private void WriteDataDescriptor(uint crc32, uint compressedSize, uint uncompressedSize) + { + byte[] rented = ArrayPool.Shared.Rent(16); + try + { + var span = rented.AsSpan(0, 16); + WriteUInt32(span, 0, 0x08074B50u); + WriteUInt32(span, 4, crc32); + WriteUInt32(span, 8, compressedSize); + WriteUInt32(span, 12, uncompressedSize); +#if NETSTANDARD2_0 + WriteRaw(rented, 0, 16); +#else + WriteRaw(span); +#endif + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private async Task WriteDataDescriptorAsync(uint crc32, uint compressedSize, uint uncompressedSize, CancellationToken cancellationToken) + { + byte[] rented = ArrayPool.Shared.Rent(16); + try + { + var span = rented.AsSpan(0, 16); + WriteUInt32(span, 0, 0x08074B50u); + WriteUInt32(span, 4, crc32); + WriteUInt32(span, 8, compressedSize); + WriteUInt32(span, 12, uncompressedSize); + await WriteRawAsync(rented, 0, 16, cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private void WriteCentralDirectory() + { + long centralDirectoryOffset = _position; + + for (int i = 0; i < _entries.Count; i++) + { + WriteCentralDirectoryHeader(_entries[i]); + } + + long centralDirectorySize = _position - centralDirectoryOffset; + WriteEndOfCentralDirectory(CheckedToUInt16(_entries.Count), CheckedToUInt32(centralDirectorySize), CheckedToUInt32(centralDirectoryOffset)); + } + + private async Task WriteCentralDirectoryAsync(CancellationToken cancellationToken) + { + long centralDirectoryOffset = _position; + + for (int i = 0; i < _entries.Count; i++) + { + await WriteCentralDirectoryHeaderAsync(_entries[i], cancellationToken).ConfigureAwait(false); + } + + long centralDirectorySize = _position - centralDirectoryOffset; + await WriteEndOfCentralDirectoryAsync(CheckedToUInt16(_entries.Count), CheckedToUInt32(centralDirectorySize), CheckedToUInt32(centralDirectoryOffset), cancellationToken).ConfigureAwait(false); + } + + private void WriteCentralDirectoryHeader(CentralDirectoryEntry entry) + { + int len = 46 + entry.NameLength; + byte[] rented = ArrayPool.Shared.Rent(len); + try + { + var span = rented.AsSpan(0, len); + span.Clear(); + + WriteUInt32(span, 0, 0x02014B50u); + WriteUInt16(span, 4, 20); + WriteUInt16(span, 6, 20); + WriteUInt16(span, 8, entry.Flags); + WriteUInt16(span, 10, entry.Method); + WriteUInt16(span, 12, entry.DosTime); + WriteUInt16(span, 14, entry.DosDate); + WriteUInt32(span, 16, entry.Crc32); + WriteUInt32(span, 20, entry.CompressedSize); + WriteUInt32(span, 24, entry.UncompressedSize); + WriteUInt16(span, 28, CheckedToUInt16(entry.NameLength)); + WriteUInt16(span, 30, 0); + WriteUInt16(span, 32, 0); + WriteUInt16(span, 34, 0); + WriteUInt16(span, 36, 0); + WriteUInt32(span, 38, 0); + WriteUInt32(span, 42, entry.LocalHeaderOffset); + entry.NameBuffer.AsSpan(0, entry.NameLength).CopyTo(span.Slice(46)); + +#if NETSTANDARD2_0 + WriteRaw(rented, 0, len); +#else + WriteRaw(span); +#endif + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private async Task WriteCentralDirectoryHeaderAsync(CentralDirectoryEntry entry, CancellationToken cancellationToken) + { + int len = 46 + entry.NameLength; + byte[] rented = ArrayPool.Shared.Rent(len); + try + { + var span = rented.AsSpan(0, len); + span.Clear(); + + WriteUInt32(span, 0, 0x02014B50u); + WriteUInt16(span, 4, 20); + WriteUInt16(span, 6, 20); + WriteUInt16(span, 8, entry.Flags); + WriteUInt16(span, 10, entry.Method); + WriteUInt16(span, 12, entry.DosTime); + WriteUInt16(span, 14, entry.DosDate); + WriteUInt32(span, 16, entry.Crc32); + WriteUInt32(span, 20, entry.CompressedSize); + WriteUInt32(span, 24, entry.UncompressedSize); + WriteUInt16(span, 28, CheckedToUInt16(entry.NameLength)); + WriteUInt16(span, 30, 0); + WriteUInt16(span, 32, 0); + WriteUInt16(span, 34, 0); + WriteUInt16(span, 36, 0); + WriteUInt32(span, 38, 0); + WriteUInt32(span, 42, entry.LocalHeaderOffset); + entry.NameBuffer.AsSpan(0, entry.NameLength).CopyTo(span.Slice(46)); + + await WriteRawAsync(rented, 0, len, cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private void WriteEndOfCentralDirectory(ushort entryCount, uint centralDirectorySize, uint centralDirectoryOffset) + { + byte[] rented = ArrayPool.Shared.Rent(22); + try + { + var span = rented.AsSpan(0, 22); + span.Clear(); + + WriteUInt32(span, 0, 0x06054B50u); + WriteUInt16(span, 4, 0); + WriteUInt16(span, 6, 0); + WriteUInt16(span, 8, entryCount); + WriteUInt16(span, 10, entryCount); + WriteUInt32(span, 12, centralDirectorySize); + WriteUInt32(span, 16, centralDirectoryOffset); + WriteUInt16(span, 20, 0); + +#if NETSTANDARD2_0 + WriteRaw(rented, 0, 22); +#else + WriteRaw(span); +#endif + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private async Task WriteEndOfCentralDirectoryAsync(ushort entryCount, uint centralDirectorySize, uint centralDirectoryOffset, CancellationToken cancellationToken) + { + byte[] rented = ArrayPool.Shared.Rent(22); + try + { + var span = rented.AsSpan(0, 22); + span.Clear(); + + WriteUInt32(span, 0, 0x06054B50u); + WriteUInt16(span, 4, 0); + WriteUInt16(span, 6, 0); + WriteUInt16(span, 8, entryCount); + WriteUInt16(span, 10, entryCount); + WriteUInt32(span, 12, centralDirectorySize); + WriteUInt32(span, 16, centralDirectoryOffset); + WriteUInt16(span, 20, 0); + + await WriteRawAsync(rented, 0, 22, cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private static void WriteUInt16(Span dest, int offset, ushort value) + { + dest[offset] = (byte)value; + dest[offset + 1] = (byte)(value >> 8); + } + + private static void WriteUInt32(Span dest, int offset, uint value) + { + dest[offset] = (byte)value; + dest[offset + 1] = (byte)(value >> 8); + dest[offset + 2] = (byte)(value >> 16); + dest[offset + 3] = (byte)(value >> 24); + } + + private static ushort CheckedToUInt16(int value) + { + if ((uint)value > ushort.MaxValue) throw new NotSupportedException("Zip field exceeds UInt16 range."); + return (ushort)value; + } + + private static Utf8NameBuffer RentUtf8Name(string name) + { +#if NETSTANDARD2_0 + int byteCount = Encoding.UTF8.GetByteCount(name); + if (byteCount > ushort.MaxValue) + throw new ArgumentException("Entry name is too long.", nameof(name)); + + byte[] rented = ArrayPool.Shared.Rent(byteCount); + int written = Encoding.UTF8.GetBytes(name, 0, name.Length, rented, 0); + return new Utf8NameBuffer(rented, written); +#else + int byteCount = Encoding.UTF8.GetByteCount(name.AsSpan()); + if (byteCount > ushort.MaxValue) + throw new ArgumentException("Entry name is too long.", nameof(name)); + + byte[] rented = ArrayPool.Shared.Rent(byteCount); + int written = Encoding.UTF8.GetBytes(name.AsSpan(), rented.AsSpan()); + return new Utf8NameBuffer(rented, written); +#endif + } + + internal static uint CheckedToUInt32(long value) + { + if ((ulong)value > uint.MaxValue) throw new NotSupportedException("ZIP64 is not supported by this forward-only zip writer."); + return (uint)value; + } + + private void EnsureNotDisposed() + { + if (_disposed) throw new ObjectDisposedException(nameof(ForwardOnlyZipWriter)); + } + + internal interface ICompressor : IDisposable + { + void Write(ReadOnlySpan data); + void Flush(); + void Finish(); + ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken); + ValueTask FlushAsync(); + ValueTask FinishAsync(); + } + + internal static Func? CompressorProvider; + + private static ICompressor CreateDefaultCompressor(Stream sink, CompressionLevel compression) + { + return compression == CompressionLevel.NoCompression + ? (ICompressor)new CopyCompressor(sink) + : new DeflateStreamCompressor(sink, compression); + } + + private sealed class DeflateStreamCompressor : ICompressor + { + private readonly DeflateStream _deflate; + + public DeflateStreamCompressor(Stream sink, CompressionLevel level) + { + _deflate = new DeflateStream(sink, level, leaveOpen: true); + } + + public void Write(ReadOnlySpan data) + { + if (data.Length == 0) return; +#if NETSTANDARD2_0 + byte[] tmp = data.ToArray(); + _deflate.Write(tmp, 0, tmp.Length); +#else + _deflate.Write(data); +#endif + } + + public void Flush() => _deflate.Flush(); + + public void Finish() => _deflate.Dispose(); + + public void Dispose() => _deflate.Dispose(); + + public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + if (data.Length == 0) return default; +#if NETSTANDARD2_0 + var tmp = data.ToArray(); + return new ValueTask(_deflate.WriteAsync(tmp, 0, tmp.Length, cancellationToken)); +#else + return _deflate.WriteAsync(data, cancellationToken); +#endif + } + + public ValueTask FlushAsync() + { +#if NETSTANDARD2_0 + _deflate.Flush(); + return default; +#else + return new ValueTask(_deflate.FlushAsync(CancellationToken.None)); +#endif + } + + public ValueTask FinishAsync() + { +#if NETSTANDARD2_0 + _deflate.Dispose(); + return default; +#else + return _deflate.DisposeAsync(); +#endif + } + } + + private sealed class CopyCompressor : ICompressor + { + private readonly Stream _sink; + + public CopyCompressor(Stream sink) => _sink = sink; + + public void Write(ReadOnlySpan data) + { + if (data.Length == 0) return; +#if NETSTANDARD2_0 + byte[] tmp = data.ToArray(); + _sink.Write(tmp, 0, tmp.Length); +#else + _sink.Write(data); +#endif + } + + public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + if (data.Length == 0) return default; +#if NETSTANDARD2_0 + var tmp = data.ToArray(); + return new ValueTask(_sink.WriteAsync(tmp, 0, tmp.Length, cancellationToken)); +#else + return _sink.WriteAsync(data, cancellationToken); +#endif + } + + public ValueTask FlushAsync() => default; + + public ValueTask FinishAsync() => default; + + public void Flush() => _sink.Flush(); + + public void Finish() { } + + public void Dispose() { } + } + + private sealed class EntryWriteStream : Stream + { + private readonly ForwardOnlyZipWriter _owner; + private readonly CentralDirectoryEntry _entry; + private readonly CountingWriteStream _countingStream; + private readonly ICompressor _compressor; + private readonly ICrc32 _crc32; + private long _uncompressedSize; + private bool _disposed; + + public EntryWriteStream(ForwardOnlyZipWriter owner, CentralDirectoryEntry entry, CompressionLevel compression) + { + _owner = owner; + _entry = entry; + _countingStream = new CountingWriteStream(owner, bufferWrites: false); + _compressor = CompressorProvider is not null + ? CompressorProvider(_countingStream, compression) + : CreateDefaultCompressor(_countingStream, compression); + _crc32 = CreateDefaultCrc32(); + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => !_disposed; + public override long Length => _uncompressedSize; + + public override long Position + { + get => _uncompressedSize; + set => throw new NotSupportedException(); + } + + public override void Flush() => _compressor.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + if (buffer is null) throw new ArgumentNullException(nameof(buffer)); + if ((uint)offset > (uint)buffer.Length) throw new ArgumentOutOfRangeException(nameof(offset)); + if ((uint)count > (uint)(buffer.Length - offset)) throw new ArgumentOutOfRangeException(nameof(count)); + if (count == 0) return; + + EnsureNotDisposed(); + _crc32.Update(buffer.AsSpan(offset, count)); + _uncompressedSize += count; + _compressor.Write(buffer.AsSpan(offset, count)); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + if (buffer is null) throw new ArgumentNullException(nameof(buffer)); + if ((uint)offset > (uint)buffer.Length) throw new ArgumentOutOfRangeException(nameof(offset)); + if ((uint)count > (uint)(buffer.Length - offset)) throw new ArgumentOutOfRangeException(nameof(count)); + if (count == 0) return Task.CompletedTask; + + EnsureNotDisposed(); + _crc32.Update(buffer.AsSpan(offset, count)); + _uncompressedSize += count; + return _compressor.WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).AsTask(); + } + +#if !NETSTANDARD2_0 + public override void Write(ReadOnlySpan buffer) + { + if (buffer.Length == 0) return; + + EnsureNotDisposed(); + _crc32.Update(buffer); + _uncompressedSize += buffer.Length; + _compressor.Write(buffer); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + return ValueTask.FromCanceled(cancellationToken); + + if (buffer.Length == 0) return default; + + EnsureNotDisposed(); + _crc32.Update(buffer.Span); + _uncompressedSize += buffer.Length; + return _compressor.WriteAsync(buffer, cancellationToken); + } +#endif + + public override Task FlushAsync(System.Threading.CancellationToken cancellationToken) + { + EnsureNotDisposed(); + return _compressor.FlushAsync().AsTask(); + } + +#if NETSTANDARD2_0 + public ValueTask DisposeAsync() +#else + public override ValueTask DisposeAsync() +#endif + { + if (_disposed) return default; + _disposed = true; + return DisposeAsyncCore(); + } + + private async ValueTask DisposeAsyncCore() + { + try + { + await _compressor.FinishAsync().ConfigureAwait(false); + } + finally + { + try + { + await _countingStream.DisposeAsync().ConfigureAwait(false); + } + finally + { + uint compressedSize = CheckedToUInt32(_countingStream.BytesWritten); + uint uncompressedSize = CheckedToUInt32(_uncompressedSize); + await _owner.CompleteEntryAsync(_entry, _crc32.Result, compressedSize, uncompressedSize, System.Threading.CancellationToken.None).ConfigureAwait(false); + } + } + } + + protected override void Dispose(bool disposing) + { + if (_disposed) return; + _disposed = true; + + try + { + _compressor.Finish(); + } + finally + { + try + { + _countingStream.Dispose(); + } + finally + { + uint compressedSize = CheckedToUInt32(_countingStream.BytesWritten); + uint uncompressedSize = CheckedToUInt32(_uncompressedSize); + _owner.CompleteEntry(_entry, _crc32.Result, compressedSize, uncompressedSize); + + base.Dispose(disposing); + } + } + } + + private void EnsureNotDisposed() + { + if (_disposed) throw new ObjectDisposedException(nameof(EntryWriteStream)); + } + } + + private sealed class CountingWriteStream : Stream + { + private readonly ForwardOnlyZipWriter _owner; + private readonly byte[]? _buffer; + private int _buffered; + private bool _disposed; + + public CountingWriteStream(ForwardOnlyZipWriter owner, bool bufferWrites) + { + _owner = owner; + if (bufferWrites) + _buffer = ArrayPool.Shared.Rent(16 * 1024); + } + + public long BytesWritten { get; private set; } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => BytesWritten; + + public override long Position + { + get => BytesWritten; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + FlushBuffered(); + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + if (buffer is null) throw new ArgumentNullException(nameof(buffer)); + if ((uint)offset > (uint)buffer.Length) throw new ArgumentOutOfRangeException(nameof(offset)); + if ((uint)count > (uint)(buffer.Length - offset)) throw new ArgumentOutOfRangeException(nameof(count)); + if (count == 0) return; + + EnsureNotDisposed(); + WriteBuffered(buffer.AsSpan(offset, count)); + BytesWritten += count; + } + +#if !NETSTANDARD2_0 + public override void Write(ReadOnlySpan buffer) + { + if (buffer.Length == 0) return; + + EnsureNotDisposed(); + WriteBuffered(buffer); + BytesWritten += buffer.Length; + } +#endif + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (buffer is null) throw new ArgumentNullException(nameof(buffer)); + if ((uint)offset > (uint)buffer.Length) throw new ArgumentOutOfRangeException(nameof(offset)); + if ((uint)count > (uint)(buffer.Length - offset)) throw new ArgumentOutOfRangeException(nameof(count)); + if (count == 0) return Task.CompletedTask; + EnsureNotDisposed(); + return WriteBufferedAsync(buffer, offset, count, cancellationToken); + } + +#if !NETSTANDARD2_0 + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (buffer.Length == 0) return default; + EnsureNotDisposed(); + BytesWritten += buffer.Length; + if (_buffer is null) + { + return _owner.WriteRawAsync(buffer, cancellationToken); + } + WriteBuffered(buffer.Span); + return default; + } +#endif + + private Task WriteBufferedAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (count == 0) return Task.CompletedTask; + BytesWritten += count; + if (_buffer is null) + { + return _owner.WriteRawAsync(buffer, offset, count, cancellationToken); + } + WriteBuffered(buffer.AsSpan(offset, count)); + return Task.CompletedTask; + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return FlushBufferedAsync(cancellationToken); + } + + private Task FlushBufferedAsync(CancellationToken cancellationToken) + { + if (_buffer is null || _buffered == 0) return Task.CompletedTask; + var t = _owner.WriteRawAsync(_buffer, 0, _buffered, cancellationToken); + _buffered = 0; + return t; + } + +#if NETSTANDARD2_0 + public ValueTask DisposeAsync() +#else + public override ValueTask DisposeAsync() +#endif + { + if (_disposed) return default; + _disposed = true; + FlushBuffered(); + if (_buffer is not null) + ArrayPool.Shared.Return(_buffer); +#if NETSTANDARD2_0 + return default; +#else + return base.DisposeAsync(); +#endif + } + + protected override void Dispose(bool disposing) + { + if (_disposed) return; + _disposed = true; + FlushBuffered(); + if (_buffer is not null) + ArrayPool.Shared.Return(_buffer); + base.Dispose(disposing); + } + + private void FlushBuffered() + { + if (_buffer is null || _buffered == 0) return; + _owner.WriteRaw(_buffer, 0, _buffered); + _buffered = 0; + } + + private void WriteBuffered(ReadOnlySpan buffer) + { + if (_buffer is null) + { +#if NETSTANDARD2_0 + var tmp = buffer.ToArray(); + _owner.WriteRaw(tmp, 0, tmp.Length); +#else + _owner.WriteRaw(buffer); +#endif + return; + } + + if (buffer.Length >= _buffer.Length) + { + FlushBuffered(); +#if NETSTANDARD2_0 + var tmp = buffer.ToArray(); + _owner.WriteRaw(tmp, 0, tmp.Length); +#else + _owner.WriteRaw(buffer); +#endif + return; + } + + if (_buffered + buffer.Length > _buffer.Length) + FlushBuffered(); + + buffer.CopyTo(_buffer.AsSpan(_buffered)); + _buffered += buffer.Length; + } + + private void EnsureNotDisposed() + { + if (_disposed) throw new ObjectDisposedException(nameof(CountingWriteStream)); + } + } + + private sealed class CentralDirectoryEntry + { + public CentralDirectoryEntry(byte[] nameBuffer, int nameLength, ushort method, ushort flags, ushort dosTime, ushort dosDate, uint localHeaderOffset) + { + NameBuffer = nameBuffer; + NameLength = nameLength; + Method = method; + Flags = flags; + DosTime = dosTime; + DosDate = dosDate; + LocalHeaderOffset = localHeaderOffset; + } + + public byte[] NameBuffer { get; private set; } + public int NameLength { get; } + public ushort Method { get; } + public ushort Flags { get; } + public ushort DosTime { get; } + public ushort DosDate { get; } + public uint LocalHeaderOffset { get; } + public uint Crc32 { get; set; } + public uint CompressedSize { get; set; } + public uint UncompressedSize { get; set; } + + public void ReturnNameBuffer() + { + if (NameBuffer.Length == 0) return; + ArrayPool.Shared.Return(NameBuffer); + NameBuffer = Array.Empty(); + } + } + + private readonly struct Utf8NameBuffer + { + public Utf8NameBuffer(byte[] buffer, int length) + { + Buffer = buffer; + Length = length; + } + + public byte[] Buffer { get; } + public int Length { get; } + } + + private readonly struct DosDateTime + { + public DosDateTime(ushort time, ushort date) + { + Time = time; + Date = date; + } + + public ushort Time { get; } + public ushort Date { get; } + + public static DosDateTime From(DateTime value) + { + if (value.Kind == DateTimeKind.Unspecified) value = DateTime.SpecifyKind(value, DateTimeKind.Local); + else if (value.Kind == DateTimeKind.Utc) + throw new ArgumentException("DosDateTime.From requires local time. Convert UTC to local time before calling.", nameof(value)); + if (value.Year < 1980) value = new DateTime(1980, 1, 1, 0, 0, 0, DateTimeKind.Local); + + ushort time = (ushort)((value.Hour << 11) | (value.Minute << 5) | (value.Second >> 1)); + ushort date = (ushort)(((value.Year - 1980) << 9) | (value.Month << 5) | value.Day); + return new DosDateTime(time, date); + } + } + + private static readonly uint[] Crc32Table = BuildCrc32Table(); + +#if DEBUG + static ForwardOnlyZipWriter() + { + var data = System.Text.Encoding.ASCII.GetBytes("123456789"); + const uint expected = 0xCBF43926u; + + var intrinsic = new IntrinsicCrc32(); + intrinsic.Update(data); + if (intrinsic.Result != expected) + throw new InvalidOperationException($"IntrinsicCrc32 self-test failed: {intrinsic.Result:X8}"); + + var sliced = new SlicingBy8Crc32(); + sliced.Update(data); + if (sliced.Result != expected) + throw new InvalidOperationException($"SlicingBy8Crc32 self-test failed: {sliced.Result:X8}"); + } +#endif + + internal interface ICrc32 + { + void Update(ReadOnlySpan data); + uint Result { get; } + } + + internal static Func? Crc32Provider; + + private static ICrc32 CreateDefaultCrc32() + { + return Crc32Provider is not null + ? Crc32Provider() +#if NET6_0_OR_GREATER + : (Crc32.IsSupported + ? (ICrc32)new IntrinsicCrc32() + : new SlicingBy8Crc32()); +#else + : new SlicingBy8Crc32(); +#endif + } + + private sealed class IntrinsicCrc32 : ICrc32 + { + private uint _crc = 0xFFFFFFFFu; + + public void Update(ReadOnlySpan data) + { +#if NET6_0_OR_GREATER + if (Crc32.IsSupported) + { + int i = 0, len = data.Length; + while (i + 8 <= len) + { + ulong v = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(i)); + _crc = Crc32.ComputeCrc32(_crc, (uint)v); + _crc = Crc32.ComputeCrc32(_crc, (uint)(v >> 32)); + i += 8; + } + if (i + 4 <= len) + { + _crc = Crc32.ComputeCrc32(_crc, BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i))); + i += 4; + } + if (i + 2 <= len) + { + _crc = Crc32.ComputeCrc32(_crc, BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(i))); + i += 2; + } + for (; i < len; i++) + _crc = Crc32.ComputeCrc32(_crc, data[i]); + return; + } +#endif + for (int i = 0; i < data.Length; i++) + _crc = Crc32Table[(_crc ^ data[i]) & 0xFF] ^ (_crc >> 8); + } + + public uint Result => ~_crc; + } + + private sealed class SlicingBy8Crc32 : ICrc32 + { + private uint _crc = 0xFFFFFFFFu; + + public void Update(ReadOnlySpan data) + { + uint c = _crc; + int len = data.Length; + int i = 0; + while (i + 8 <= len) + { + c ^= BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i)); + c = Crc32Table[0x700 + (c & 0xFF)] ^ + Crc32Table[0x600 + ((c >> 8) & 0xFF)] ^ + Crc32Table[0x500 + ((c >> 16) & 0xFF)] ^ + Crc32Table[0x400 + (c >> 24)] ^ + Crc32Table[0x300 + data[i + 4]] ^ + Crc32Table[0x200 + data[i + 5]] ^ + Crc32Table[0x100 + data[i + 6]] ^ + Crc32Table[0x000 + data[i + 7]]; + i += 8; + } + for (; i < len; i++) + c = Crc32Table[0x000 + (byte)(c ^ data[i])] ^ (c >> 8); + _crc = c; + } + + public uint Result => ~_crc; + } + + private static uint[] BuildCrc32Table() + { + var table = new uint[8 * 256]; + for (int n = 0; n < 256; n++) + { + uint value = (uint)n; + for (int bit = 0; bit < 8; bit++) + value = (value & 1) != 0 ? 0xEDB88320u ^ (value >> 1) : value >> 1; + table[n] = value; + } + for (int n = 0; n < 256; n++) + { + uint value = table[n]; + for (int k = 1; k < 8; k++) + { + value = table[value & 0xFF] ^ (value >> 8); + table[k * 256 + n] = value; + } + } + return table; + } + } +} diff --git a/src/Magicodes.IE.IO/Internal/ModuleInitializerAttribute.cs b/src/Magicodes.IE.IO/Internal/ModuleInitializerAttribute.cs new file mode 100644 index 00000000..fe613872 --- /dev/null +++ b/src/Magicodes.IE.IO/Internal/ModuleInitializerAttribute.cs @@ -0,0 +1,11 @@ +#if NETSTANDARD2_0 +using System; + +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + public sealed class ModuleInitializerAttribute : Attribute + { + } +} +#endif diff --git a/src/Magicodes.IE.IO/Internal/NumberFormatHelper.cs b/src/Magicodes.IE.IO/Internal/NumberFormatHelper.cs new file mode 100644 index 00000000..06148907 --- /dev/null +++ b/src/Magicodes.IE.IO/Internal/NumberFormatHelper.cs @@ -0,0 +1,90 @@ + +using System; +using System.Buffers; +using System.Globalization; +using System.Text; + +namespace Magicodes.IE.IO +{ + internal static class NumberFormatHelper + { + + public static void WriteInt32(int value, Span dest, out int written) + { +#if NETSTANDARD2_0 + var s = value.ToString(CultureInfo.InvariantCulture); + var tmp = Encoding.UTF8.GetBytes(s); + if (tmp.Length > dest.Length) + throw new InvalidOperationException($"NumberFormatHelper.WriteInt32: buffer too small (need {tmp.Length}, have {dest.Length})"); + tmp.AsSpan().CopyTo(dest); + written = tmp.Length; +#else + if (!System.Buffers.Text.Utf8Formatter.TryFormat(value, dest, out written)) + throw new InvalidOperationException("Utf8Formatter.TryFormat failed (buffer too small?)"); +#endif + } + + public static void WriteInt64(long value, Span dest, out int written) + { +#if NETSTANDARD2_0 + var s = value.ToString(CultureInfo.InvariantCulture); + var tmp = Encoding.UTF8.GetBytes(s); + if (tmp.Length > dest.Length) + throw new InvalidOperationException($"NumberFormatHelper.WriteInt64: buffer too small (need {tmp.Length}, have {dest.Length})"); + tmp.AsSpan().CopyTo(dest); + written = tmp.Length; +#else + if (!System.Buffers.Text.Utf8Formatter.TryFormat(value, dest, out written)) + throw new InvalidOperationException("Utf8Formatter.TryFormat failed (buffer too small?)"); +#endif + } + + public static void WriteDouble(double value, char format, Span dest, out int written) + { + if (format != 'R' && format != 'r' && format != 'G' && format != 'g' && format != 'F' && format != 'f') + throw new ArgumentException($"WriteDouble: unsupported format '{format}' (only R/G/F accepted)", nameof(format)); +#if NETSTANDARD2_0 + // On .NET Framework, double.ToString("R") emits 15 sig digits and is NOT reliably + // round-trippable (the shortest-round-trip "R" fix landed in .NET Core 3.0). + // "G17" always emits 17 sig digits → uniquely identifies the double on every TFM. + // The modern path uses Utf8Formatter 'R' (shortest round-trip); both round-trip + // correctly, the ns2.0 output is just more verbose (17 digits vs shortest). + var fmt = (format == 'R' || format == 'r') ? "G17" : (format == 'F' || format == 'f') ? "F" : "G"; + var s = value.ToString(fmt, CultureInfo.InvariantCulture); + var tmp = Encoding.UTF8.GetBytes(s); + if (tmp.Length > dest.Length) + throw new InvalidOperationException($"NumberFormatHelper.WriteDouble: buffer too small (need {tmp.Length}, have {dest.Length})"); + tmp.AsSpan().CopyTo(dest); + written = tmp.Length; +#else + if (!System.Buffers.Text.Utf8Formatter.TryFormat(value, dest, out written, new System.Buffers.StandardFormat(format))) + throw new InvalidOperationException("Utf8Formatter.TryFormat failed (buffer too small?)"); +#endif + } + + public static void WriteDoubleFixedTrimmed(double value, int decimals, Span dest, out int written) + { + if (decimals < 0 || decimals > 28) + throw new ArgumentOutOfRangeException(nameof(decimals)); + + value = Math.Round(value, decimals, MidpointRounding.AwayFromZero); + +#if NETSTANDARD2_0 + var s = value.ToString("F" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + var tmp = Encoding.UTF8.GetBytes(s); + if (tmp.Length > dest.Length) + throw new InvalidOperationException($"NumberFormatHelper.WriteDoubleFixedTrimmed: buffer too small (need {tmp.Length}, have {dest.Length})"); + tmp.AsSpan().CopyTo(dest); + written = tmp.Length; +#else + if (!System.Buffers.Text.Utf8Formatter.TryFormat(value, dest, out written, new System.Buffers.StandardFormat('F', (byte)decimals))) + throw new InvalidOperationException("Utf8Formatter.TryFormat failed (buffer too small?)"); +#endif + + while (written > 0 && dest[written - 1] == (byte)'0') + written--; + if (written > 0 && dest[written - 1] == (byte)'.') + written--; + } + } +} diff --git a/src/Magicodes.IE.IO/Internal/XmlHelper.cs b/src/Magicodes.IE.IO/Internal/XmlHelper.cs new file mode 100644 index 00000000..e9f1af2d --- /dev/null +++ b/src/Magicodes.IE.IO/Internal/XmlHelper.cs @@ -0,0 +1,118 @@ + +using System; + +namespace Magicodes.IE.IO +{ + internal static class XmlHelper + { + internal static string EscapeXmlAttr(string s) => EscapeXmlText(s, isAttribute: true); + + internal static string EscapeXmlText(string s, bool isAttribute = false) + { + if (string.IsNullOrEmpty(s)) return s; + + int extra = 0; + bool needsRewrite = false; + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (char.IsHighSurrogate(c)) + { + if (i + 1 < s.Length && char.IsLowSurrogate(s[i + 1])) + { + i++; + continue; + } + needsRewrite = true; + continue; + } + if (char.IsLowSurrogate(c)) + { + needsRewrite = true; + continue; + } + extra += c switch + { + '&' => 4, + '<' => 3, + '>' => 3, + '"' => isAttribute ? 5 : 0, + '\'' => isAttribute ? 5 : 0, + _ => 0, + }; + + if (IsIllegalXmlChar(c)) + { + needsRewrite = true; + } + else if (c is '&' or '<' or '>' or '"' or '\'') + { + needsRewrite = true; + } + } + + if (!needsRewrite) + return s; + + var chars = new char[s.Length + extra]; + int p = 0; + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (char.IsHighSurrogate(c) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1])) + { + chars[p++] = c; + chars[p++] = s[++i]; + continue; + } + if (char.IsSurrogate(c)) + { + chars[p++] = '\uFFFD'; + continue; + } + switch (c) + { + case '&': + chars[p++] = '&'; chars[p++] = 'a'; chars[p++] = 'm'; chars[p++] = 'p'; chars[p++] = ';'; + break; + case '<': + chars[p++] = '&'; chars[p++] = 'l'; chars[p++] = 't'; chars[p++] = ';'; + break; + case '>': + chars[p++] = '&'; chars[p++] = 'g'; chars[p++] = 't'; chars[p++] = ';'; + break; + case '"': + if (isAttribute) + { + chars[p++] = '&'; chars[p++] = 'q'; chars[p++] = 'u'; chars[p++] = 'o'; chars[p++] = 't'; chars[p++] = ';'; + } + else + { + chars[p++] = '"'; + } + break; + case '\'': + if (isAttribute) + { + chars[p++] = '&'; chars[p++] = 'a'; chars[p++] = 'p'; chars[p++] = 'o'; chars[p++] = 's'; chars[p++] = ';'; + } + else + { + chars[p++] = '\''; + } + break; + default: + chars[p++] = IsIllegalXmlChar(c) ? '\uFFFD' : c; + break; + } + } + + return new string(chars, 0, p); + } + + private static bool IsIllegalXmlChar(char c) + { + return (c < 0x20 && c != '\t' && c != '\n' && c != '\r') || c is '\uFFFE' or '\uFFFF'; + } + } +} diff --git a/src/Magicodes.IE.IO/Internal/ZipArchiveExtensions.cs b/src/Magicodes.IE.IO/Internal/ZipArchiveExtensions.cs new file mode 100644 index 00000000..d348c438 --- /dev/null +++ b/src/Magicodes.IE.IO/Internal/ZipArchiveExtensions.cs @@ -0,0 +1,15 @@ + +using System.IO; +using System.IO.Compression; + +namespace Magicodes.IE.IO +{ + internal static class ZipArchiveExtensions + { + internal static Stream OpenEntry(this ZipArchive archive, string name, CompressionLevel compression) + { + var entry = archive.CreateEntry(name, compression); + return entry.Open(); + } + } +} diff --git a/src/Magicodes.IE.IO/MIGRATION.md b/src/Magicodes.IE.IO/MIGRATION.md new file mode 100644 index 00000000..f1bd5630 --- /dev/null +++ b/src/Magicodes.IE.IO/MIGRATION.md @@ -0,0 +1,198 @@ +# 迁移指南:Magicodes.IE.Excel → Magicodes.IE.IO + +本文档帮助把现有 `Magicodes.IE.Excel`(基于 EPPlus)迁移到新的 `Magicodes.IE.IO`。两个包可以共存,逐步迁移。 + +--- + +## 为什么迁移 + +| 维度 | 老的 `Magicodes.IE.Excel` | 新的 `Magicodes.IE.IO` | +|---|---|---| +| EPPlus 依赖 | vendored(嵌 100+MB) | 运行时无;`netstandard2.0` 带兼容性依赖 | +| 性能 | 中(EPPlus DOM 模型) | 更轻,但要按场景看基准 | +| AOT / Trim | 不友好(EPPlus 反射) | 普通 .NET 应用无需额外配置;NativeAOT/Trim 场景为 DTO 添加 `[XlsxExportable]` 即启用已验证的生成读写路径(包含自定义 `CellConverter`) | +| 多 sheet | 复杂 API | `WriteWorkbookToBytes(...)` 一行 | +| 异步流 | 无 | `IAsyncEnumerable` | +| 模板导出 | 有(复杂) | `Xlsx.ExportByTemplateAsync` | +| Reader | 有 | 有(`Xlsx.Read` / `Xlsx.ReadAsync`) | +| 协议 | MIT | MIT | + +--- + +## 安装 + +```bash +# 保留老的 Magicodes.IE.Excel 同时引入新的 +dotnet add package Magicodes.IE.IO +``` + +目标框架:`netstandard2.0` / `net6.0` / `net8.0` / `net10.0`。 + +--- + +## API 映射(导出) + +### 1. 简单导出(零配置) + +老写法: + +```csharp +public class OrderDto +{ + [Exporter(Name = "订单号")] + public string OrderNo { get; set; } + public decimal Amount { get; set; } +} + +await new ExcelExporter().Export("/path/to/file.xlsx", data); +``` + +新写法: + +```csharp +public class OrderDto +{ + [ExporterHeader(Name = "订单号")] + public string OrderNo { get; set; } + public decimal Amount { get; set; } +} + +Xlsx.Write("/path/to/file.xlsx", data); +``` + +要点: + +- `ExcelExporter` → `Xlsx` 静态入口 +- `Export(string path, data)` → `Xlsx.Write(path, data)` +- `ExporterAttribute` → `ExporterHeaderAttribute`(老 attribute 在 IE.Core 里仍可用,但推荐迁移) + +### 2. 字节数组导出 + +老: + +```csharp +var bytes = await new ExcelExporter().ExportAsByteArray(data); +``` + +新: + +```csharp +var bytes = Xlsx.ToBytes(data); +``` + +### 3. 列映射(老 `ExportDto` / 新 `ExportProfile`) + +老: + +```csharp +[ExcelExporter(Name = "订单导出", HeaderFontColor = "FF0000")] +public class OrderExportDto : ExportDto { } +``` + +新: + +```csharp +var profile = new ExportProfile() + .Sheet("订单表") + .Column(x => x.OrderNo, c => c.WithName("订单号")); + +var bytes = Xlsx.ToBytes(orders, profile); +``` + +要点: + +- 老的 `ExportDto` 抽象 + 配置类 → 新的 `ExportProfile` fluent builder +- 列属性改用 `.Column(x => x.Foo, c => c.WithName(...).WithFormat(...))` +- 老 `[ExporterHeader]` 在新包里继续兼容,推荐和 `[Display]` 语义对齐 + +### 4. 模板导出(老 `ExportByTemplate`) + +老: + +```csharp +await new ExcelExporter().ExportByTemplate("/template.xlsx", "/output.xlsx", data); +``` + +新: + +```csharp +await Xlsx.ExportByTemplateAsync("/template.xlsx", "/output.xlsx", data); +``` + +模板语法兼容 `{{PropertyName}}` / `{{#List}}...{{/List}}`。 + +### 5. 读取(老 `Import` / 新 `Xlsx.Read`) + +老: + +```csharp +var orders = await new ExcelImporter().Import(stream); +``` + +新: + +```csharp +var orders = new List(); +await foreach (var order in Xlsx.ReadAsync(stream)) + orders.Add(order); + +// 或同步: +var syncOrders = Xlsx.Read(stream).ToList(); +``` + +要点: + +- `Import` → `Xlsx.Read` / `Xlsx.ReadAsync` +- `[ImportHeader(Name = "...")]` → `[ImporterHeader(Name = "...")]` +- 老 `ImportDtoBase` 配置类对应新 `XlsxReadOptions` + +### 6. 不再推荐库内 DI 抽象 + +老代码如果依赖库内的导入/导出抽象,建议直接改成调用 `Xlsx` 静态入口,然后在应用层自己包一层业务服务。 + +```csharp +public sealed class OrderReportWriter +{ + public void Write(string path, IEnumerable data) + => Xlsx.Write(path, data); +} +``` + +--- + +## 行为差异 + +1. **attribute 改名**:`[Exporter(Name = ...)]` → `[ExporterHeader(Name = ...)]`;`[ImportHeader(Name = ...)]` → `[ImporterHeader(Name = ...)]` +2. **特性优先级**:fluent `cfg.WithName() > [ExporterHeader(Name=)] > [Display(Name=)] > [Description] > 属性名` +3. **多 sheet**:`WriteWorkbookToBytes(sheet1, sheet2, ...)` 接受 `Sheet` 对象 +4. **图片导出**:`XlsxWriter.AddImage(byte[], ext, fromCell, toCell)` +5. **reader 类型**:`Xlsx.Read` 约束 `T : new()` +6. **没有 `[ExportDto]` 抽象**:`ExportProfile` fluent 替代;`ExportDtoBase` 改为组合 `ExportProfile` +7. **没有 `Magicodes.ExporterAndImporter.AspNetCore` 包**:`Xlsx` 静态入口即推荐入口 + +--- + +## 不支持的功能 + +- **图表 / 宏**:xlsx 标准允许但未实现;若需要,继续用 EPPlus +- **PDF / Word / HTML / CSV / JSON 输出**:`Magicodes.IE.IO` 当前只做 `.xlsx` +- **Conditional Formatting 变体**:只支持基础规则,color scale / data bar / icon set 暂未实现 +- **Pivot Table / 自定义 XML part**:未实现 +- **大文件**:`Magicodes.IE.IO` 支持流式 `IAsyncEnumerable`,推荐用 `Xlsx.WriteAsync(stream, ...)`;但当前 ZIP writer 不支持 ZIP64,仍受 ZIP32 和单 worksheet 行列上限约束 +- **Reader 范围**:`Xlsx.Read` 读 cell 值 + SST;不读公式结果 / 条件格式 / 批注 / 命名范围 + +--- + +## 共存策略 + +两个包可同时引用,新代码用 `Magicodes.IE.IO`,老代码继续用 `Magicodes.IE.Excel`。逐步: + +1. 新功能用新包 +2. 读老表用 `Xlsx.Read` +3. 写老表用新 `Xlsx` 包 +4. 测试通过后,移除 `Magicodes.IE.Excel` 引用 +--- + +## 性能参考 + +最新 benchmark 的完整数字请直接看同仓库里的 `BenchmarkDotNet.Artifacts/results/` 报告。 diff --git a/src/Magicodes.IE.IO/Magicodes.IE.IO.csproj b/src/Magicodes.IE.IO/Magicodes.IE.IO.csproj new file mode 100644 index 00000000..5bb1b8ad --- /dev/null +++ b/src/Magicodes.IE.IO/Magicodes.IE.IO.csproj @@ -0,0 +1,39 @@ + + + + netstandard2.0;net6.0;net8.0;net10.0 + latest + enable + false + Magicodes.IE.IO + + High-performance, low-allocation Excel (.xlsx) I/O library for .NET, with streaming write APIs and a simple high-level API for import and export. + 面向 .NET 的高性能、低分配 Excel (.xlsx) 导入导出库,提供流式写入 API 与简洁的高层导入导出接口。 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Magicodes.IE.IO/Model/CellValue.cs b/src/Magicodes.IE.IO/Model/CellValue.cs new file mode 100644 index 00000000..63b1fdc7 --- /dev/null +++ b/src/Magicodes.IE.IO/Model/CellValue.cs @@ -0,0 +1,87 @@ +using System; +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + /// + /// An intermediate cell value produced by the writer and converters. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public readonly struct CellValue + { + /// + /// Gets the kind of value this cell holds. + /// + public readonly CellType Type; + /// + /// Gets the string value. Valid when is . + /// + public readonly string? StringValue; + /// + /// Gets the numeric value. Valid when is . + /// + public readonly double NumberValue; + /// + /// Gets the Boolean value. Valid when is . + /// + public readonly bool BoolValue; + + private CellValue(CellType type, string? s, double n, bool b) + { + Type = type; StringValue = s; NumberValue = n; BoolValue = b; + } + + /// + /// Represents an empty cell. + /// + public static CellValue Null => new(CellType.Null, null, 0, false); + /// + /// Creates a string cell, or when is . + /// + public static CellValue FromString(string? s) => s is null ? Null : new(CellType.String, s, 0, false); + /// + /// Creates a numeric cell. + /// + public static CellValue FromNumber(double d) => new(CellType.Number, null, d, false); + /// + /// Creates a numeric cell from a 64-bit integer. + /// + public static CellValue FromInteger(long l) => new(CellType.Number, null, l, false); + /// + /// Creates a date cell using the Excel OLE Automation date serial number. + /// + public static CellValue FromDateTime(DateTime dt) => new(CellType.Number, null, dt.ToOADate(), false); + /// + /// Creates a Boolean cell. + /// + public static CellValue FromBool(bool b) => new(CellType.Boolean, null, 0, b); + } + + /// + /// The kind of value stored in a . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public enum CellType : byte + { + /// + /// An empty cell. + /// + Null = 0, + /// + /// A string value. + /// + String = 1, + /// + /// A numeric value. + /// + Number = 2, + /// + /// A Boolean value. + /// + Boolean = 3, + /// + /// A formula. + /// + Formula = 4, + } +} diff --git a/src/Magicodes.IE.IO/Model/StylesEnums.cs b/src/Magicodes.IE.IO/Model/StylesEnums.cs new file mode 100644 index 00000000..d4c3fc57 --- /dev/null +++ b/src/Magicodes.IE.IO/Model/StylesEnums.cs @@ -0,0 +1,71 @@ +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + /// + /// The border style applied to a cell or range. + /// + public enum BorderStyle + { + /// + /// No border. + /// + None = 0, + + /// + /// A thin border. + /// + Thin = 1, + + /// + /// A medium border. + /// + Medium = 2, + + /// + /// A thick border. + /// + Thick = 3, + + /// + /// A dashed border. + /// + Dashed = 4, + + /// + /// A dotted border. + /// + Dotted = 5, + + /// + /// A double border. + /// + Double = 6, + } + + /// + /// The vertical alignment of content within a cell. + /// + public enum VerticalAlignment + { + /// + /// No explicit alignment; Excel's default is used. + /// + None = 0, + + /// + /// Align content to the top of the cell. + /// + Top = 1, + + /// + /// Center content vertically. + /// + Center = 2, + + /// + /// Align content to the bottom of the cell. + /// + Bottom = 3, + } +} diff --git a/src/Magicodes.IE.IO/MultiSheetExports.cs b/src/Magicodes.IE.IO/MultiSheetExports.cs new file mode 100644 index 00000000..e6b4686d --- /dev/null +++ b/src/Magicodes.IE.IO/MultiSheetExports.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + /// + /// Base class for the sheets that can be written by Xlsx.WriteWorkbook(Stream, IReadOnlyList<SheetBase>). + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public abstract class SheetBase + { + /// + /// Gets the name of the sheet. + /// + public string SheetName { get; } + + protected SheetBase(string sheetName) + { + SheetName = sheetName ?? throw new ArgumentNullException(nameof(sheetName)); + } + + internal abstract void WriteTo(XlsxWriter writer); + } + + /// + /// A worksheet whose rows are supplied as a non-generic and exported with default settings. + /// + public sealed class Sheet : SheetBase + { + /// + /// Gets the row data for the sheet. + /// + public IEnumerable Data { get; } + + /// + /// Initializes a new instance of the class with the default export configuration. + /// + public Sheet(string sheetName, IEnumerable data) + : base(sheetName) + { + Data = data ?? throw new ArgumentNullException(nameof(data)); + } + + internal override void WriteTo(XlsxWriter writer) + { + if (writer is null) throw new ArgumentNullException(nameof(writer)); + Xlsx.WriteSheetUntyped(writer, SheetName, Data); + } + } + + /// + /// A strongly typed worksheet whose rows are exported using an optional . + /// + /// The row model type. + public sealed class Sheet : SheetBase + { + private readonly ExportProfile? _profile; + + /// + /// Gets the row data for the sheet. + /// + public IEnumerable Data { get; } + + /// + /// Initializes a new instance of the class with the default export configuration. + /// + public Sheet(string sheetName, IEnumerable data) + : base(sheetName) + { + Data = data ?? throw new ArgumentNullException(nameof(data)); + } + + /// + /// Initializes a new instance of the class with a specific export configuration. + /// + public Sheet(string sheetName, IEnumerable data, ExportProfile profile) + : this(sheetName, data) + { + _profile = profile ?? throw new ArgumentNullException(nameof(profile)); + } + + internal override void WriteTo(XlsxWriter writer) + { + if (writer is null) throw new ArgumentNullException(nameof(writer)); + Xlsx.WriteSheet(writer, SheetName, Data, _profile); + } + } +} diff --git a/src/Magicodes.IE.IO/README.md b/src/Magicodes.IE.IO/README.md new file mode 100644 index 00000000..d64c2b55 --- /dev/null +++ b/src/Magicodes.IE.IO/README.md @@ -0,0 +1,320 @@ +# Magicodes.IE.IO + +Excel I/O 库,热点写入路径以低分配为目标。本文涵盖公共 API 速览、能力边界与选型建议。 + +覆盖常用 Excel 能力(数据验证 / 公式 / 行高 / SST / 表格 / 保护 / 打印 / 大纲 / 批注 / 命名范围 / 条件格式)。 + +--- + +## 公开 API 一览 + +高层入口都在 `Xlsx` 静态类中: + +| 分组 | API | 说明 | +|---|---|---| +| 写 | `Write(path, data, cfg?, opt?)` | 写到文件 | +| 写 | `Write(Stream, data, cfg?, opt?)` | 写到流 | +| 写 | `Write(IBufferWriter, data, cfg?, opt?)` | 主低分配路径 | +| 写 | `WriteAsync(Stream, IAsyncEnumerable, cfg?, opt?, ct)` | 异步枚举边查边写 | +| 写 | `WriteAsync(Stream, IEnumerable, cfg?, opt?, ct)` | 已物化集合的异步流写入 | +| 写 | `ToBytes(data, cfg?, opt?)` | 便利层,返回 `byte[]` | +| 读 | `Read(Stream, profile?, onError?)` | 同步读取 | +| 读 | `ReadAsync(Stream, profile?, onError?, ct)` | 异步枚举读取 | +| 多 sheet | `WriteWorkbook(Stream, params Sheet[])` | 多 sheet 写流 | +| 多 sheet | `WriteWorkbook(IBufferWriter, params Sheet[])` | 多 sheet 低分配 | +| 多 sheet | `WriteWorkbookToBytes(params Sheet[])` | 多 sheet 便利层 | +| 模板 | `ExportByTemplateAsync(path, path, data)` | 模板导出(文件) | +| 模板 | `ExportByTemplateAsync(Stream, Stream, data)` | 模板导出(流) | + +配置类型:`ExportProfile`(列名/格式/忽略/sheet 名/fluent DSL)、`XlsxWriteOptions`(压缩/严格引用)、`XlsxReadOptions`(列映射/自定义转换器)、`XlsxReadErrorInfo`(读取错误信息)和 `Sheet` / `Sheet`(多 sheet 元素)。 + +选型建议: + +- 写入文件、响应流或大数据时,优先使用 `Write(Stream, ...)` 或 `Write(IBufferWriter, ...)`。 +- 数据源支持 `IAsyncEnumerable`,或需要异步等待流 I/O 时,使用 `WriteAsync(...)`。 +- 只需要一次性取得结果时,使用 `ToBytes(...)`;该便利层必然物化 `byte[]`。 +- `Read(...)` / `ReadAsync(...)` 读取 xlsx;异步版本的收益在流 I/O 等待,单元格解析与对象映射仍是同步 CPU 工作。 + +写入 API 不拥有调用方提供的输出流,不会负责关闭它。直接使用底层 `XlsxWriter` 时,必须先添加至少一个 worksheet,再调用 `Complete()`。 + +--- + +## 安装 + +```bash +dotnet add package Magicodes.IE.IO +``` + +目标框架:`netstandard2.0` / `net6.0` / `net8.0` / `net10.0`。 +`net6.0` 及以上目标仅依赖 BCL;`netstandard2.0` 目标由 NuGet 自动带入兼容性依赖。 + +--- + +## 快速上手 + +### 零配置 + +```csharp +Xlsx.Write("/tmp/orders.xlsx", orders); +``` + +表头 = 属性名,列序 = 声明序,自动 inline string / number / datetime / bool / enum / struct / record。 + +### fluent profile + +```csharp +var bytes = Xlsx.ToBytes(orders, p => p + .Sheet("订单表") + .Column(x => x.OrderNo, c => c.WithName("订单号").WithWidth(30)) + .Column(x => x.Amount, c => c.WithFormat("0.00")) + .Ignore(x => x.CreatedAt) + .WithFreezeHeader(true)); +``` + +### 多 sheet + +```csharp +var bytes = Xlsx.WriteWorkbookToBytes( + new Sheet("Orders", orders), + new Sheet("Items", items)); +``` + +不想要 `byte[]`: + +```csharp +using var fs = File.Create("/tmp/report.xlsx"); +Xlsx.WriteWorkbook(fs, + new Sheet("Orders", orders), + new Sheet("Items", items)); +``` + +### 读取 xlsx + +```csharp +// 同步 +var rows = Xlsx.Read(stream).ToList(); + +// 异步 +await foreach (var o in Xlsx.ReadAsync(stream)) { ... } +``` + +### 异步流写入(IAsyncEnumerable) + +```csharp +async IAsyncEnumerable YieldOrders() +{ + await foreach (var o in dbContext.Orders.AsAsyncEnumerable()) + yield return o; +} + +await Xlsx.WriteAsync(stream, YieldOrders()); // 边查边写,输出 I/O 异步等待 +``` + +### 模板导出 + +基于一个 `.xlsx` 模板,把单元格里 `{{属性名}}` 占位符替换为数据值;`{{#集合}}…{{/集合}}` 列表块按集合逐行展开。模板原有的样式、合并、图片、公式都保留。 + +#### 基本用法 + +```csharp +// 文件 → 文件 +await Xlsx.ExportByTemplateAsync("template.xlsx", "output.xlsx", data); + +// 流 → 流(非可寻流自动 buffer 到内存,无需 seekable) +await Xlsx.ExportByTemplateAsync(templateStream, outputStream, data); +``` + +#### 单值占位符 `{{属性名}}` + +模板单元格里写 `{{属性名}}`,导出时按属性名(大小写不敏感)反射取值替换。一个单元格内可放多个占位符加任意字面文本: + +```csharp +public class TemplateHolder +{ + public string CustomerName { get; set; } = ""; + public string OrderNo { get; set; } = ""; + public List Items { get; set; } = new(); +} + +// 模板 A1 单元格:客户:{{CustomerName}} A2 单元格:订单号:{{OrderNo}} +await Xlsx.ExportByTemplateAsync("tpl.xlsx", "out.xlsx", + new TemplateHolder { CustomerName = "张三", OrderNo = "SO-1" }); +// 结果:A1 = 客户:张三 A2 = 订单号:SO-1 +``` + +行为细则: +- 值为 `null` → 替换为空串。 +- 属性找不到 → 占位符**原样保留**(不报错,便于发现拼写错误)。 +- 只支持 T 的**顶层属性**,不支持 `{{A.B}}` 嵌套路径。 +- 值经 `Convert.ToString(value, InvariantCulture)` 转文本,并按上下文自动 XML 转义:在文本节点里转 `<`/`&` 等,在属性值里转引号。如 `CustomerName="A&B"` → `A&B<C>`。 +- ⚠️ 日期/数字被替换为**不变区域性字符串**(如 `2026/07/01 00:00:00`、`12.5`),不会套用模板单元格的数字格式。如需保留日期/数字格式,自行先格式化为字符串再绑定。 + +#### 列表块 `{{#集合}}…{{/集合}}` + +`{{#Items}}` 与 `{{/Items}}` 之间的内容作为"行模板"按 `Items` 集合逐项展开,块内 `{{字段}}` 取自当前项。**两个标记要放在 `` 元素之间**(不在单元格里),中间是完整的一行或多行作为重复模板: + +```csharp +public class TemplateCell +{ + public string Name { get; set; } = ""; + public int Qty { get; set; } + public decimal Price { get; set; } +} +``` + +模板 sheet XML(节选): + +```xml +客户:{{CustomerName}} +{{#Items}}{{Name}} x{{Qty}} ${{Price}}{{/Items}} +``` + +`Items` 有 2 项(`{Name="A",Qty=2,Price=5}`、`{Name="B",Qty=3,Price=7}`)时,导出后行号自动平移: + +```xml +…客户:李四… +A x2 $5 +B x3 $7 +``` + +- `Items` 必须是 T 的 `IEnumerable` 属性。集合为空 → 块整段移除、块后行上移回收模板行位。 +- 块内/块后的 ``、`ref=`/`location=` 属性、以及公式 `` 里的 A1 引用都同步平移;公式**引号内的字符串原样不动**。 +- **不支持嵌套**列表块(`{{#A}}…{{#B}}…{{/B}}…{{/A}}` 会被非贪婪正则错配)。 + +#### 工作表名 `{{!Sheet:Name=名称}}` + +在 `workbook.xml` 的 sheet name 处写 `{{!Sheet:Name=销售明细}}`,导出时替换为"销售明细"(**静态**替换为占位符里写定的名称,不绑定数据)。 + +#### 处理范围与资源 + +- 只替换 `xl/worksheets/*`、`xl/sharedStrings.xml`、`xl/workbook.xml` 三类 part 里的占位符;样式、图片、图表等 part 不处理。 +- 输入流不可寻时自动复制到内存再处理;输出流由调用方拥有,库不主动 Dispose(流重载)。 + +### 写入选项 + +```csharp +var bytes = Xlsx.ToBytes(orders, p => p.WithFreezeHeader(), new XlsxWriteOptions +{ + Compression = CompressionLevel.NoCompression, + StrictCellReferences = false, +}); +``` + +--- + +## 属性与配置 + +优先级:**fluent `cfg.WithName()` > `[ExporterHeader(Name=)]` > `[Display(Name=)]` > `[Description(...)]` > 属性名** + +```csharp +public class Order +{ + [ExporterHeader(Name = "订单号", Width = 30)] + public string OrderNo { get; set; } + + [DisplayFormat(DataFormatString = "0.00")] + public decimal Amount { get; set; } + + [ExporterHeader(IsIgnore = true)] + public DateTime CreatedAt { get; set; } +} + +var bytes = Xlsx.ToBytes(orders); // 表头 = 订单号 / Amount;CreatedAt 自动忽略 +``` + +--- + +## 高级功能(默认隐藏) + +下面是高级功能,不是普通导出的首选入口。 + +### 数据验证(Data Validation) + +```csharp +using var ms = new MemoryStream(); +using var writer = new XlsxWriter(ms, "订单表"); +writer.AddDataValidation(new DataValidation("C2:C1000", DataValidationType.List, "\"已下单,已发货,已完成\"")); +``` + +### 公式(列级 + 行级占位) + +```csharp +var bytes = Xlsx.ToBytes(items, p => p + .Column(x => x.Qty, c => c.WithName("数量")) + .Column(x => x.Price, c => c.WithName("单价")) + .Column(x => x.Total, c => c.WithName("合计").WithFormula("A{row}*B{row}"))); +``` + +`{row}` 占位当前 1-based sheet 行号(自动展开为 `A2*B2` / `A3*B3` / ...)。 + +### Shared Strings Table(大文件去重) + +```csharp +var bytes = Xlsx.ToBytes(orders, p => p.WithAutoSst(true)); +``` + +`WithAutoSst(true)` 走启发式:同步和异步路径都会预扫最多前 64 行,字符串去重比例低于 +70% 时切到 SST。`RowFilter` 会先执行,null 数据项不会参与探测;行数少于 16 行或使用 +`NoCompression` 时保持 inline string。 + +### 表格 / 保护 / 打印 / 命名范围 / 批注 / 条件格式 / 大纲 + +`XlsxWriter.AddTable(...)` 添加 Excel 表格(写入 `xl/tables`);表格样式用 `TableDefinition.WithTableStyle(TableStyle.Xxx)`(内置样式枚举,IntelliSense 可提示全部 60 个内置名)指定,或用 `WithTableStyle("自定义样式名")` 兜底。其余分别走 `SetSheetProtection(...)` / `SetPageSetup(...)` / `AddNamedRange(...)` / `AddComment(...)` / `AddConditionalFormatting(...)` / `SetOutline(...)`。详细示例见 `tests/Magicodes.IE.IO.Tests/`。 + +--- + +## 压缩档位 + +```csharp +// 默认 Fastest +Xlsx.ToBytes(data); + +// NoCompression:大表上更快,但产物明显变大 +Xlsx.ToBytes(data, options: new XlsxWriteOptions { Compression = CompressionLevel.NoCompression }); +``` + +默认 `Fastest` 已覆盖绝大多数场景。若导出是高频热点、CPU 比带宽更紧张,用 `NoCompression` 换更快的写入(代价是文件变大);若文件要经网络传输、体积优先,再考虑 `Optimal`。 + +--- + +## 性能 + +热点写入路径以低分配为目标:`Stream` / `IBufferWriter` 是主低分配路径,`ToBytes(...)` 便利层因需物化 `byte[]` 分配更高。具体基准数字见仓库内 BenchmarkDotNet 产物。严格来说,当前目标是“低分配主路径 + 便利层可用”,不是所有场景绝对零分配。 + +--- + +## 与老 Magicodes.IE.Excel 的区别 + +| | 老的 `Magicodes.IE.Excel` | 新的 `Magicodes.IE.IO` | +|---|---|---| +| 第三方依赖 | 有 | 运行时无;`netstandard2.0` 带兼容性依赖 | +| 性能 | 中(DOM 模型) | **流式低分配** | +| reader | 有 | `Read` / `ReadAsync` | +| 多 sheet | 复杂 | `WriteWorkbookToBytes(...)` 一行 | +| 异步流 | 无 | `IAsyncEnumerable` | +| AOT / Trim | 不友好 | 普通 .NET 应用无需额外配置;NativeAOT/Trim 场景为 DTO 添加 `[XlsxExportable]` 即启用已验证的生成读写路径(包含自定义 `CellConverter`) | +| API 风格 | 老抽象层 / 多接口入口 | `Xlsx.Write` / `Xlsx.Read` 单一静态入口 | + +**迁移**:把旧的导出/导入抽象入口替换为 `Xlsx.Write(...)` / `Xlsx.ToBytes(...)` / `Xlsx.Read(...)`,`ExporterHeaderAttribute` 仍然兼容,`ExportDtoAttribute` 不再需要(直接使用 `ExportProfile`)。 + +--- + +## AOT 与裁剪 + +`[XlsxExportable]` 标记的 DTO 会由 Source Generator 生成属性 getter、cell writer、列元数据和 cell setter,读写路径都不需要访问 DTO 的反射元数据。未标记的类型在动态代码不可用时会回退到反射。 + +普通 .NET 应用可以直接使用 `Xlsx`,无需为 DTO 添加标记。需要 NativeAOT/Trim 时,推荐为 DTO 添加 `[XlsxExportable]`,启用与 `System.Text.Json` 配合 `JsonSerializerContext` 类似的生成元数据路径;该路径包含自定义 `CellConverter`,且 `Read` 通过基类虚分发调用,不依赖运行时反射。 + +--- + +## 已知限制 + +- **API 形态**:`ToBytes(...)` 最终仍会物化 `byte[]`;真大数据请优先用 `Write(streamOrBuffer, ...)` +- **Reader**:`Xlsx.Read` 不读公式结果(只读缓存值);不读条件格式 / 批注 / 表格 +- **ZIP**:当前 writer 不支持 ZIP64,超过 ZIP32 限制的工作簿需要使用其他工具链 + +--- + +## 协议 + +MIT \ No newline at end of file diff --git a/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedGettersRegistry.cs b/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedGettersRegistry.cs new file mode 100644 index 00000000..e1146545 --- /dev/null +++ b/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedGettersRegistry.cs @@ -0,0 +1,37 @@ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + + /// + /// Source-generated registry mapping types to their object-based getter delegates. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static class XlsxGeneratedGettersRegistry + { + private static readonly ConcurrentDictionary>> _getters = new(); + + /// + /// Registers a getter factory for the specified type. + /// + public static void Register(Func>> factory) + { + if (factory is null) return; + var dict = factory(); + if (dict is null || dict.Count == 0) return; + _getters[typeof(T)] = dict; + } + + /// + /// Retrieves the registered getters for the given type, or when no generated code exists. + /// + public static IReadOnlyDictionary>? TryGet(Type t) + { + return _getters.TryGetValue(t, out var g) ? g : null; + } + } +} diff --git a/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedRowWritersRegistry.cs b/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedRowWritersRegistry.cs new file mode 100644 index 00000000..387d22b7 --- /dev/null +++ b/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedRowWritersRegistry.cs @@ -0,0 +1,39 @@ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + + /// + /// Source-generated registry mapping types to their strongly typed row writers. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static class XlsxGeneratedRowWritersRegistry + { + private static readonly ConcurrentDictionary _writers = new(); + + /// + /// Registers a row-writer factory for the specified type. + /// + public static void Register(Func>> factory) + { + if (factory is null) return; + var dict = factory(); + if (dict is null || dict.Count == 0) return; + _writers[typeof(T)] = dict; + } + + /// + /// Retrieves the registered row writer for the given type, or when no generated code exists. + /// + public static IReadOnlyDictionary>? TryGet() + { + if (_writers.TryGetValue(typeof(T), out var o) && o is IReadOnlyDictionary> d) + return d; + return null; + } + } +} diff --git a/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedTypeMetadata.cs b/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedTypeMetadata.cs new file mode 100644 index 00000000..8b771aca --- /dev/null +++ b/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedTypeMetadata.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + /// + /// Setter generated for a source-generated property. + /// + public delegate bool XlsxGeneratedPropertySetter( + T item, + string cell, + IReadOnlyList? converters, + bool date1904); + + /// + /// Read/write metadata generated for one property. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class XlsxGeneratedPropertyMetadata + { + /// + /// Gets the property name. + /// + public string Name { get; } + /// + /// Gets the header name used for import matching. + /// + public string? ImportHeader { get; } + /// + /// Gets the display name used for the export header. + /// + public string? DisplayName { get; } + /// + /// Gets the description of the property. + /// + public string? Description { get; } + /// + /// Gets the number format string. + /// + public string? Format { get; } + /// + /// Gets the column width. + /// + public double? Width { get; } + /// + /// Gets the export ordering index. + /// + public int ExportIndex { get; } + /// + /// Gets a value indicating whether the property is excluded from export/import. + /// + public bool IsIgnored { get; } + /// + /// Gets the name of the target type. + /// + public string TargetTypeName { get; } + /// + /// Gets the generated value accessor. + /// + public Func Getter { get; } + /// + /// Gets the object-based value accessor. + /// + public Func ObjectGetter { get; } + /// + /// Gets the generated cell writer, when one is available. + /// + public Action? CellWriter { get; } + /// + /// Gets the generated property setter, when one is available. + /// + public XlsxGeneratedPropertySetter? Setter { get; } + + public XlsxGeneratedPropertyMetadata( + string name, + string? importHeader, + string? displayName, + string? description, + string? format, + double? width, + int exportIndex, + bool isIgnored, + string targetTypeName, + Func getter, + Action? cellWriter, + XlsxGeneratedPropertySetter? setter) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + ImportHeader = importHeader; + DisplayName = displayName; + Description = description; + Format = format; + Width = width; + ExportIndex = exportIndex; + IsIgnored = isIgnored; + TargetTypeName = targetTypeName ?? throw new ArgumentNullException(nameof(targetTypeName)); + Getter = getter ?? throw new ArgumentNullException(nameof(getter)); + ObjectGetter = item => item is T typed ? getter(typed) : CellValue.Null; + CellWriter = cellWriter; + Setter = setter; + } + } + + /// + /// Registry for source-generated type metadata. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static class XlsxGeneratedTypeMetadataRegistry + { + private static readonly ConcurrentDictionary _metadata = new(); + + /// + /// Registers metadata produced for . + /// + public static void Register(Func>> factory) + { + if (factory is null) throw new ArgumentNullException(nameof(factory)); + var metadata = factory(); + if (metadata is null || metadata.Count == 0) return; + _metadata[typeof(T)] = metadata; + } + + /// + /// Returns metadata for , if generated metadata was registered. + /// + public static IReadOnlyList>? TryGet() + { + return _metadata.TryGetValue(typeof(T), out var value) + ? value as IReadOnlyList> + : null; + } + } + + /// + /// Shared parsing helper used by generated readers. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static class XlsxGeneratedReadHelper + { + /// + /// Parses a cell value using the generated reader's conversion rules. + /// + public static bool TryParse( + string cell, + out T value, + IReadOnlyList? converters, + bool date1904) + { + if (!CellValueParser.TryParse(cell, typeof(T), out var parsed, converters, date1904)) + { + value = default!; + return false; + } + + if (parsed is null) + { + value = default!; + return true; + } + + value = parsed is T typed ? typed : (T)parsed; + return true; + } + } +} diff --git a/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedTypedGettersRegistry.cs b/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedTypedGettersRegistry.cs new file mode 100644 index 00000000..9d71e2a7 --- /dev/null +++ b/src/Magicodes.IE.IO/SourceGen/XlsxGeneratedTypedGettersRegistry.cs @@ -0,0 +1,39 @@ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + + /// + /// Source-generated registry mapping types to their strongly typed getter delegates. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static class XlsxGeneratedTypedGettersRegistry + { + private static readonly ConcurrentDictionary> _getters = new(); + + /// + /// Registers a strongly typed getter factory for the specified type. + /// + public static void Register(Func>> factory) + { + if (factory is null) return; + var dict = factory(); + if (dict is null || dict.Count == 0) return; + var d = new System.Collections.Generic.Dictionary(dict.Count); + foreach (var kv in dict) d[kv.Key] = kv.Value; + _getters[typeof(T)] = d; + } + + /// + /// Retrieves the registered strongly typed getters for the given type, or when no generated code exists. + /// + public static IReadOnlyDictionary? TryGet(Type t) + { + return _getters.TryGetValue(t, out var g) ? g : null; + } + } +} diff --git a/src/Magicodes.IE.IO/Xlsx.cs b/src/Magicodes.IE.IO/Xlsx.cs new file mode 100644 index 00000000..059cbb8c --- /dev/null +++ b/src/Magicodes.IE.IO/Xlsx.cs @@ -0,0 +1,432 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Magicodes.IE.IO +{ + /// + /// Provides high-level, allocation-conscious APIs for reading and writing .xlsx workbooks without explicitly creating a writer or reader. + /// + /// + /// Export: use the overloads to write rows to a file, stream, , or array. + /// Import: use the overloads to stream deserialized rows from a workbook. + /// Multi-sheet: . Template export: . + /// When no is supplied, headers and formats are inferred from property names and from [Display], [Description], and [DisplayFormat] attributes. The underlying writer/reader is a dependency-free, streaming, low-allocation OOXML implementation and does not depend on EPPlus. + /// Lifetime: overloads that take a path own and dispose the underlying ; overloads that take a or do not, and the caller is responsible for disposal. + /// + public static class Xlsx + { + /// + /// Writes the specified rows to the .xlsx file at . + /// + /// The row model type. May be a class, record, struct, or readonly struct. + /// The output file path. Cannot be or whitespace. + /// The rows to export. Any . + /// An optional callback that configures the header, columns, and styles through the fluent API. When omitted, columns are inferred from the model. + /// Optional , such as the compression level and cell-reference strictness. + /// or is . + /// is empty or whitespace. + public static void Write(string path, IEnumerable data, Action>? configure = null, XlsxWriteOptions? options = null) + { + ValidatePath(path); + if (data is null) throw new ArgumentNullException(nameof(data)); + using var fs = File.Create(path); + XlsxWritePipeline.Run(fs, data, configure, options); + } + + /// + /// Writes the specified rows to using a pre-built instead of a fluent callback. + /// + /// Use this overload when you reuse the same configuration across many exports, to avoid rebuilding the profile each time. + /// , , or is . + /// is empty or whitespace. + public static void Write(string path, IEnumerable data, ExportProfile profile, XlsxWriteOptions? options = null) + { + ValidatePath(path); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (profile is null) throw new ArgumentNullException(nameof(profile)); + using var fs = File.Create(path); + XlsxWritePipeline.Run(fs, data, profile, options); + } + + /// + /// Writes the specified rows to an existing stream. The caller owns and disposes . + /// + /// Useful for writing directly to a response stream (for example, ASP.NET's HttpResponse.Body) or any externally managed stream. + /// or is . + public static void Write(Stream output, IEnumerable data, Action>? configure = null, XlsxWriteOptions? options = null) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (data is null) throw new ArgumentNullException(nameof(data)); + XlsxWritePipeline.Run(output, data, configure, options); + } + + /// + /// Writes the specified rows to an existing stream using a pre-built . The caller owns and disposes . + /// + /// , , or is . + public static void Write(Stream output, IEnumerable data, ExportProfile profile, XlsxWriteOptions? options = null) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (profile is null) throw new ArgumentNullException(nameof(profile)); + XlsxWritePipeline.Run(output, data, profile, options); + } + + /// + /// Writes the specified rows to an (for example, a response buffer) for low-allocation output. + /// + /// Avoids the extra wrapper used by the stream overloads; bytes are written directly to the buffer. + /// or is . + public static void Write(IBufferWriter output, IEnumerable data, Action>? configure = null, XlsxWriteOptions? options = null) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (data is null) throw new ArgumentNullException(nameof(data)); + XlsxWritePipeline.Run(new BufferWriterStream(output), data, configure, options); + } + + /// + /// Writes the specified rows to an using a pre-built . + /// + /// , , or is . + public static void Write(IBufferWriter output, IEnumerable data, ExportProfile profile, XlsxWriteOptions? options = null) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (profile is null) throw new ArgumentNullException(nameof(profile)); + XlsxWritePipeline.Run(new BufferWriterStream(output), data, profile, options); + } + + /// + /// Streams rows from an to a stream, enumerating and writing concurrently so the data is never fully materialized. + /// + /// The caller owns and disposes . Use this overload for large or paged data sources (for example, a database query). Unlike the async overloads, the data source itself is asynchronous. + /// or is . + public static async Task WriteAsync(Stream output, IAsyncEnumerable data, Action>? configure = null, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (data is null) throw new ArgumentNullException(nameof(data)); + var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest; + var strictCellReferences = options?.StrictCellReferences ?? true; + await using var writer = new XlsxWriter(output, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences); + await XlsxWritePipeline.RunAsync(writer, data, configure, cancellationToken).ConfigureAwait(false); + } + + /// + /// Streams rows from an to a stream using a pre-built . + /// + /// , , or is . + public static async Task WriteAsync(Stream output, IAsyncEnumerable data, ExportProfile profile, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (profile is null) throw new ArgumentNullException(nameof(profile)); + var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest; + var strictCellReferences = options?.StrictCellReferences ?? true; + await using var writer = new XlsxWriter(output, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences); + await XlsxWritePipeline.RunAsync(writer, data, profile, cancellationToken).ConfigureAwait(false); + } + + /// + /// Writes an in-memory to a stream asynchronously. + /// + /// The data source is not asynchronous here; only the writing is. For an asynchronous data source, use the overload. + /// or is . + public static async Task WriteAsync(Stream output, IEnumerable data, Action>? configure = null, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (data is null) throw new ArgumentNullException(nameof(data)); + var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest; + var strictCellReferences = options?.StrictCellReferences ?? true; + await using var writer = new XlsxWriter(output, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences); + await XlsxWritePipeline.RunAsync(writer, data, configure, cancellationToken).ConfigureAwait(false); + } + + /// + /// Writes an in-memory to a stream asynchronously using a pre-built . + /// + /// , , or is . + public static async Task WriteAsync(Stream output, IEnumerable data, ExportProfile profile, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (profile is null) throw new ArgumentNullException(nameof(profile)); + var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest; + var strictCellReferences = options?.StrictCellReferences ?? true; + await using var writer = new XlsxWriter(output, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences); + await XlsxWritePipeline.RunAsync(writer, data, profile, cancellationToken).ConfigureAwait(false); + } + + /// + /// Exports the specified rows to a array. + /// + /// For very large datasets, prefer the stream overloads to avoid holding the entire workbook in memory. The initial capacity is estimated from the collection size to reduce reallocations. + /// is . + public static byte[] ToBytes(IEnumerable data, Action>? configure = null, XlsxWriteOptions? options = null) + { + if (data is null) throw new ArgumentNullException(nameof(data)); + using var ms = new MemoryStream(EstimateToBytesCapacity(data)); + XlsxWritePipeline.Run(ms, data, configure, options); + return ms.ToArray(); + } + + /// + /// Exports the specified rows to a array using a pre-built . + /// + /// or is . + public static byte[] ToBytes(IEnumerable data, ExportProfile profile, XlsxWriteOptions? options = null) + { + if (data is null) throw new ArgumentNullException(nameof(data)); + if (profile is null) throw new ArgumentNullException(nameof(profile)); + using var ms = new MemoryStream(EstimateToBytesCapacity(data)); + XlsxWritePipeline.Run(ms, data, profile, options); + return ms.ToArray(); + } + + // Only pre-size ToBytes when the source is a large, countable collection, to avoid + // over-allocating for small or deferred sequences. + private static int EstimateToBytesCapacity(IEnumerable data) + { + if (data is ICollection coll && coll.Count > 1024) + return Math.Min(coll.Count * 64, 64 * 1024 * 1024); + return 0; + } + + /// + /// Reads the first worksheet of a .xlsx workbook and lazily returns the deserialized rows as . + /// + /// The row model type. Must have a parameterless constructor. + /// The stream containing the .xlsx data. It is disposed when enumeration completes. + /// Optional that configures header mapping and date handling. + /// Optional callback invoked when a cell cannot be parsed. When omitted, a is thrown on the first parse error. + /// A lazy . Each row is parsed on demand as you iterate; do not enumerate the result more than once. + /// When a source-generated reader is available for , it is used for reflection-free reading; otherwise reflection is used. + public static IEnumerable Read(Stream stream, XlsxReadOptions? profile = null, Action? onParseError = null) where T : new() + { + using var reader = new XlsxReader(stream); + var headers = reader.ReadHeader(); + var converters = profile?.GetConverters(); + int rowIndex = 0; + + var generated = XlsxGeneratedTypeMetadataRegistry.TryGet(); + if (generated is not null) + { + var generatedResolver = XlsxReadPipeline.BuildGeneratedResolver(headers, profile, generated); + while (true) + { + var row = reader.ReadNextRowView(); + if (row is null) yield break; + var item = new T(); + for (int i = 0; i < row.Count; i++) + { + var cell = row[i]; + if (cell is null) continue; + var property = generatedResolver(i); + if (property is null) continue; + XlsxReadPipeline.SetGeneratedProperty(item, property, cell, onParseError, rowIndex, i, headers.Length > i ? headers[i] : null, converters, reader.Date1904); + } + rowIndex++; + yield return item; + } + } + + var resolver = XlsxReadPipeline.BuildResolver(headers, profile); + while (true) + { + var row = reader.ReadNextRowView(); + if (row is null) yield break; + object item = new T(); + for (int i = 0; i < row.Count; i++) + { + var cell = row[i]; + if (cell is null) continue; + var prop = resolver(i); + if (prop is null) continue; + XlsxReadPipeline.SetCellProperty(item, prop, cell, onParseError, rowIndex, i, headers.Length > i ? headers[i] : null, converters, reader.Date1904); + } + rowIndex++; + yield return (T)item; + } + } + + /// + /// Reads the first worksheet of a .xlsx workbook asynchronously and returns the deserialized rows as . + /// + /// Supports cancellation via . Otherwise the behavior matches . + /// A lazy . Do not enumerate the result more than once. + public static async IAsyncEnumerable ReadAsync(Stream stream, XlsxReadOptions? profile = null, Action? onParseError = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : new() + { + cancellationToken.ThrowIfCancellationRequested(); + using var reader = new XlsxReader(stream); + var headers = await reader.ReadHeaderAsync(cancellationToken).ConfigureAwait(false); + var converters = profile?.GetConverters(); + int rowIndex = 0; + + var generated = XlsxGeneratedTypeMetadataRegistry.TryGet(); + if (generated is not null) + { + var generatedResolver = XlsxReadPipeline.BuildGeneratedResolver(headers, profile, generated); + while (!cancellationToken.IsCancellationRequested) + { + var row = await reader.ReadNextRowViewAsync(cancellationToken).ConfigureAwait(false); + if (row is null) yield break; + var item = new T(); + for (int i = 0; i < row.Count; i++) + { + var cell = row[i]; + if (cell is null) continue; + var property = generatedResolver(i); + if (property is null) continue; + XlsxReadPipeline.SetGeneratedProperty(item, property, cell, onParseError, rowIndex, i, headers.Length > i ? headers[i] : null, converters, reader.Date1904); + } + rowIndex++; + yield return item; + } + cancellationToken.ThrowIfCancellationRequested(); + yield break; + } + + var resolver = XlsxReadPipeline.BuildResolver(headers, profile); + while (!cancellationToken.IsCancellationRequested) + { + var row = await reader.ReadNextRowViewAsync(cancellationToken).ConfigureAwait(false); + if (row is null) yield break; + object item = new T(); + for (int i = 0; i < row.Count; i++) + { + var cell = row[i]; + if (cell is null) continue; + var prop = resolver(i); + if (prop is null) continue; + XlsxReadPipeline.SetCellProperty(item, prop, cell, onParseError, rowIndex, i, headers.Length > i ? headers[i] : null, converters, reader.Date1904); + } + rowIndex++; + yield return (T)item; + } + cancellationToken.ThrowIfCancellationRequested(); + } + + /// + /// Writes multiple sheets to a stream. At least one non- sheet must be supplied. + /// + /// Each sheet's type and configuration are determined by its subclass. The caller owns and disposes . + /// or is . + /// is empty or contains a element. + public static void WriteWorkbook(Stream output, IReadOnlyList sheets) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (sheets is null) throw new ArgumentNullException(nameof(sheets)); + if (sheets.Count == 0) throw new ArgumentException("At least one sheet is required.", nameof(sheets)); + using var writer = new XlsxWriter(output); + for (int i = 0; i < sheets.Count; i++) + { + if (sheets[i] is null) throw new ArgumentException("Sheet cannot be null.", nameof(sheets)); + XlsxWritePipeline.BuildAndWrite(writer, sheets[i]); + } + } + + /// + /// Writes multiple sheets to a stream, supplied as a parameter list. + /// + /// Equivalent to wrapping the sheets in an and calling . + public static void WriteWorkbook(Stream output, params SheetBase[] sheets) + { + if (sheets is null) throw new ArgumentNullException(nameof(sheets)); + WriteWorkbook(output, (IReadOnlyList)sheets); + } + + /// + /// Writes multiple sheets to an . At least one non- sheet must be supplied. + /// + /// or is . + /// is empty or contains a element. + public static void WriteWorkbook(IBufferWriter output, IReadOnlyList sheets) + { + if (output is null) throw new ArgumentNullException(nameof(output)); + if (sheets is null) throw new ArgumentNullException(nameof(sheets)); + if (sheets.Count == 0) throw new ArgumentException("At least one sheet is required.", nameof(sheets)); + using var writer = new XlsxWriter(new BufferWriterStream(output)); + for (int i = 0; i < sheets.Count; i++) + { + if (sheets[i] is null) throw new ArgumentException("Sheet cannot be null.", nameof(sheets)); + XlsxWritePipeline.BuildAndWrite(writer, sheets[i]); + } + } + + /// + /// Writes multiple sheets to an , supplied as a parameter list. + /// + public static void WriteWorkbook(IBufferWriter output, params SheetBase[] sheets) + { + if (sheets is null) throw new ArgumentNullException(nameof(sheets)); + WriteWorkbook(output, (IReadOnlyList)sheets); + } + + /// + /// Writes multiple sheets and returns them as a array. + /// + /// is . + /// is empty or contains a element. + public static byte[] WriteWorkbookToBytes(IReadOnlyList sheets) + { + if (sheets is null) throw new ArgumentNullException(nameof(sheets)); + if (sheets.Count == 0) throw new ArgumentException("At least one sheet is required.", nameof(sheets)); + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + for (int i = 0; i < sheets.Count; i++) + { + if (sheets[i] is null) throw new ArgumentException("Sheet cannot be null.", nameof(sheets)); + XlsxWritePipeline.BuildAndWrite(writer, sheets[i]); + } + } + return ms.ToArray(); + } + + /// + /// Writes multiple sheets and returns them as a array, supplied as a parameter list. + /// + public static byte[] WriteWorkbookToBytes(params SheetBase[] sheets) + { + if (sheets is null) throw new ArgumentNullException(nameof(sheets)); + return WriteWorkbookToBytes((IReadOnlyList)sheets); + } + + /// + /// Replaces {{PropertyName}} placeholders in an .xlsx template with values from and writes the result to . + /// + /// The template is opened and closed by this method, and the output file is created and closed as well. + public static Task ExportByTemplateAsync(string templatePath, string outputPath, T data, CancellationToken cancellationToken = default) + => XlsxTemplateExporter.ExportAsync(templatePath, outputPath, data, cancellationToken); + + /// + /// Exports a model into a template stream and writes the result to an output stream. Both streams are owned and disposed by the caller. + /// + /// Placeholders take the form {{PropertyName}}; the lifetimes of both streams are the caller's responsibility. + public static Task ExportByTemplateAsync(Stream templateStream, Stream outputStream, T data, CancellationToken cancellationToken = default) + => XlsxTemplateExporter.ExportAsync(templateStream, outputStream, data, cancellationToken); + + /// + /// Writes the given untyped data to using an already-open . + /// + internal static void WriteSheetUntyped(XlsxWriter writer, string sheetName, System.Collections.IEnumerable data) + => XlsxWritePipeline.WriteSheetUntyped(writer, sheetName, data); + + /// + /// Writes the given typed data to using an already-open . + /// + internal static void WriteSheet(XlsxWriter writer, string sheetName, IEnumerable data, ExportProfile? profile) + => XlsxWritePipeline.WriteSheet(writer, sheetName, data, profile); + + // Throws if the output path is null or whitespace. + private static void ValidatePath(string path) + { + if (path is null) throw new ArgumentNullException(nameof(path)); + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Path cannot be empty.", nameof(path)); + } + } +} diff --git a/src/Magicodes.IE.IO/XlsxException.cs b/src/Magicodes.IE.IO/XlsxException.cs new file mode 100644 index 00000000..ac0ab32c --- /dev/null +++ b/src/Magicodes.IE.IO/XlsxException.cs @@ -0,0 +1,88 @@ +using System; +using System.ComponentModel; + +namespace Magicodes.IE.IO +{ + /// + /// The exception that is thrown when an error occurs while reading or writing an .xlsx workbook, optionally carrying the cell location of the failure. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class XlsxException : Exception + { + /// + /// Gets the zero-based row index where the error occurred, or if not applicable. + /// + public int? RowIndex { get; } + + /// + /// Gets the zero-based column index where the error occurred, or if not applicable. + /// + public int? ColIndex { get; } + + /// + /// Gets the A1-style cell reference (for example, A2), or if not available. + /// + public string? CellRef { get; } + + /// + /// Gets the header text of the failing column, or if not available. + /// + public string? Header { get; } + + /// + /// Gets the target property name, or if not available. + /// + public string? PropertyName { get; } + + /// + /// Gets the raw cell text that could not be parsed, or if not available. + /// + public string? RawCellValue { get; } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + public XlsxException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified error message and inner exception. + /// + public XlsxException(string message, Exception innerException) : base(message, innerException) { } + + /// + /// Initializes a new instance of the class with a message and the cell location of the failure. + /// + public XlsxException(string message, int? rowIndex = null, int? colIndex = null, + string? cellRef = null, string? header = null, string? propertyName = null, string? rawCellValue = null) + : base(message) + { + RowIndex = rowIndex; + ColIndex = colIndex; + CellRef = cellRef; + Header = header; + PropertyName = propertyName; + RawCellValue = rawCellValue; + } + + /// + /// Gets the error message, appended with any available cell location details. + /// + public override string Message + { + get + { + var baseMsg = base.Message; + var loc = new System.Text.StringBuilder(128); + if (RowIndex.HasValue) loc.Append("Row=").Append(RowIndex.Value).Append(' '); + if (ColIndex.HasValue) loc.Append("Col=").Append(ColIndex.Value).Append(' '); + if (CellRef is not null) loc.Append("CellRef=").Append(CellRef).Append(' '); + if (Header is not null) loc.Append("Header=").Append(Header).Append(' '); + if (PropertyName is not null) loc.Append("Property=").Append(PropertyName).Append(' '); + if (RawCellValue is not null) loc.Append("RawValue=").Append(RawCellValue).Append(' '); + return loc.Length > 0 + ? $"{baseMsg} (at {loc.ToString().TrimEnd()})" + : baseMsg; + } + } + } +} diff --git a/src/Magicodes.IE.IO/XlsxWriteOptions.cs b/src/Magicodes.IE.IO/XlsxWriteOptions.cs new file mode 100644 index 00000000..f3a3a9ef --- /dev/null +++ b/src/Magicodes.IE.IO/XlsxWriteOptions.cs @@ -0,0 +1,32 @@ +using System; +using System.IO.Compression; + +namespace Magicodes.IE.IO +{ + /// + /// Controls compression and cell-reference behavior when writing an .xlsx workbook. + /// + public sealed class XlsxWriteOptions + { + /// + /// The ZIP compression level. The default is . + /// + public CompressionLevel Compression { get; init; } = CompressionLevel.Fastest; + + /// + /// Whether to write an A1 reference (for example, A1) on every cell. + /// Disabling this yields a smaller file but reduces compatibility with some consumers. + /// + public bool StrictCellReferences { get; init; } = true; + + /// + /// Creates an instance that sets only the compression level. + /// + public static XlsxWriteOptions WithCompression(CompressionLevel compression) => new() { Compression = compression }; + + /// + /// Creates an instance that omits cell references, for consumers that do not rely on them. + /// + public static XlsxWriteOptions WithoutCellReferences() => new() { StrictCellReferences = false }; + } +} diff --git a/src/MagicodesWebSite/Pages/Index.cshtml b/src/MagicodesWebSite/Pages/Index.cshtml index 11eac8d3..cd8f1c0b 100644 --- a/src/MagicodesWebSite/Pages/Index.cshtml +++ b/src/MagicodesWebSite/Pages/Index.cshtml @@ -1,56 +1,34 @@ -@page -@model MagicodesWebSite.IndexModel -@{ } +@page +@model MagicodesWebSite.IndexModel +@{ + Layout = null; +} - - - - + + + Magicodes WebSite + + + - - \ No newline at end of file + + const blob = await response.blob(); + const contentDisposition = response.headers.get("Content-Disposition") || ""; + const match = contentDisposition.match(/filename\*?=(?:UTF-8''|\"?)([^\";]+)\"?/i); + const filename = match ? decodeURIComponent(match[1]) : "export.xlsx"; + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +}); + + + diff --git a/src/MagicodesWebSite/Program.cs b/src/MagicodesWebSite/Program.cs index 4fbfe8b6..26d508dc 100644 --- a/src/MagicodesWebSite/Program.cs +++ b/src/MagicodesWebSite/Program.cs @@ -22,20 +22,7 @@ public static void Main(string[] args) public static IWebHostBuilder CreateHostBuilder(string[] args) { - string scenario; - if (args.Length == 0) - { - Console.WriteLine("Choose a sample to run:"); - Console.WriteLine($"1. MiddlewareScenario"); - Console.WriteLine($"2. FilterScenario"); - Console.WriteLine(); - - scenario = Console.ReadLine(); - } - else - { - scenario = args[0]; - } + var scenario = args.Length > 0 ? args[0] : FilterScenario; Type startupType; switch (scenario) @@ -69,6 +56,7 @@ class StartupMiddlewareTest public void ConfigureServices(IServiceCollection services) { services.AddControllers(); + services.AddRazorPages(); services.AddMagicodesPdfExporter(); services.AddSwaggerGen(c => { @@ -85,6 +73,7 @@ public void Configure(IApplicationBuilder app) app.UseSwaggerUI(); app.UseEndpoints(endpoints => { + endpoints.MapRazorPages(); endpoints.MapControllers(); }); } diff --git a/src/MagicodesWebSite/Properties/launchSettings.json b/src/MagicodesWebSite/Properties/launchSettings.json index 1fb32c6d..298b6b7f 100644 --- a/src/MagicodesWebSite/Properties/launchSettings.json +++ b/src/MagicodesWebSite/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,8 +20,8 @@ "MagicodesWebSite": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "weatherforecast", - "applicationUrl": "https://localhost:5001;http://localhost:5000", + "launchUrl": "", + "applicationUrl": "http://localhost:5000;https://localhost:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/tests/Magicodes.IE.IO.ReadmeSamples/Program.cs b/tests/Magicodes.IE.IO.ReadmeSamples/Program.cs new file mode 100644 index 00000000..d86c20ef --- /dev/null +++ b/tests/Magicodes.IE.IO.ReadmeSamples/Program.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using Magicodes.IE.IO; + +namespace ReadmeSamples; + +public sealed class Order +{ + [ExporterHeader(Name = "订单号", Width = 30)] + public string OrderNo { get; set; } = ""; + [DisplayFormat(DataFormatString = "0.00")] + public decimal Amount { get; set; } + public int Qty { get; set; } + public decimal Price { get; set; } + public decimal Total { get; set; } + public System.DateTime CreatedAt { get; set; } +} + +public sealed class Item { public string Name { get; set; } = ""; } + +public static class Program +{ + private static async IAsyncEnumerable YieldOrders() + { + await Task.Yield(); + yield return new Order { OrderNo = "O-1" }; + } + + public static async Task Main() + { + var orders = new[] { new Order { OrderNo = "O-1" } }; + var items = new[] { new Item { Name = "I-1" } }; + using var stream = new MemoryStream(); + _ = Xlsx.ToBytes(orders, p => p.Sheet("订单表") + .Column(x => x.OrderNo, c => c.WithName("订单号").WithWidth(30)) + .Column(x => x.Amount, c => c.WithFormat("0.00")) + .Ignore(x => x.CreatedAt) + .WithFreezeHeader(true)); + _ = Xlsx.WriteWorkbookToBytes(new Sheet("Orders", orders), new Sheet("Items", items)); + Xlsx.WriteWorkbook(stream, new Sheet("Orders", orders), new Sheet("Items", items)); + stream.Position = 0; + _ = Xlsx.Read(stream).ToList(); + stream.Position = 0; + await foreach (var _ in Xlsx.ReadAsync(stream)) { } + stream.SetLength(0); + await Xlsx.WriteAsync(stream, YieldOrders()); + _ = Xlsx.ToBytes(orders, options: new XlsxWriteOptions { Compression = CompressionLevel.NoCompression, StrictCellReferences = false }); + _ = Xlsx.ToBytes(orders, p => p + .Column(x => x.Qty, c => c.WithName("数量")) + .Column(x => x.Price, c => c.WithName("单价")) + .Column(x => x.Total, c => c.WithName("合计").WithFormula("A{row}*B{row}"))); + _ = Xlsx.ToBytes(orders, p => p.WithAutoSst(true)); + await Xlsx.ExportByTemplateAsync("/tmp/template.xlsx", "/tmp/output.xlsx", orders[0]); + } +} diff --git a/tests/Magicodes.IE.IO.ReadmeSamples/ReadmeSamples.csproj b/tests/Magicodes.IE.IO.ReadmeSamples/ReadmeSamples.csproj new file mode 100644 index 00000000..bad6f22e --- /dev/null +++ b/tests/Magicodes.IE.IO.ReadmeSamples/ReadmeSamples.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + latest + + + + + diff --git a/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj new file mode 100644 index 00000000..69e2fbbf --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj @@ -0,0 +1,39 @@ + + + + net471;net6.0;net8.0;net10.0 + false + latest + enable + + + + net6.0;net8.0;net10.0 + false + latest + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/tests/Magicodes.IE.IO.Tests/NuGetConsumerIntegrationTests.cs b/tests/Magicodes.IE.IO.Tests/NuGetConsumerIntegrationTests.cs new file mode 100644 index 00000000..64d1dc87 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/NuGetConsumerIntegrationTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Magicodes.IE.IO; +using Xunit; + +namespace Magicodes.IE.IO.Tests; + +public sealed class NuGetConsumerIntegrationTests +{ + [Fact] + public void PackedNuGet_ConsumerWithSourceGeneratedDto_Builds() + { + RunPackedConsumer(publishAot: false); + } + + [Fact] + public void PackedNuGet_SourceGeneratedDto_PublishesNativeAot() + { +#if NET8_0 + RunPackedConsumer(publishAot: true); +#endif + } + + private static void RunPackedConsumer(bool publishAot) + { + var repo = FindRepositoryRoot(); + var dotnet = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "/usr/local/share/dotnet/dotnet"; + if (!File.Exists(dotnet)) dotnet = "dotnet"; + var root = Path.Combine(Path.GetTempPath(), "mieio-consumer-" + Guid.NewGuid().ToString("N")); + var feed = Path.Combine(root, "feed"); + var consumer = Path.Combine(root, "consumer"); + Directory.CreateDirectory(feed); + Directory.CreateDirectory(consumer); + try + { + var packageVersion = "1.0.0-consumer-" + Guid.NewGuid().ToString("N"); + Run(dotnet, repo, "build src/Magicodes.IE.IO.SourceGenerator/Magicodes.IE.IO.SourceGenerator.csproj -c Debug -t:Rebuild -m:1 --no-restore"); + Run(dotnet, repo, $"pack src/Magicodes.IE.IO/Magicodes.IE.IO.csproj -c Debug --no-restore -m:1 -p:PackageVersion={packageVersion} -o \"{feed}\""); + var package = Directory.GetFiles(feed, "Magicodes.IE.IO.*.nupkg").Single(p => !p.EndsWith(".symbols.nupkg", StringComparison.OrdinalIgnoreCase)); + File.WriteAllText(Path.Combine(consumer, "NuGet.config"), $""); + var publishProperties = publishAot ? "truetrue" : string.Empty; + File.WriteAllText(Path.Combine(consumer, "Consumer.csproj"), $"Exenet8.0enable{publishProperties}"); + File.WriteAllText(Path.Combine(consumer, "Program.cs"), "using System.IO; using System.Linq; using Magicodes.IE.IO; namespace Consumer; public sealed class MoneyConverter : CellConverter { public override bool Read(string cell, out decimal value) => decimal.TryParse(cell, out value); } [XlsxExportable] public sealed class ConsumerRow { public string Name { get; set; } = \"\"; public decimal Amount { get; set; } } public static class Program { public static void Main() { var metadata = XlsxGeneratedTypeMetadataRegistry.TryGet(); if (metadata is null) throw new System.Exception(\"metadata:null\"); using var ms = new MemoryStream(); Xlsx.Write(ms, new[] { new ConsumerRow { Name = \"ok\", Amount = 12.5m } }); ms.Position = 0; var options = new XlsxReadOptions().WithConverter(new MoneyConverter()); var row = Xlsx.Read(ms, options).Single(); if (row.Name != \"ok\" || row.Amount != 12.5m) throw new System.Exception($\"{row.Name}|{row.Amount}\"); } }"); + var rid = RuntimeInformation.RuntimeIdentifier; + Run(dotnet, consumer, $"restore --no-cache --configfile NuGet.config{(publishAot ? $" -r {rid}" : string.Empty)} -v:minimal"); + if (!publishAot) + { + Run(dotnet, consumer, "build --no-restore -v:minimal"); + return; + } + + Run(dotnet, consumer, $"publish --no-restore -c Release -r {rid} --self-contained true -p:PublishAot=true -p:PublishTrimmed=true -v:minimal"); + var publishRoot = Path.Combine(consumer, "bin", "Release", "net8.0", rid, "publish"); + var executable = Directory.GetFiles(publishRoot, "*", SearchOption.TopDirectoryOnly) + .SingleOrDefault(path => + string.Equals(Path.GetFileName(path), OperatingSystem.IsWindows() ? "Consumer.exe" : "Consumer", StringComparison.Ordinal)); + if (executable is null) + throw new FileNotFoundException($"NativeAOT executable was not found under {publishRoot}. Files: {string.Join(", ", Directory.Exists(publishRoot) ? Directory.GetFiles(publishRoot, "*", SearchOption.AllDirectories) : Array.Empty())}"); + Run(executable, consumer, string.Empty); + } + finally + { + try { Directory.Delete(root, recursive: true); } catch { } + } + } + + private static void Run(string fileName, string workingDirectory, string arguments) + { + using var process = Process.Start(new ProcessStartInfo(fileName, arguments) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }) ?? throw new InvalidOperationException("Could not start dotnet."); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + Task.WaitAll(outputTask, errorTask); + Assert.True(process.ExitCode == 0, $"{fileName} {arguments}\n{outputTask.Result}\n{errorTask.Result}"); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "src", "Magicodes.IE.IO"))) + directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("Magicodes.IE repository root not found."); + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs new file mode 100644 index 00000000..90adfe44 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs @@ -0,0 +1,712 @@ + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Magicodes.IE.IO; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests + { + [Fact] + public async Task SaveAsync_PlainDto_WritesFile() + { + var path = Path.Combine(Path.GetTempPath(), $"xlsx_io_test_{Guid.NewGuid():N}.xlsx"); + try + { + Xlsx.Write(path, new[] { new OrderDto { OrderNo = "A1", Amount = 10m, CreatedAt = new DateTime(2024, 1, 1) } }); + File.Exists(path).ShouldBeTrue(); + var bytes = await File.ReadAllBytesAsync(path); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows.Count.ShouldBe(2); + rows[1][0].ShouldBe("A1"); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public async Task SaveAsBytes_PlainDto_HeaderIsPropertyName() + { + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[0][0].ShouldBe("OrderNo"); + rows[0][1].ShouldBe("Amount"); + rows[0][2].ShouldBe("CreatedAt"); + } + + [Fact] + public async Task WriteAsync_BufferWriter_PlainDto_WritesValidXlsx() + { + var output = new ArrayBufferWriter(); + Xlsx.Write(output, new[] { new OrderDto { OrderNo = "BufferWriter" } }); + var rows = XlsxIO_TestSupport.ReadSheet(output.WrittenMemory.ToArray()); + rows[1][0].ShouldBe("BufferWriter"); + } + + [Fact] + public async Task WriteAsync_Stream_WithOptions_WritesValidXlsx() + { + using var ms = new MemoryStream(); + + Xlsx.Write(ms, new[] { new OrderDto { OrderNo = "W1" } }, p => p + .Sheet("Orders") + .Column(x => x.OrderNo, c => c.WithName("订单号"))); + + var rows = XlsxIO_TestSupport.ReadSheet(ms.ToArray()); + rows[0][0].ShouldBe("订单号"); + rows[1][0].ShouldBe("W1"); + } + + [Fact] + public async Task WriteAsync_ProfileSheetName_WritesGivenSheet() + { + using var ms = new MemoryStream(); + + Xlsx.Write(ms, new[] { new OrderDto { OrderNo = "P1" } }, p => p + .Sheet("ProfileSheet") + .Column(x => x.OrderNo, c => c.WithName("订单号"))); + + var workbookXml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/workbook.xml"); + workbookXml.ShouldContain("ProfileSheet"); + } + + [Fact] + public async Task WriteAsync_BufferWriter_WithOptions_WritesValidXlsx() + { + var output = new ArrayBufferWriter(); + + Xlsx.Write(output, new[] { new OrderDto { OrderNo = "BW1" } }, p => p + .Column(x => x.OrderNo, c => c.WithName("订单号"))); + + var rows = XlsxIO_TestSupport.ReadSheet(output.WrittenMemory.ToArray()); + rows[0][0].ShouldBe("订单号"); + rows[1][0].ShouldBe("BW1"); + } + + [Fact] + public async Task SaveAs_WithProfileInline_AppliesHeaderAndIgnore() + { + var profile = new ExportProfile() + .Column(x => x.OrderNo, c => c.WithName("订单号")) + .Ignore(x => x.Secret); + var bytes = Xlsx.ToBytes(new[] { new OrderWithAttributesDto() }, profile); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[0].ShouldContain("订单号"); + rows[0].ShouldNotContain("Secret"); + } + + [Fact] + public async Task SaveAs_WithProfileClass_AppliesConfig() + { + var p = new ExportProfile() + .Column(x => x.OrderNo, c => c.WithName("订单号")); + var bytes = Xlsx.ToBytes(new[] { new OrderWithAttributesDto() }, p); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[0].ShouldContain("订单号"); + } + + [Fact] + public async Task SaveAs_WithHeader_RenamesColumn() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithName("Order ID")); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[0][0].ShouldBe("Order ID"); + } + + [Fact] + public async Task SaveAs_WithHiddenColumn_KeepsDataAlignedAndWritesHiddenFlag() + { + var p = new ExportProfile() + .Column(x => x.OrderNo, c => c.WithName("订单号")) + .Column(x => x.Amount, c => c.WithHidden()) + .Column(x => x.CreatedAt, c => c.WithName("创建时间")); + + var bytes = Xlsx.ToBytes(new[] + { + new OrderDto { OrderNo = "A1", Amount = 12.34m, CreatedAt = new DateTime(2024, 1, 1) } + }, p); + + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[0].Count.ShouldBe(3); + rows[1].Count.ShouldBe(3); + rows[0][0].ShouldBe("订单号"); + rows[0][1].ShouldBe("Amount"); + rows[0][2].ShouldBe("创建时间"); + rows[1][1].ShouldBe("12.34"); + + var sheetXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + sheetXml.ShouldContain("hidden=\"1\""); + sheetXml.ShouldContain("r=\"B1\""); + sheetXml.ShouldContain("r=\"B2\""); + } + + [Fact] + public void SourceGenerator_RegistersTypedGetters_ForExportableDto() + { + var getters = XlsxGeneratedTypedGettersRegistry.TryGet(typeof(ExportableDto)); + getters.ShouldNotBeNull(); + getters!.Keys.ShouldContain("OrderNo"); + getters.Keys.ShouldContain("Amount"); + + } + + [Fact] + public void SourceGenerator_RegistersReaderMetadata_ForExportableDto() + { + var metadata = XlsxGeneratedTypeMetadataRegistry.TryGet(); + metadata.ShouldNotBeNull(); + metadata!.Count.ShouldBe(2); + metadata.Single(x => x.Name == "OrderNo").Setter.ShouldNotBeNull(); + metadata.Single(x => x.Name == "Amount").Setter.ShouldNotBeNull(); + + var bytes = Xlsx.ToBytes(new[] + { + new ExportableDto { OrderNo = "AOT", Amount = 12.5m } + }); + + using var stream = new MemoryStream(bytes); + var row = Xlsx.Read(stream).Single(); + row.OrderNo.ShouldBe("AOT"); + row.Amount.ShouldBe(12.5m); + } + + [Fact] + public async Task SaveAs_SourceGeneratedRowWriter_RespectsIgnoreAndIndex() + { + var p = new ExportProfile() + .Ignore(x => x.B) + .Column(x => x.C, c => c.WithIndex(0)) + .Column(x => x.A, c => c.WithIndex(1)); + + var bytes = Xlsx.ToBytes(new[] + { + new ExportableReorderedDto { A = "A1", B = "B1", C = "C1" } + }, p); + + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[0].ShouldBe(new List { "C", "A" }); + rows[1].ShouldBe(new List { "C1", "A1" }); + + } + + [Fact] + public void Profile_Freeze_ThrowsOnFurtherMutation() + { + var p = new ExportProfile(); + p.Column(x => x.OrderNo, c => c.WithName("X")); + _ = Xlsx.ToBytes(new[] { new OrderDto() }, p); + Should.Throw(() => p.Column(x => x.OrderNo, c => c.WithName("Y"))); + } + + [Fact] + public async Task SaveAs_EmptyData_WritesHeaderOnly() + { + var bytes = Xlsx.ToBytes(Array.Empty()); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows.Count.ShouldBe(1); + } + + [Fact] + public async Task SaveAs_LargeData_WritesAllRows() + { + var data = Enumerable.Range(0, 1000).Select(i => new OrderDto { OrderNo = $"O{i}" }).ToArray(); + var bytes = Xlsx.ToBytes(data); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows.Count.ShouldBe(1001); + } + + [Fact] + public async Task SaveAs_Comments_InvalidXmlChars_AreSanitized() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.AddComment(new Comment(0, 0, "A\u0001B", "x\u0002y")); + } + + var bytes = ms.ToArray(); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/comments1.xml"); + XlsxIO_TestSupport.AssertWellFormedXml(xml, "comments1.xml"); + xml.ShouldNotContain("\u0001"); + xml.ShouldNotContain("\u0002"); + } + + [Fact] + public async Task SaveAs_EnumValue_WritesNumeric() + { + var bytes = Xlsx.ToBytes(new[] { new OrderWithEnumDto { Status = Status.Paid } }); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[1][1].ShouldBe("1"); + } + + [Fact] + public async Task SaveAs_DateTimeOffset_AndNullableEnum_WritesNumericAndEmpty() + { + var when = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(8)); + var bytes = Xlsx.ToBytes(new[] + { + new OffsetEnumDto { When = when, OptionalStatus = null } + }); + + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[1][0].ShouldBe(when.ToString("O", CultureInfo.InvariantCulture)); + rows[1][1].ShouldBeNull(); + + var roundTrip = Xlsx.Read(new MemoryStream(bytes)).ToList(); + roundTrip.Count.ShouldBe(1); + roundTrip[0].When.ShouldBe(when); + } + + [Fact] + public async Task SaveAsync_NullPath_Throws() + { + Should.Throw(() => Xlsx.Write((string)null!, Array.Empty())); + } + + [Fact] + public async Task SaveAsync_EmptyPath_Throws() + { + Should.Throw(() => Xlsx.Write("", Array.Empty())); + } + + [Fact] + public async Task SaveAs_DisplayAttribute_BecomesHeader() + { + var bytes = Xlsx.ToBytes(new[] { new OrderWithFormatDto() }); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[0].ShouldContain("Amount"); + rows[0].ShouldContain("Date"); + } + + [Fact] + public async Task SaveAs_DescriptionAttribute_RoundsTripHeader() + { + var bytes = Xlsx.ToBytes(new[] { new DescribedDto { Note = "x" } }); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[0].ShouldContain("备注"); + + var readBack = Xlsx.Read(new MemoryStream(bytes)).ToList(); + readBack.Count.ShouldBe(1); + readBack[0].Note.ShouldBe("x"); + } + + [Fact] + public async Task SaveAs_FluentOverridesDisplay() + { + var p = new ExportProfile().Column(x => x.Note, c => c.WithName("备注")); + var bytes = Xlsx.ToBytes(new[] { new OrderWithFormatDto { Note = "x" } }, p); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows[0].ShouldContain("备注"); + } + + [Fact] + public async Task SaveAsBytes_NullableProperties_PreservesNullCells() + { + var bytes = Xlsx.ToBytes(new[] + { + new NullableDto(), + new NullableDto + { + Name = "A", + Qty = 3, + Price = 1.25m, + CreatedAt = new DateTime(2024, 1, 2), + Enabled = true, + }, + }); + + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows.Count.ShouldBe(3); + rows[1].All(string.IsNullOrEmpty).ShouldBeTrue(); + rows[2][0].ShouldBe("A"); + rows[2][1].ShouldBe("3"); + rows[2][2].ShouldBe("1.25"); + rows[2][4].ShouldBe("1"); + } + + [Fact] + public async Task SaveAs_StructType_WritesCorrectly() + { + var bytes = Xlsx.ToBytes(new[] { new TestOrderRecord("S1", 5m) }); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows.Count.ShouldBe(2); + rows[1][0].ShouldBe("S1"); + } + + [Fact] + public async Task SaveAs_RecordStruct_WritesCorrectly() + { + var bytes = Xlsx.ToBytes(new[] { new TestOrderRecord("S1", 5m) }); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows.Count.ShouldBe(2); + rows[1][0].ShouldBe("S1"); + } + + [Fact] + public async Task SaveAs_WithColumnWidth_WritesColsElement() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithWidth(25)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain(""); + xml.ShouldContain("width=\"25\""); + } + + [Fact] + public async Task SaveAs_WithFreezeHeader_WritesSheetView() + { + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain("().WithFreezeHeader(false); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldNotContain("(); + var bytes = Xlsx.WriteWorkbookToBytes( + new Sheet("First", new[] { new OrderDto { OrderNo = "A" } }), + new Sheet("Second", new[] { new OrderDto { OrderNo = "B" } })); + output.Write(bytes); + + var xml = XlsxIO_TestSupport.ReadEntry(output.WrittenMemory.ToArray(), "xl/workbook.xml"); + xml.ShouldContain("First"); + xml.ShouldContain("Second"); + } + + [Fact] + public async Task SaveMultiSheet_GenericEmptySheet_PreservesSheetAndHeader() + { + var bytes = Xlsx.WriteWorkbookToBytes( + new Sheet("EmptyOrders", Array.Empty()), + new Sheet("ActualOrders", new[] { new OrderDto { OrderNo = "A" } })); + + var workbookXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/workbook.xml"); + workbookXml.ShouldContain("EmptyOrders"); + workbookXml.ShouldContain("ActualOrders"); + + var firstSheetXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + firstSheetXml.ShouldContain("OrderNo"); + firstSheetXml.ShouldContain("Amount"); + firstSheetXml.ShouldContain("CreatedAt"); + } + + [Fact] + public async Task SaveMultiSheet_NonGenericEmptySheet_PreservesEmptySheetPart() + { + var bytes = Xlsx.WriteWorkbookToBytes( + new Sheet("EmptyOrders", new System.Collections.ArrayList())); + + var workbookXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/workbook.xml"); + workbookXml.ShouldContain("EmptyOrders"); + + var sheetXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + sheetXml.ShouldContain(""); + } + + [Fact] + public async Task SaveMultiSheet_ZeroSheets_Throws() + { + Should.Throw(() => { Xlsx.WriteWorkbookToBytes(); }); + } + + [Fact] + public async Task SaveMultiSheet_NullSheet_Throws() + { + Should.Throw(() => { Xlsx.ToBytes((IEnumerable)null!); }); + } + + [Fact] + public async Task SaveMultiSheet_ValidatesSheetNames() + { + Should.Throw(() => { Xlsx.WriteWorkbookToBytes(new Sheet("Bad/Name", new[] { new OrderDto() })); }); + } + + [Fact] + public async Task WriteAsync_Stream_WritesValidXlsx() + { + using var ms = new MemoryStream(); + Xlsx.Write(ms, new[] { new OrderDto { OrderNo = "S" } }); + ms.Position = 0; + var items = Xlsx.Read(ms).ToList(); + items.Count.ShouldBe(1); + items[0].OrderNo.ShouldBe("S"); + } + + [Fact] + public async Task WriteAsync_NonSeekableStream_WritesValidXlsx() + { + using var inner = new MemoryStream(); + using (var output = new NonSeekableWriteStream(inner)) + { + Xlsx.Write(output, new[] { new OrderDto { OrderNo = "NS" } }); + } + + inner.Position = 0; + var items = Xlsx.Read(inner).ToList(); + items.Count.ShouldBe(1); + items[0].OrderNo.ShouldBe("NS"); + } + + [Fact] + public async Task WriteAsync_Stream_WithConfigure_AppliesProfile() + { + using var ms = new MemoryStream(); + Xlsx.Write(ms, new[] { new OrderDto() }, p => p + .Column(x => x.OrderNo, c => c.WithName("订单"))); + + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain("订单"); + } + + [Fact] + public async Task WriteAsync_BufferWriter_AsyncEnumerable_WritesValidXlsx() + { + async IAsyncEnumerable Data() + { + yield return new OrderDto { OrderNo = "AsyncBufferWriter" }; + await Task.Yield(); + } + + using var ms = new MemoryStream(); + await Xlsx.WriteAsync(ms, Data()); + var rows = XlsxIO_TestSupport.ReadSheet(ms.ToArray()); + rows[1][0].ShouldBe("AsyncBufferWriter"); + } + + [Fact] + public async Task WriteAsync_AsyncEnumerable_WithOptions_WritesValidXlsx() + { + async IAsyncEnumerable Data() + { + yield return new OrderDto { OrderNo = "AsyncWrite" }; + await Task.Yield(); + } + + using var ms = new MemoryStream(); + await Xlsx.WriteAsync(ms, Data(), p => p.Sheet("AsyncOrders")); + + var rows = XlsxIO_TestSupport.ReadSheet(ms.ToArray()); + rows[1][0].ShouldBe("AsyncWrite"); + } + + [Fact] + public async Task WriteAsync_AsyncEnumerable_AppliesRowFilter() + { + async IAsyncEnumerable Data() + { + yield return new OrderDto { OrderNo = "keep-1" }; + await Task.Yield(); + yield return new OrderDto { OrderNo = "drop" }; + yield return new OrderDto { OrderNo = "keep-2" }; + } + + using var ms = new MemoryStream(); + await Xlsx.WriteAsync(ms, Data(), new ExportProfile() + .Where(x => x.OrderNo != "drop")); + + var rows = XlsxIO_TestSupport.ReadSheet(ms.ToArray()); + rows.Count.ShouldBe(3); + rows[1][0].ShouldBe("keep-1"); + rows[2][0].ShouldBe("keep-2"); + } + + [Fact] + public async Task WriteAsync_Iterator_AppliesRowFilter() + { + IEnumerable Data() + { + yield return new OrderDto { OrderNo = "keep-1" }; + yield return new OrderDto { OrderNo = "drop" }; + yield return new OrderDto { OrderNo = "keep-2" }; + } + + using var ms = new MemoryStream(); + await Xlsx.WriteAsync(ms, Data(), new ExportProfile() + .Where(x => x.OrderNo != "drop")); + + var rows = XlsxIO_TestSupport.ReadSheet(ms.ToArray()); + rows.Count.ShouldBe(3); + rows[1][0].ShouldBe("keep-1"); + rows[2][0].ShouldBe("keep-2"); + } + + + [Fact] + public async Task WriteMultiSheetAsync_Stream_WritesValidWorkbook() + { + using var ms = new MemoryStream(); + var bytes = Xlsx.WriteWorkbookToBytes( + new Sheet("S1", new[] { new OrderDto { OrderNo = "A" } }), + new Sheet("S2", Array.Empty())); + ms.Write(bytes); + var workbookXml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/workbook.xml"); + workbookXml.ShouldContain("S1"); + workbookXml.ShouldContain("S2"); + } + + [Fact] + public async Task WriteMultiSheetAsync_BufferWriter_WithCompression_WritesValidWorkbook() + { + var output = new ArrayBufferWriter(); + var bytes = Xlsx.WriteWorkbookToBytes( + new Sheet("First", new[] { new OrderDto { OrderNo = "A" } }), + new Sheet("Second", new[] { new OrderDto { OrderNo = "B" } })); + output.Write(bytes); + + var xml = XlsxIO_TestSupport.ReadEntry(output.WrittenMemory.ToArray(), "xl/workbook.xml"); + xml.ShouldContain("First"); + xml.ShouldContain("Second"); + } + + [Fact] + public async Task SaveAsBytesAsync_WithSheetName_DoesNotCreateDuplicateSheets() + { + async IAsyncEnumerable Data() + { + yield return new OrderDto { OrderNo = "A" }; + await Task.CompletedTask; + } + + using var ms = new MemoryStream(); + await Xlsx.WriteAsync(ms, Data(), p => p.Sheet("Orders")); + var bytes = ms.ToArray(); + var workbookXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/workbook.xml"); + var sheetTagCount = (workbookXml.Length - workbookXml.Replace("(() => { Xlsx.ToBytes((IEnumerable)null!); }); + } + + [Fact] + public async Task SaveAsBytes_EmptyEnumerable_WritesValidXlsx() + { + var bytes = Xlsx.ToBytes(Array.Empty()); + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows.Count.ShouldBe(1); + } + + private sealed class NonSeekableWriteStream : Stream + { + private readonly Stream _inner; + + public NonSeekableWriteStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => _inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + +#if NETCOREAPP3_1_OR_GREATER + public override void Write(ReadOnlySpan buffer) => _inner.Write(buffer); +#endif + } + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_EdgeCases_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_EdgeCases_Tests.cs new file mode 100644 index 00000000..5bc6c456 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_EdgeCases_Tests.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Magicodes.IE.IO; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests + { + [Fact] + public async Task ExportByTemplateAsync_SeekableTemplateStream_DoesNotDisposeCallerStream() + { + using var templateMs = new MemoryStream(); + using (var zip = new ZipArchive(templateMs, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = entry.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "客户:{{CustomerName}}" + + ""); + } + + templateMs.Position = 0; + using var outputMs = new MemoryStream(); + await Xlsx.ExportByTemplateAsync(templateMs, outputMs, new TemplateHolder { CustomerName = "张三" }); + + templateMs.CanRead.ShouldBeTrue(); + templateMs.Position = 0; + using var zip2 = new ZipArchive(templateMs, ZipArchiveMode.Read, leaveOpen: true); + zip2.Entries.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task ExportByTemplateAsync_NonSeekableTemplateStream_UsesFallbackAndReplacesContent() + { + using var templateMs = new MemoryStream(); + using (var zip = new ZipArchive(templateMs, ZipArchiveMode.Create, leaveOpen: true)) + { + var sheet = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = sheet.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "客户:{{CustomerName}}" + + ""); + } + templateMs.Position = 0; + + using var nonSeekable = new XlsxIO_TestSupport.NonSeekableReadStream(templateMs); + using var outputMs = new MemoryStream(); + await Xlsx.ExportByTemplateAsync(nonSeekable, outputMs, new TemplateHolder { CustomerName = "李四" }); + + var xml = XlsxIO_TestSupport.ReadEntry(outputMs.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain("客户:李四"); + xml.ShouldNotContain("{{CustomerName}}"); + } + + [Fact] + public async Task ExportByTemplateAsync_OutputContainsCriticalXmlParts_AndAllAreWellFormed() + { + using var templateMs = new MemoryStream(); + using (var zip = new ZipArchive(templateMs, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var path in new[] + { + "[Content_Types].xml", + "_rels/.rels", + "xl/workbook.xml", + "xl/_rels/workbook.xml.rels", + "xl/worksheets/sheet1.xml", + "xl/worksheets/_rels/sheet1.xml.rels", + "xl/styles.xml" + }) + { + var entry = zip.CreateEntry(path); + using var es = entry.Open(); + using var sw = new StreamWriter(es); + switch (path) + { + case "[Content_Types].xml": + sw.Write(""); + break; + case "_rels/.rels": + sw.Write(""); + break; + case "xl/workbook.xml": + sw.Write(""); + break; + case "xl/_rels/workbook.xml.rels": + sw.Write(""); + break; + case "xl/worksheets/sheet1.xml": + sw.Write("客户:{{CustomerName}}"); + break; + case "xl/worksheets/_rels/sheet1.xml.rels": + sw.Write(""); + break; + case "xl/styles.xml": + sw.Write(""); + break; + } + } + } + templateMs.Position = 0; + + using var outputMs = new MemoryStream(); + await Xlsx.ExportByTemplateAsync(templateMs, outputMs, new TemplateHolder { CustomerName = "王五" }); + var parts = XlsxIO_TestSupport.ReadCriticalXmlParts(outputMs.ToArray()); + foreach (var kv in parts) + { + XlsxIO_TestSupport.AssertWellFormedXml(kv.Value, kv.Key); + } + parts["xl/worksheets/sheet1.xml"].ShouldContain("王五"); + parts["xl/workbook.xml"].ShouldContain("S1"); + } + + + + + + + + [Fact] + public async Task LowLevelWriter_WriteRowsAsync_CompleteAsync_ProducesEquivalentXlsxToSync() + { + var data = Enumerable.Range(0, 100) + .Select(i => new StringDto { Name = $"Row{i}" }) + .ToArray(); + + (ColumnMeta[] cols, TypedRowPlan plan) BuildPlan() + { + var c = new[] { new ColumnMeta("Name", "Name", null, null, false, 0, 0) }; + var getters = new Func[] { o => CellValue.FromString(o.Name) }; + var plan = new TypedRowPlan( + c, new Func[0], getters, new int[1], + new Action?[1], hasFormulas: false); + return (c, plan); + } + + byte[] Sync() + { + var (c, plan) = BuildPlan(); + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms)) + { + w.AddSheet("S1"); + w.ResolveColumnStyles(c); + w.WriteSheetMeta(c, freezeHeader: true); + w.WriteHeader(c); + w.WriteRows(data, plan); + w.Complete(); + } + return ms.ToArray(); + } + + async Task Async() + { + var (c, plan) = BuildPlan(); + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms)) + { + w.AddSheet("S1"); + w.ResolveColumnStyles(c); + w.WriteSheetMeta(c, freezeHeader: true); + w.WriteHeader(c); + await w.WriteRowsAsync(ToAsyncEnumerable(data), plan).ConfigureAwait(false); + await w.CompleteAsync().ConfigureAwait(false); + await w.DisposeAsync().ConfigureAwait(false); + } + return ms.ToArray(); + } + + var syncBytes = Sync(); + var asyncBytes = await Async(); + + var syncParts = UnzipParts(syncBytes); + var asyncParts = UnzipParts(asyncBytes); + asyncParts.Keys.OrderBy(k => k).SequenceEqual(syncParts.Keys.OrderBy(k => k)).ShouldBeTrue(); + foreach (var key in syncParts.Keys) + { + asyncParts[key].SequenceEqual(syncParts[key]).ShouldBeTrue($"part '{key}' 内容在异步链路下不一致"); + } + + var list = Xlsx.Read(new MemoryStream(asyncBytes)).ToList(); + list.Count.ShouldBe(100); + list[0].Name.ShouldBe("Row0"); + list[99].Name.ShouldBe("Row99"); + } + + [Fact] + public async Task CompleteAsync_FailureIsFaultedAndCannotResume() + { + await using var output = new XlsxIO_TestSupport.ThrowOnAsyncWriteStream(); + await using var writer = new XlsxWriter(output, "Sheet1"); + writer.WriteHeader(XlsxIO_TestSupport.MakeCols()); + var first = await Should.ThrowAsync(() => writer.CompleteAsync()); + var second = await Should.ThrowAsync(() => writer.CompleteAsync()); + second.Message.ShouldBe(first.Message); + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source, [EnumeratorCancellation] CancellationToken ct = default) + { + foreach (var item in source) + { + ct.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return item; + } + } + + private static Dictionary UnzipParts(byte[] zip) + { + var dict = new Dictionary(StringComparer.Ordinal); + using var ms = new MemoryStream(zip); + using var za = new ZipArchive(ms, ZipArchiveMode.Read); + foreach (var e in za.Entries) + { + using var s = e.Open(); + using var outMs = new MemoryStream(); + s.CopyTo(outMs); + dict[e.FullName] = outMs.ToArray(); + } + return dict; + } + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Features_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Features_Tests.cs new file mode 100644 index 00000000..e1633707 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Features_Tests.cs @@ -0,0 +1,800 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using System.Xml; +using Magicodes.IE.IO; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests + { + [Fact] + public async Task MergeCells_WritesMergeCellsElement() + { + var p = new ExportProfile().WithMergeCells("A1:B2"); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain("().WithAutoFilter("A1:C10"); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain(""); + } + + [Fact] + public async Task Hyperlink_WritesHyperlinksElement() + { + var p = new ExportProfile().WithHyperlink("A2", "https://example.com"); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain(""); + xml.ShouldContain("r:id=\"rIdH1\""); + var rels = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/_rels/sheet1.xml.rels"); + rels.ShouldContain("https://example.com"); + } + + [Fact] + public async Task Hyperlink_MultipleLinks_WritesAll() + { + var p = new ExportProfile() + .WithHyperlink("A2", "https://a.com") + .WithHyperlink("B2", "https://b.com"); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var rels = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/_rels/sheet1.xml.rels"); + rels.ShouldContain("https://a.com"); + rels.ShouldContain("https://b.com"); + } + + private static readonly byte[] TinyPng = new byte[] + { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82 + }; + + [Fact] + public async Task AddImage_WritesImageAndDrawing() + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms)) + { + w.AddSheet("S1"); + w.AddImage(TinyPng, "png", "A1", "B2"); + } + using var zip = new ZipArchive(new MemoryStream(ms.ToArray()), ZipArchiveMode.Read); + zip.GetEntry("xl/drawings/drawing1.xml").ShouldNotBeNull(); + zip.GetEntry("xl/media/image1.png").ShouldNotBeNull(); + } + + [Fact] + public async Task AddImage_ExcelParseable_RelsAndNamespaces() + { + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms)) + { + w.AddSheet("S1"); + w.AddImage(TinyPng, "png", "A1", "B2"); + } + using var zip = new ZipArchive(new MemoryStream(ms.ToArray()), ZipArchiveMode.Read); + + var drawRelsEntry = zip.GetEntry("xl/drawings/_rels/drawing1.xml.rels"); + drawRelsEntry.ShouldNotBeNull(); + using (var s = drawRelsEntry!.Open()) + using (var sr = new StreamReader(s)) + { + var drawRels = sr.ReadToEnd(); + drawRels.ShouldContain("Id=\"rId1\""); + drawRels.ShouldContain("Target=\"../media/image1.png\""); + } + + var drawEntry = zip.GetEntry("xl/drawings/drawing1.xml"); + drawEntry.ShouldNotBeNull(); + using (var s = drawEntry!.Open()) + using (var sr = new StreamReader(s)) + { + var draw = sr.ReadToEnd(); + draw.ShouldContain("xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\""); + draw.ShouldContain("r:embed=\"rId1\""); + } + + var sheetRelsEntry = zip.GetEntry("xl/worksheets/_rels/sheet1.xml.rels"); + sheetRelsEntry.ShouldNotBeNull(); + using (var s = sheetRelsEntry!.Open()) + using (var sr = new StreamReader(s)) + { + var sheetRels = sr.ReadToEnd(); + sheetRels.ShouldContain("Id=\"rIdImage1\""); + sheetRels.ShouldContain("Target=\"../drawings/drawing1.xml\""); + } + + var sheetEntry = zip.GetEntry("xl/worksheets/sheet1.xml"); + sheetEntry.ShouldNotBeNull(); + using (var s = sheetEntry!.Open()) + using (var sr = new StreamReader(s)) + { + var sheet = sr.ReadToEnd(); + sheet.ShouldContain(""); + } + } + + [Fact] + public void AddImage_InvalidExtension_Throws() + { + using var ms = new MemoryStream(); + using var writer = new XlsxWriter(ms); + writer.AddSheet("S1"); + Should.Throw(() => writer.AddImage(new byte[] { 1, 2, 3 }, "tiff", "A1", "B2")); + } + + [Fact] + public void AddImage_InvalidAnchor_Throws() + { + using var ms = new MemoryStream(); + using var writer = new XlsxWriter(ms); + writer.AddSheet("S1"); + Should.Throw(() => writer.AddImage(new byte[] { 1, 2, 3 }, "png", "INVALID", "B2")); + } + + [Fact] + public void AddHyperlink_JavascriptScheme_Throws() + { + using var ms = new MemoryStream(); + using var writer = new XlsxWriter(ms); + writer.AddSheet("S1"); + Should.Throw(() => writer.AddHyperlink("A1", "javascript:alert(1)")); + } + + [Fact] + public void AddHyperlink_HttpUrl_Accepted() + { + using var ms = new MemoryStream(); + using var writer = new XlsxWriter(ms); + writer.AddSheet("S1"); + Should.NotThrow(() => writer.AddHyperlink("A1", "https://example.com")); + } + + [Fact] + public void AddComment_MultipleAuthors_SerializesStableAuthorIds() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.AddComment(new Comment(0, 0, "Alice", "first by alice")); + writer.AddComment(new Comment(1, 0, "Bob", "first by bob")); + writer.AddComment(new Comment(2, 0, "Alice", "second by alice")); + writer.AddComment(new Comment(3, 0, "Bob", "second by bob")); + } + + using var za = new ZipArchive(ms, ZipArchiveMode.Read, leaveOpen: false); + var entry = za.GetEntry("xl/comments1.xml"); + entry.ShouldNotBeNull(); + using var es = entry!.Open(); + var doc = new XmlDocument(); + using (var xr = XmlReader.Create(es, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null })) + doc.Load(xr); + + var ns = new XmlNamespaceManager(doc.NameTable); + ns.AddNamespace("x", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"); + + var authors = doc.SelectNodes("/x:comments/x:authors/x:author", ns); + authors!.Count.ShouldBe(2); + authors[0]!.InnerText.ShouldBe("Alice"); + authors[1]!.InnerText.ShouldBe("Bob"); + + var commentList = doc.SelectNodes("/x:comments/x:commentList/x:comment", ns); + commentList!.Count.ShouldBe(4); + commentList[0]!.Attributes!["ref"]!.Value.ShouldBe("A1"); + commentList[1]!.Attributes!["ref"]!.Value.ShouldBe("A2"); + commentList[2]!.Attributes!["ref"]!.Value.ShouldBe("A3"); + commentList[3]!.Attributes!["ref"]!.Value.ShouldBe("A4"); + commentList[0]!.Attributes!["authorId"]!.Value.ShouldBe("0"); + commentList[1]!.Attributes!["authorId"]!.Value.ShouldBe("1"); + commentList[2]!.Attributes!["authorId"]!.Value.ShouldBe("0"); + commentList[3]!.Attributes!["authorId"]!.Value.ShouldBe("1"); + } + + [Fact] + public void AddComment_SingleAuthor_AllCommentsAuthorIdZero() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.AddComment(new Comment(0, 0, "Solo", "a")); + writer.AddComment(new Comment(0, 1, "Solo", "b")); + writer.AddComment(new Comment(0, 2, "Solo", "c")); + } + using var za = new ZipArchive(ms, ZipArchiveMode.Read); + using var es = za.GetEntry("xl/comments1.xml")!.Open(); + var doc = new XmlDocument(); + doc.Load(es); + var ns = new XmlNamespaceManager(doc.NameTable); + ns.AddNamespace("x", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"); + var comments = doc.SelectNodes("/x:comments/x:commentList/x:comment", ns); + for (int i = 0; i < comments!.Count; i++) + comments[i]!.Attributes!["authorId"]!.Value.ShouldBe("0"); + + comments[0]!.Attributes!["ref"]!.Value.ShouldBe("A1"); + comments[1]!.Attributes!["ref"]!.Value.ShouldBe("B1"); + comments[2]!.Attributes!["ref"]!.Value.ShouldBe("C1"); + } + + [Fact] + public void AddComment_WritesWorksheetRelationship() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.AddComment(new Comment(0, 0, "Solo", "comment")); + } + + using var za = new ZipArchive(ms, ZipArchiveMode.Read, leaveOpen: false); + var relsEntry = za.GetEntry("xl/worksheets/_rels/sheet1.xml.rels"); + relsEntry.ShouldNotBeNull(); + using var es = relsEntry!.Open(); + var rels = new StreamReader(es).ReadToEnd(); + rels.ShouldContain("Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments\""); + rels.ShouldContain("Target=\"../comments1.xml\""); + rels.ShouldContain("Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing\""); + rels.ShouldContain("Target=\"../drawings/vmlDrawing1.vml\""); + + var sheetXml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + sheetXml.ShouldContain(""); + + var vmlEntry = za.GetEntry("xl/drawings/vmlDrawing1.vml"); + vmlEntry.ShouldNotBeNull(); + using (var vmlStream = vmlEntry!.Open()) + using (var reader = new StreamReader(vmlStream)) + { + var vml = reader.ReadToEnd(); + vml.ShouldContain("0"); + vml.ShouldContain("0"); + } + } + + [Fact] + public async Task DataValidation_List_WritesDataValidationElement() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.AddDataValidation(new DataValidation("A2:A10", DataValidationType.List, "\"选项1,选项2\"")); + writer.WriteHeader(XlsxIO_TestSupport.MakeCols()); + writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); + } + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain("().Column(x => x.OrderNo, c => c.WithName("Status")); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain("Status"); + } + + [Fact] + public async Task Formula_WritesFormulaElement() + { + var p = new ExportProfile() + .Column(x => x.Amount, c => c.WithFormula("SUM(A1:A10)")); + var bytes = Xlsx.ToBytes(new[] { new OrderWithFormatDto { Amount = 5 } }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain(""); + xml.ShouldContain("SUM(A1:A10)"); + } + + [Fact] + public async Task Formula_ColumnWithRowPlaceholder_WritesRowAwareFormula() + { + var p = new ExportProfile() + .Column(x => x.Amount, c => c.WithFormula("A{row}*2")); + var bytes = Xlsx.ToBytes( + new[] { new OrderWithFormatDto { Amount = 1 }, new OrderWithFormatDto { Amount = 2 } }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain("A2*2"); + xml.ShouldContain("A3*2"); + } + + [Fact] + public async Task Formula_MultipleRowPlaceholders_AllReplaced() + { + var p = new ExportProfile() + .Column(x => x.Amount, c => c.WithFormula("A{row}+B{row}")); + var bytes = Xlsx.ToBytes(new[] { new OrderWithFormatDto { Amount = 1 } }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain("A2+B2"); + } + + [Fact] + public async Task SetDefaultRowHeight_AppearsInSheetFormatPr() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms, defaultRowHeight: 22.5)) + { + writer.AddSheet("S1"); + var cols = XlsxIO_TestSupport.MakeCols(); + writer.WriteSheetMeta(cols, freezeHeader: true); + writer.WriteHeader(cols); + writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); + } + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain("defaultRowHeight=\"16.875\""); + } + + [Fact] + public async Task SetNextRowHeight_EmitsHtAttrOnNextRow() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.WriteHeader(XlsxIO_TestSupport.MakeCols()); + writer.SetNextRowHeight(33.3); + writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); + } + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain("ht=\"24.974999999999998\""); + xml.ShouldContain("customHeight=\"1\""); + } + + [Fact] + public async Task EnableSharedStrings_RepeatedStrings_AreDeduplicated() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.EnableSharedStrings(); + var cols = new[] { new ColumnMeta("Name", "Name", null, null, false, 0, 0) }; + writer.ResolveColumnStyles(cols); + writer.WriteSheetMeta(cols, freezeHeader: true); + writer.WriteHeader(cols); + var getters = new Func[] { o => CellValue.FromString(o.Name) }; + var plan = new TypedRowPlan(cols, new Func[0], getters, new int[1], new Action?[1], hasFormulas: false); + writer.WriteRows(Enumerable.Range(0, 100).Select(_ => new StringDto { Name = "重复" }), plan); + } + using var za = new ZipArchive(ms, ZipArchiveMode.Read); + var sstEntry = za.GetEntry("xl/sharedStrings.xml"); + sstEntry.ShouldNotBeNull(); + string sstXml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/sharedStrings.xml"); + int sstCount = (sstXml.Length - sstXml.Replace("", string.Empty).Length) / 4; + sstCount.ShouldBe(1); + } + + [Fact] + public async Task EnableSharedStrings_SstCountEqualsTotalCellRefs() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.EnableSharedStrings(); + var cols = new[] { new ColumnMeta("Name", "Name", null, null, false, 0, 0) }; + writer.ResolveColumnStyles(cols); + writer.WriteSheetMeta(cols, freezeHeader: true); + writer.WriteHeader(cols); + var getters = new Func[] { o => CellValue.FromString(o.Name) }; + var plan = new TypedRowPlan(cols, new Func[0], getters, new int[1], new Action?[1], hasFormulas: false); + writer.WriteRows(Enumerable.Range(0, 100).Select(i => new StringDto { Name = i % 2 == 0 ? "a" : "b" }), plan); + } + var sstXml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/sharedStrings.xml"); + sstXml.ShouldContain("count=\"100\""); + sstXml.ShouldContain("uniqueCount=\"2\""); + } + + [Fact] + public async Task SetOutline_AppearsInSheetFormatPr() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.SetOutline(new OutlineSettings { SummaryBelow = false, SummaryRight = false }); + var cols = XlsxIO_TestSupport.MakeCols(); + writer.WriteSheetMeta(cols, freezeHeader: true); + writer.WriteHeader(cols); + writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); + } + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain("summaryBelow=\"0\""); + xml.ShouldContain("summaryRight=\"0\""); + xml.IndexOf(""); + xml.ShouldContain("name=\"MyRange\""); + xml.ShouldContain("S1!$A$1:$A$10"); + } + + [Fact] + public async Task AddConditionalFormatting_AppearsInSheetXml() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.AddConditionalFormatting(new ConditionalFormatting("A1:A100", + new CfRule(CfOperator.GreaterThan, "100"))); + writer.WriteHeader(XlsxIO_TestSupport.MakeCols()); + writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); + } + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain(""); + xml.ShouldContain("ref=\"A1:B2\""); + } + + [Fact] + public async Task SetAutoFilter_AppearsInSheetXml() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.WriteHeader(XlsxIO_TestSupport.MakeCols()); + writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); + writer.SetAutoFilter("A1:A10"); + } + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain(""); + } + + [Fact] + public async Task AutoSst_RepeatedStrings_EnablesSharedStrings() + { + var data = Enumerable.Range(0, 100).Select(i => new StringDto { Name = "A" }).ToList(); + var p = new ExportProfile().WithAutoSst(); + var bytes = Xlsx.ToBytes(data, p); + using var za = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + za.GetEntry("xl/sharedStrings.xml").ShouldNotBeNull(); + } + + [Fact] + public async Task AutoSst_UniqueStrings_DoesNotEnableSharedStrings() + { + var data = Enumerable.Range(0, 100).Select(i => new StringDto { Name = $"unique-{i}" }).ToList(); + var p = new ExportProfile().WithAutoSst(); + var bytes = Xlsx.ToBytes(data, p); + using var za = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + za.GetEntry("xl/sharedStrings.xml").ShouldBeNull(); + } + + [Fact] + public void AutoSst_RowFilterStillDetectsSharedStrings() + { + var data = Enumerable.Range(0, 100).Select(_ => new StringDto { Name = "A" }); + var p = new ExportProfile().Where(_ => true).WithAutoSst(); + var bytes = Xlsx.ToBytes(data, p); + + using var za = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + za.GetEntry("xl/sharedStrings.xml").ShouldNotBeNull(); + } + + [Fact] + public async Task AutoSst_AsyncEnumerableUsesDetectedMode() + { + async IAsyncEnumerable Data() + { + for (int i = 0; i < 100; i++) + yield return new StringDto { Name = "A" }; + } + + using var ms = new MemoryStream(); + await Xlsx.WriteAsync(ms, Data(), new ExportProfile().WithAutoSst()); + + using var za = new ZipArchive(new MemoryStream(ms.ToArray()), ZipArchiveMode.Read); + za.GetEntry("xl/sharedStrings.xml").ShouldNotBeNull(); + } + + [Fact] + public async Task AutoSst_NoCompression_StaysInlineString() + { + var data = Enumerable.Range(0, 100).Select(i => new StringDto { Name = "A" }).ToList(); + var p = new ExportProfile().WithAutoSst(); + var bytes = Xlsx.ToBytes(data, p, new XlsxWriteOptions { Compression = CompressionLevel.NoCompression }); + using var za = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + za.GetEntry("xl/sharedStrings.xml").ShouldBeNull(); + } + + [Fact] + public async Task AutoSst_SharedStrings_PreservesWhitespaceAndEscapesXml() + { + var data = Enumerable.Range(0, 100).Select(_ => new StringDto { Name = " &\"' " }).ToList(); + var bytes = Xlsx.ToBytes(data, new ExportProfile().WithAutoSst()); + var sstXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/sharedStrings.xml"); + + sstXml.ShouldContain("xml:space=\"preserve\""); + sstXml.ShouldContain("<a>&"'"); + } + + [Fact] + public async Task WriteInlineStringCell_AsciiOnly_ProducesSpecCompliantBytes() + { + var ascii = Enumerable.Range(0, 100).Select(i => new StringDto { Name = $"name-{i}" }).ToList(); + var bytes = Xlsx.ToBytes(ascii, new ExportProfile()); + bytes.ShouldNotBeEmpty(); + string sheetXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + sheetXml.ShouldContain("r=\"A1\" t=\"inlineStr\">Name"); + sheetXml.ShouldContain("r=\"A2\" t=\"inlineStr\">name-0"); + sheetXml.ShouldContain("r=\"A101\" t=\"inlineStr\">name-99"); + sheetXml.ShouldNotContain("name-0<"); + sheetXml.ShouldNotContain("name-0&"); + } + + [Fact] + public async Task WriteInlineStringCell_AsciiWhitespace_PreservesXmlSpace() + { + var bytes = Xlsx.ToBytes(new[] { new StringDto { Name = " leading trailing " } }, new ExportProfile()); + string sheetXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + + sheetXml.ShouldContain(" leading trailing "); + } + + [Fact] + public async Task WriteInlineStringCell_NonAscii_ProducesSpecCompliantBytes() + { + var data = Enumerable.Range(0, 100).Select(i => new StringDto { Name = $"中文-{i}" }).ToList(); + var bytes = Xlsx.ToBytes(data, new ExportProfile()); + string sheetXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + sheetXml.ShouldContain("中文-0"); + sheetXml.ShouldContain("中文-99"); + } + + [Fact] + public async Task WriteInlineStringCell_XmlSpecialChars_AreEscaped() + { + var data = new[] { + new StringDto { Name = "a&bd\"e'f" }, + new StringDto { Name = "plain-ascii-with-no-special" }, + }; + var bytes = Xlsx.ToBytes(data, new ExportProfile()); + string sheetXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + sheetXml.ShouldContain("a&b<c>d"e'f"); + sheetXml.ShouldContain("plain-ascii-with-no-special"); + sheetXml.ShouldNotContain("plain-ascii-with-no-special&"); + sheetXml.ShouldNotContain("plain-ascii-with-no-special<"); + } + + [Fact] + public async Task WriteInlineStringCell_LargeAscii_TakesFastPath() + { + var data = Enumerable.Range(0, 100).Select(i => new StringDto + { + Name = new string('a', 30) + i.ToString("D3") + }).ToList(); + var bytes = Xlsx.ToBytes(data, new ExportProfile()); + string sheetXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + sheetXml.ShouldContain("r=\"A2\" t=\"inlineStr\">aaaa"); + sheetXml.ShouldContain("r=\"A101\" t=\"inlineStr\">aaa"); + sheetXml.ShouldContain("099"); + } + + + [Fact] + public async Task SingleSheet_SheetProtection_WrittenToFinalXml() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("Sheet1"); + writer.SetSheetProtection(new SheetProtection + { + FormatCells = false, + Sort = false, + AutoFilter = false, + PasswordHash = "ABC123", + }); + writer.WriteHeader(XlsxIO_TestSupport.MakeCols()); + writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); + } + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain(""); + xml.ShouldContain("&LHeader <1>"); + xml.ShouldContain("&RPage &P of &N"); + } + + [Fact] + public async Task SingleSheet_FitToPage_EmitsPageSetUpPr() + { + // fitToWidth/fitToHeight on are ignored by Excel unless + // is also emitted. + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("Sheet1"); + writer.SetPageSetup(new PageSetup { FitToWidth = 1, FitToHeight = 0 }); + var cols = XlsxIO_TestSupport.MakeCols(); + writer.WriteSheetMeta(cols, freezeHeader: false); + writer.WriteHeader(cols); + writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); + } + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain(""); + xml.ShouldContain(""); + xml.ShouldContain("fitToWidth=\"1\""); + } + + [Fact] + public async Task SingleSheet_Table_WrittenToFinalXml() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("Sheet1"); + writer.WriteHeader(XlsxIO_TestSupport.MakeCols()); + writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); + writer.AddTable(new TableDefinition("T1", "A1:A2", showTotalsRow: true).WithColumn("A")); + } + using var zip = new ZipArchive(ms, ZipArchiveMode.Read); + zip.GetEntry("xl/tables/table1_1.xml").ShouldNotBeNull(); + var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/tables/table1_1.xml"); + xml.ShouldContain("totalsRowShown=\"1\""); + xml.ShouldNotContain("(() => writer.AddTable(new TableDefinition("T1", "A1:&bad"))); + } + + [Fact] + public void AddTable_ValidatesColumnCountAndNames() + { + using var ms = new MemoryStream(); + using var writer = new XlsxWriter(ms); + writer.AddSheet("Sheet1"); + Should.Throw(() => writer.AddTable(new TableDefinition("T1", "A1:B2").WithColumn("A"))); + Should.Throw(() => writer.AddTable(new TableDefinition("T2", "A1:B2").WithColumn("A").WithColumn("A"))); + } + + [Fact] + public void Complete_ValidatesTableNamesAcrossSheets() + { + using var ms = new MemoryStream(); + using var writer = new XlsxWriter(ms); + writer.AddSheet("First"); + writer.AddTable(new TableDefinition("T1", "A1:A2").WithColumn("A")); + writer.AddSheet("Second"); + writer.AddTable(new TableDefinition("T1", "A1:A2").WithColumn("A")); + Should.Throw(() => writer.Complete()); + } + + [Fact] + public async Task Write_RelaxedCellReferences_OmitsCellRefsAndRemainsReadable() + { + var ascii = Enumerable.Range(0, 100).Select(i => new StringDto { Name = $"name-{i}" }).ToList(); + var bytes = Xlsx.ToBytes(ascii, options: new XlsxWriteOptions { StrictCellReferences = false }); + bytes.ShouldNotBeEmpty(); + + string sheetXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + sheetXml.ShouldNotContain(" r=\"A1\""); + sheetXml.ShouldContain("Name"); + + var roundtrip = Xlsx.Read(new MemoryStream(bytes)).ToList(); + roundtrip.Count.ShouldBe(100); + roundtrip[0].Name.ShouldBe("name-0"); + roundtrip[^1].Name.ShouldBe("name-99"); + } + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs new file mode 100644 index 00000000..dd403285 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs @@ -0,0 +1,718 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.IO.Compression; +using Magicodes.IE.IO; +using MiniExcelLibs; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests + { + [Fact] + public async Task XlsxRead_ReadsInlineStringsAndNumbers() + { + var bytes = Xlsx.ToBytes(new[] + { + new OrderDto { OrderNo = "A", Amount = 10m, CreatedAt = new DateTime(2024, 1, 1) }, + new OrderDto { OrderNo = "B", Amount = 20m, CreatedAt = new DateTime(2024, 1, 2) }, + }); + var list = Xlsx.Read(new MemoryStream(bytes)).ToList(); + list.Count.ShouldBe(2); + list[0].OrderNo.ShouldBe("A"); + list[0].Amount.ShouldBe(10m); + list[1].OrderNo.ShouldBe("B"); + } + + [Fact] + public async Task XlsxRead_RoundTrip_PreservesData() + { + var src = new[] + { + new OrderDto { OrderNo = "X", Amount = 1.5m, CreatedAt = new DateTime(2024, 5, 1) }, + new OrderDto { OrderNo = "Y", Amount = 2.5m, CreatedAt = new DateTime(2024, 5, 2) }, + }; + var bytes = Xlsx.ToBytes(src); + var dst = Xlsx.Read(new MemoryStream(bytes)).ToList(); + dst.Count.ShouldBe(src.Length); + for (int i = 0; i < src.Length; i++) + { + dst[i].OrderNo.ShouldBe(src[i].OrderNo); + dst[i].Amount.ShouldBe(src[i].Amount); + dst[i].CreatedAt.ShouldBe(src[i].CreatedAt); + } + } + + [Fact] + public async Task XlsxRead_AsyncStreaming() + { + var bytes = Xlsx.ToBytes(Enumerable.Range(0, 20).Select(i => new OrderDto { OrderNo = $"R{i}" })); + var list = new List(); + await foreach (var o in Xlsx.ReadAsync(new MemoryStream(bytes))) + list.Add(o); + list.Count.ShouldBe(20); + } + + [Fact] + public void XlsxReader_EmptyRowDoesNotConsumeFollowingRow() + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var sheet = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = sheet.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "OrderNo" + + "after" + + ""); + } + + ms.Position = 0; + using var reader = new XlsxReader(ms); + reader.ReadHeader().ShouldBe(new[] { "OrderNo" }); + reader.ReadNextRowView().ShouldBeEmpty(); + reader.ReadNextRowView()![0].ShouldBe("after"); + } + + [Fact] + public void XlsxReader_OutOfOrderCellRefs_PlacedByColumnIndex() + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var sheet = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = sheet.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "H1H2H3" + + "acb" + + ""); + } + + ms.Position = 0; + using var reader = new XlsxReader(ms); + reader.ReadHeader().ShouldBe(new[] { "H1", "H2", "H3" }); + // Cells emitted out of order (A, C, B) must still land at their column index, + // not be appended in document order. + reader.ReadNextRowView()!.ShouldBe(new[] { "a", "b", "c" }); + } + + [Fact] + public void XlsxReader_InlineStringRichText_ConcatenatesRuns() + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var sheet = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = sheet.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "H" + + "foobar" + + ""); + } + + ms.Position = 0; + using var reader = new XlsxReader(ms); + reader.ReadHeader().ShouldBe(new[] { "H" }); + // Multiple runs (rich text) must be concatenated, exercising the StringBuilder path. + reader.ReadNextRowView()![0].ShouldBe("foobar"); + } + + [Fact] + public void XlsxRead_ULongAboveLongMaxValue_DoesNotWrapNegative() + { + // ulong > long.MaxValue used to be cast to (long) in the generated getter and + // silently wrap to a negative value; it must now survive round-trip as positive. + // 1 << 63 is exactly representable as double, so it round-trips within Excel's + // IEEE754-double precision. + var src = new[] + { + new ULongDto { Name = "big", Value = 1UL << 63 }, + }; + var bytes = Xlsx.ToBytes(src); + var dst = Xlsx.Read(new MemoryStream(bytes)).Single(); + dst.Name.ShouldBe("big"); + dst.Value.ShouldBe(1UL << 63); + dst.Value.ShouldBeGreaterThan((ulong)long.MaxValue); + } + + [Fact] + public void XlsxWrite_ULongBackedEnumAboveLongMax_DoesNotOverflow() + { + // A ulong-backed enum with a value > long.MaxValue used to be cast to (long) and + // silently wrap to negative (cell-writer path) or throw OverflowException + // (typed-getter / ToCellValue paths). It must now write a positive number. + var bytes = Xlsx.ToBytes(new[] { new ULongEnumDto { Name = "x", Flag = BigFlag.Huge } }); + // Read the same cell back as double (Enum.TryParse rejects the scientific-notation + // text, so read as the underlying numeric to verify the written value). + var asDouble = Xlsx.Read(new MemoryStream(bytes)).Single(); + asDouble.Name.ShouldBe("x"); + // Before the fix this was -9.223372036854776E+18 (negative wrap). The written cell + // must be the positive 2^63 (= (double)(1UL << 63)). + asDouble.Flag.ShouldBe((double)(1UL << 63), $"cell was {asDouble.Flag}"); + asDouble.Flag.ShouldBeGreaterThan(0); + } + + [Fact] + public void XlsxWrite_ColumnsWithoutIndex_PreserveDeclarationOrder() + { + // List.Sort is not stable; columns sharing the default Index (int.MaxValue) must + // still come out in declaration order via the tiebreaker, deterministically. + var bytes = Xlsx.ToBytes(new[] { new DeclOrderDto { Alpha = "a", Bravo = "b", Charlie = "c" } }); + using var ms = new MemoryStream(bytes); + using var reader = new XlsxReader(ms); + reader.ReadHeader().ShouldBe(new[] { "Alpha", "Bravo", "Charlie" }); + } + + [Fact] + public void XlsxRead_StructPropertiesAreAssigned() + { + var bytes = Xlsx.ToBytes(new[] { new TestOrderRecord("SO-1", 12.5m) }); + var result = Xlsx.Read(new MemoryStream(bytes)).Single(); + + result.OrderNo.ShouldBe("SO-1"); + result.Amount.ShouldBe(12.5m); + } + + [Fact] + public void CellValueParser_Parses1904DatesAndDecimalExponents() + { + CellValueParser.TryParse("0", typeof(DateTime), out var date, date1904: true).ShouldBeTrue(); + ((DateTime)date!).ShouldBe(new DateTime(1904, 1, 1)); + + CellValueParser.TryParse("1E+2", typeof(decimal), out var number).ShouldBeTrue(); + ((decimal)number!).ShouldBe(100m); + } + + [Fact] + public async Task ReadAsync_CancellationRequested_ThrowsOperationCanceled() + { + var bytes = Xlsx.ToBytes(Enumerable.Range(0, 100).Select(i => new OrderDto { OrderNo = $"R{i}" })); + using var ms = new MemoryStream(bytes); + var cts = new CancellationTokenSource(); + cts.Cancel(); + var list = new List(); + await Should.ThrowAsync(async () => + { + await foreach (var o in Xlsx.ReadAsync(ms, cancellationToken: cts.Token)) + list.Add(o); + }); + list.Count.ShouldBe(0); + } + + [Fact] + public async Task ReadAsync_CancellationRequested_BeforeStart_DoesNotTouchStream() + { + var bytes = Xlsx.ToBytes(Enumerable.Range(0, 5).Select(i => new OrderDto { OrderNo = $"R{i}" })); + using var tracking = new XlsxIO_TestSupport.TrackingReadStream(new MemoryStream(bytes)); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Should.ThrowAsync(async () => + { + await foreach (var _ in Xlsx.ReadAsync(tracking, cancellationToken: cts.Token)) + { + } + }); + + tracking.ReadCount.ShouldBe(0); + tracking.ReadAsyncCount.ShouldBe(0); + tracking.SeekCount.ShouldBe(0); + tracking.PositionGetCount.ShouldBe(0); + tracking.PositionSetCount.ShouldBe(0); + tracking.LengthGetCount.ShouldBe(0); + } + + [Fact] + public async Task XlsxRead_RoundTrip_DirectSurface() + { + var bytes = Xlsx.ToBytes(new[] { new OrderDto { OrderNo = "Z" } }); + var list = Xlsx.Read(new MemoryStream(bytes)).ToList(); + list.Count.ShouldBe(1); + list[0].OrderNo.ShouldBe("Z"); + } + + [Fact] + public void ImportProfile_ExplicitColumnMapping_WinsOverHeaderName() + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var sheet = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = sheet.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "AmountOther" + + "X12" + + ""); + } + + ms.Position = 0; + var profile = new XlsxReadOptions() + .MapColumn(0, nameof(OrderDto.OrderNo)) + .MapColumn(1, nameof(OrderDto.Amount)); + + var list = Xlsx.Read(ms, profile).ToList(); + list.Count.ShouldBe(1); + list[0].OrderNo.ShouldBe("X"); + list[0].Amount.ShouldBe(12m); + } + + [Fact] + public void ImportProfile_HeaderMapping_MapsByHeaderText() + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var sheet = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = sheet.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "订单号金额" + + "R112" + + ""); + } + + ms.Position = 0; + var profile = new XlsxReadOptions() + .MapHeader("订单号", nameof(OrderDto.OrderNo)) + .MapHeader("金额", nameof(OrderDto.Amount)); + + var list = Xlsx.Read(ms, profile).ToList(); + list.Count.ShouldBe(1); + list[0].OrderNo.ShouldBe("R1"); + list[0].Amount.ShouldBe(12m); + } + + [Fact] + public void XlsxReader_UsesWorkbookRelationshipOrder_NotHardcodedSheet1Xml() + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var workbook = zip.CreateEntry("xl/workbook.xml"); + using (var es = workbook.Open()) + using (var sw = new StreamWriter(es)) + { + sw.Write("" + + "" + + "" + + "" + + "" + + ""); + } + + var workbookRels = zip.CreateEntry("xl/_rels/workbook.xml.rels"); + using (var es = workbookRels.Open()) + using (var sw = new StreamWriter(es)) + { + sw.Write("" + + "" + + "" + + "" + + ""); + } + + var sheet1 = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using (var es = sheet1.Open()) + using (var sw = new StreamWriter(es)) + { + sw.Write("" + + "" + + "OrderNo" + + "WRONG" + + ""); + } + + var sheet2 = zip.CreateEntry("xl/worksheets/sheet2.xml"); + using (var es = sheet2.Open()) + using (var sw = new StreamWriter(es)) + { + sw.Write("" + + "" + + "OrderNo" + + "RIGHT" + + ""); + } + } + + ms.Position = 0; + var list = Xlsx.Read(ms).ToList(); + list.Count.ShouldBe(1); + list[0].OrderNo.ShouldBe("RIGHT"); + } + + [Fact] + public async Task Read_XlsxWrittenByMiniExcel_RoundTrips() + { + var path = Path.Combine(Path.GetTempPath(), $"miniexcel_{Guid.NewGuid():N}.xlsx"); + try + { + MiniExcel.SaveAs(path, new[] + { + new { OrderNo = "M1", Amount = 10 }, + new { OrderNo = "M2", Amount = 20 }, + }); + var items = Xlsx.Read(File.OpenRead(path)).ToList(); + items.Count.ShouldBe(2); + items[0].OrderNo.ShouldBe("M1"); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public async Task Read_SharedStrings_RealXlsxFormat() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.EnableSharedStrings(); + var cols = new[] { new ColumnMeta("Name", "Name", null, null, false, 0, 0) }; + writer.ResolveColumnStyles(cols); + writer.WriteSheetMeta(cols, freezeHeader: true); + writer.WriteHeader(cols); + var getters = new Func[] { o => CellValue.FromString(o.Name) }; + var plan = new TypedRowPlan(cols, new Func[0], getters, new int[1], new Action?[1], hasFormulas: false); + writer.WriteRows(new[] + { + new StringDto { Name = "x" }, new StringDto { Name = "x" }, new StringDto { Name = "y" } + }, plan); + } + var list = Xlsx.Read(new MemoryStream(ms.ToArray())).ToList(); + list.Count.ShouldBe(3); + list[0].Name.ShouldBe("x"); + list[2].Name.ShouldBe("y"); + } + + [Fact] + public async Task RoundTrip_SelfWrittenXlsx_CanBeParsed() + { + var bytes = Xlsx.ToBytes(Enumerable.Range(0, 50).Select(i => new OrderDto { OrderNo = $"O{i}" })); + using var reader = new XlsxReader(new MemoryStream(bytes)); + var headers = reader.ReadHeader(); + headers.ShouldContain("OrderNo"); + int rowCount = 0; + while (reader.ReadNextRow() is not null) rowCount++; + rowCount.ShouldBe(50); + } + + [Fact] + public async Task ReadSharedStrings_Handled() + { + using var ms = new MemoryStream(); + using (var writer = new XlsxWriter(ms)) + { + writer.AddSheet("S1"); + writer.EnableSharedStrings(); + var cols = new[] { new ColumnMeta("Name", "Name", null, null, false, 0, 0) }; + writer.ResolveColumnStyles(cols); + writer.WriteSheetMeta(cols, freezeHeader: true); + writer.WriteHeader(cols); + var getters = new Func[] { o => CellValue.FromString(o.Name) }; + var plan = new TypedRowPlan(cols, new Func[0], getters, new int[1], new Action?[1], hasFormulas: false); + writer.WriteRows(Enumerable.Range(0, 1000).Select(_ => new StringDto { Name = "same" }), plan); + } + var list = Xlsx.Read(new MemoryStream(ms.ToArray())).ToList(); + list.Count.ShouldBe(1000); + list[500].Name.ShouldBe("same"); + } + + [Fact] + public async Task Read_InlineStringRichText_AggregatesAllRuns() + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var sheet = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = sheet.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "Name" + + "Hello" + + ""); + } + + ms.Position = 0; + var list = Xlsx.Read(ms).ToList(); + list.Count.ShouldBe(1); + list[0].Name.ShouldBe("Hello"); + } + + [Fact] + public async Task Read_DateCell_TypedDateValue_RoundTrips() + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var sheet = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = sheet.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "CreatedAt" + + "2024-01-01T00:00:00Z" + + ""); + } + + ms.Position = 0; + var list = Xlsx.Read(ms).ToList(); + list.Count.ShouldBe(1); + list[0].CreatedAt.ShouldBe(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public async Task Query_OnParseError_InvokedOnInvalidNumber() + { + using var ms = new MemoryStream(); + using (var zip = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = entry.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "OrderNoAmount" + + "ONnot-a-number" + + ""); + } + ms.Position = 0; + var errors = new List(); + var list = Xlsx.Read(ms, onParseError: e => errors.Add(e)).ToList(); + list.Count.ShouldBe(1); + errors.Count.ShouldBe(1); + errors[0].PropertyName.ShouldBe("Amount"); + errors[0].RawCellValue.ShouldBe("not-a-number"); + errors[0].TargetTypeName.ShouldBe("Decimal"); + errors[0].RowIndex.ShouldBe(0); + errors[0].ColIndex.ShouldBe(1); + errors[0].Exception.ShouldNotBeNull(); + } + + [Fact] + public async Task Query_OnParseError_InvokedOnInvalidBool() + { + using var ms = new MemoryStream(); + using (var zip = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = entry.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "NameEnabled" + + "Amaybe" + + ""); + } + ms.Position = 0; + var errors = new List(); + var list = Xlsx.Read(ms, onParseError: e => errors.Add(e)).ToList(); + list.Count.ShouldBe(1); + errors.Count.ShouldBe(1); + errors[0].PropertyName.ShouldBe("Enabled"); + errors[0].RawCellValue.ShouldBe("maybe"); + errors[0].TargetTypeName.ShouldBe("Boolean"); + } + + [Fact] + public void Query_OnParseError_InvokedOnInvalidTypedBooleanCell() + { + using var ms = new MemoryStream(); + using (var zip = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = entry.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "NameEnabled" + + "A2" + + ""); + } + ms.Position = 0; + var errors = new List(); + var list = Xlsx.Read(ms, onParseError: e => errors.Add(e)).ToList(); + list.Count.ShouldBe(1); + list[0].Enabled.ShouldBeFalse(); + errors.Count.ShouldBe(1); + errors[0].PropertyName.ShouldBe("Enabled"); + errors[0].RawCellValue.ShouldBe("2"); + errors[0].TargetTypeName.ShouldBe("Boolean"); + } + + [Fact] + public void ReadNextRow_HandlesSparseColumns() + { + using var ms = new MemoryStream(); + using (var zip = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = entry.Open(); + using var sw = new StreamWriter(es); + sw.Write("" + + "" + + "" + + "ABC" + + "ONVAL" + + ""); + } + ms.Position = 0; + using var reader = new XlsxReader(ms); + var headers = reader.ReadHeader(); + headers.ShouldBe(new[] { "A", "B", "C" }); + var row = reader.ReadNextRow(); + row.ShouldNotBeNull(); + row.Count.ShouldBe(3); + row[0].ShouldBe("ON"); + row[1].ShouldBeNull(); + row[2].ShouldBe("VAL"); + } + + + + private readonly struct Money + { + public readonly decimal Value; + public Money(decimal v) { Value = v; } + public override string ToString() => Value.ToString("F2"); + } + + + private sealed class MoneyConverter : CellConverter + { + public override bool Read(string cell, out Money value) + { + if (decimal.TryParse(cell, System.Globalization.NumberStyles.Number, + System.Globalization.CultureInfo.InvariantCulture, out var d)) + { + value = new Money(d); + return true; + } + value = default; + return false; + } + } + + private sealed class MoneyDto + { + public string? Label { get; set; } + public Money Amount { get; set; } + } + + [Fact] + public void CellConverter_Read_CustomType_ParsesCorrectly() + { + var bytes = Xlsx.ToBytes(new[] + { + new MoneyDto { Label = "A", Amount = new Money(10.5m) }, + new MoneyDto { Label = "B", Amount = new Money(20.0m) }, + }); + + var opts = new XlsxReadOptions(); + opts.WithConverter(new MoneyConverter()); + + var list = Xlsx.Read(new MemoryStream(bytes), opts).ToList(); + list.Count.ShouldBe(2); + list[0].Label.ShouldBe("A"); + list[0].Amount.Value.ShouldBe(10.5m); + list[1].Label.ShouldBe("B"); + list[1].Amount.Value.ShouldBe(20.0m); + } + + [Fact] + public void ExportProfile_Freeze_IsPublic_And_ThrowsOnMutation() + { + var profile = new ExportProfile(); + profile.Sheet("test"); + profile.IsFrozen.ShouldBeFalse(); + profile.Freeze(); + profile.IsFrozen.ShouldBeTrue(); + Should.Throw(() => profile.Sheet("other")); + } + + [Fact] + public void XlsxException_ContainsStructuredLocation() + { + var ex = new XlsxException("bad cell", rowIndex: 5, colIndex: 2, cellRef: "C6"); + ex.RowIndex.ShouldBe(5); + ex.ColIndex.ShouldBe(2); + ex.CellRef.ShouldBe("C6"); + ex.Message.ShouldContain("Row=5"); + ex.Message.ShouldContain("Col=2"); + ex.Message.ShouldContain("CellRef=C6"); + } + + [Fact] + public async Task WriteAsync_WithStrictCellReferences_PassesThrough() + { + var data = new[] + { + new OrderDto { OrderNo = "X", Amount = 1m, CreatedAt = new DateTime(2024, 1, 1) }, + }; + + using var ms = new MemoryStream(); + await Xlsx.WriteAsync(ms, DataToAsync(data), + options: new XlsxWriteOptions { StrictCellReferences = false, Compression = CompressionLevel.NoCompression }); + ms.Length.ShouldBeGreaterThan(0); + + ms.Position = 0; + using var zip = new System.IO.Compression.ZipArchive(ms, ZipArchiveMode.Read); + var sheetEntry = zip.GetEntry("xl/worksheets/sheet1.xml"); + sheetEntry.ShouldNotBeNull(); + using var es = sheetEntry!.Open(); + var content = new StreamReader(es).ReadToEnd(); + content.ShouldContain("t=\"n\""); + content.ShouldNotContain("r=\"A2\""); + } + + [Fact] + public async Task WriteAsync_DelayedAsyncEnumerable_WritesAllRowsBeforeDisposingWriter() + { + static async IAsyncEnumerable DelayedData() + { + await Task.Delay(10); + yield return new OrderDto { OrderNo = "A" }; + await Task.Delay(10); + yield return new OrderDto { OrderNo = "B" }; + } + + using var ms = new MemoryStream(); + await Xlsx.WriteAsync(ms, DelayedData()); + + var rows = XlsxIO_TestSupport.ReadSheet(ms.ToArray()); + rows.Count.ShouldBe(3); + rows[1][0].ShouldBe("A"); + rows[2][0].ShouldBe("B"); + } + + private static async IAsyncEnumerable DataToAsync(IEnumerable data, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + foreach (var item in data) + { + yield return item; + await Task.Yield(); + } + } + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_ReleaseContract_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_ReleaseContract_Tests.cs new file mode 100644 index 00000000..43a446a6 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_ReleaseContract_Tests.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Magicodes.IE.IO; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests + { + [Fact] + public void WriteWorkbook_DuplicateSheetNames_ThrowsBeforeWritingDuplicate() + { + using var output = new MemoryStream(); + using var writer = new XlsxWriter(output); + + writer.AddSheet("Orders"); + + Should.Throw(() => writer.AddSheet("orders")); + } + + [Fact] + public void WriteWorkbook_TypedSheet_AppliesProfileRowFilter() + { + var profile = new ExportProfile() + .Where(x => x.OrderNo == "keep"); + + var bytes = Xlsx.WriteWorkbookToBytes( + new Sheet("Orders", new[] + { + new OrderDto { OrderNo = "drop" }, + new OrderDto { OrderNo = "keep" }, + }, profile)); + + var rows = XlsxIO_TestSupport.ReadSheet(bytes); + rows.Count.ShouldBe(2); + rows[1][0].ShouldBe("keep"); + } + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_S0_Guardrails_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_S0_Guardrails_Tests.cs new file mode 100644 index 00000000..a8800d0f --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_S0_Guardrails_Tests.cs @@ -0,0 +1,271 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using Magicodes.IE.IO; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests + { + + public static IEnumerable ColumnLetterCases() + { + yield return new object[] { 0, "A" }; + yield return new object[] { 25, "Z" }; + yield return new object[] { 26, "AA" }; + yield return new object[] { 27, "AB" }; + yield return new object[] { 51, "AZ" }; + yield return new object[] { 52, "BA" }; + yield return new object[] { 701, "ZZ" }; + yield return new object[] { 702, "AAA" }; + yield return new object[] { 703, "AAB" }; + yield return new object[] { 16383, "XFD" }; + } + + [Theory] + [MemberData(nameof(ColumnLetterCases))] + public void ColumnLetter_KnownColumns_MatchExpected(int col0, string expected) + { + Span dest = stackalloc byte[4]; + int n = XlsxWriter.ColumnLetter(col0, dest); + var actual = System.Text.Encoding.ASCII.GetString(dest.Slice(0, n)); + actual.ShouldBe(expected, $"col0={col0}"); + } + + [Fact] + public void ColumnLetter_RandomColumns_MatchReference() + { + string Ref(int col0) + { + var s = ""; + int n = col0; + while (true) + { + s = (char)('A' + n % 26) + s; + n = n / 26 - 1; + if (n < 0) break; + } + return s; + } + Span dest = stackalloc byte[4]; + var rnd = new Random(20240707); + for (int i = 0; i < 200; i++) + { + int col0 = rnd.Next(0, 16384); + int n = XlsxWriter.ColumnLetter(col0, dest); + var actual = System.Text.Encoding.ASCII.GetString(dest.Slice(0, n)); + actual.ShouldBe(Ref(col0), $"col0={col0}"); + } + } + + + [Fact] + public async Task Guardrail_PlainDto_ProducesWellFormedXlsx() + { + var bytes = Xlsx.ToBytes(new[] + { + new OrderDto { OrderNo = "A1", Amount = 10m, CreatedAt = new DateTime(2024, 1, 1) }, + new OrderDto { OrderNo = "B2", Amount = 20m, CreatedAt = new DateTime(2024, 2, 2) }, + }); + XlsxIO_TestSupport.AssertWellFormedXlsx(bytes); + } + + [Fact] + public async Task Guardrail_WithStyles_ProducesWellFormedXlsx() + { + var bytes = Xlsx.ToBytes(new[] + { + new OrderWithFormatDto { Amount = 1234.5m, Date = new DateTime(2024, 3, 3), Note = "x" }, + }); + XlsxIO_TestSupport.AssertWellFormedXlsx(bytes); + var styles = XlsxIO_TestSupport.ReadStyles(bytes); + styles.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Guardrail_Features_ProducesWellFormedXlsx() + { + var p = new ExportProfile() + .WithMergeCells("A1:B2") + .WithAutoFilter("A1:C10") + .WithHyperlink("A2", "https://example.com"); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + XlsxIO_TestSupport.AssertWellFormedXlsx(bytes); + } + + [Fact] + public async Task Guardrail_SharedStrings_ProducesWellFormedXlsx() + { + var p = new ExportProfile().WithAutoSst(false); + var bytes = Xlsx.ToBytes(new[] + { + new StringDto { Name = "hello" }, + new StringDto { Name = "world" }, + new StringDto { Name = "hello" }, + }, p); + XlsxIO_TestSupport.AssertWellFormedXlsx(bytes); + } + + + [Fact] + public void Complete_CanBeDrivenExplicitly_BeforeDispose() + { + var plan = RowPlanBuilder.BuildTyped(new ExportProfile()); + using var ms = new MemoryStream(); + var w = new XlsxWriter(ms); + w.AddSheet("S1"); + w.ResolveColumnStyles(plan.Columns); + w.WriteSheetMeta(plan.Columns, freezeHeader: false); + w.WriteHeader(plan.Columns); + w.WriteRows(new[] { new ReadbackOrder { Name = "x", Qty = 1, Price = 1m } }, plan); + w.Complete(); + w.Dispose(); + var bytes = ms.ToArray(); + XlsxIO_TestSupport.AssertWellFormedXlsx(bytes); + var rows = XlsxIO_TestSupport.ReadSheet(bytes, 1); + rows[1][0].ShouldBe("x"); + } + + [Fact] + public void Complete_IsIdempotent_WhenCalledMultipleTimes() + { + using var ms = new MemoryStream(); + var w = new XlsxWriter(ms); + w.AddSheet("S1"); + w.Complete(); + w.Complete(); + w.Dispose(); + var bytes = ms.ToArray(); + using var z = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + z.GetEntry("xl/workbook.xml").ShouldNotBeNull(); + } + + [Fact] + public void Complete_WithoutSheet_ThrowsInsteadOfWritingBrokenWorkbook() + { + using var ms = new MemoryStream(); + using var writer = new XlsxWriter(ms); + + Should.Throw(() => writer.Complete()); + } + + + [Theory] + [InlineData("'BadName")] + [InlineData("BadName'")] + [InlineData("History")] + [InlineData("history")] + [InlineData("HIStory")] + public void AddSheet_InvalidSheetName_Throws(string badName) + { + using var ms = new MemoryStream(); + using var w = new XlsxWriter(ms); + Should.Throw(() => w.AddSheet(badName)); + } + + [Fact] + public void AddSheet_ValidSheetName_Accepted() + { + using var ms = new MemoryStream(); + using var w = new XlsxWriter(ms); + Should.NotThrow(() => w.AddSheet("NormalSheet")); + Should.NotThrow(() => w.AddSheet("My-Sheet_1")); + } + + [Fact] + public void AddSheet_TrimsName_AsDocumented() + { + using var ms = new MemoryStream(); + using var w = new XlsxWriter(ms); + w.AddSheet(" Trimmed "); + w.SheetNames.ShouldContain("Trimmed"); + } + + + [Fact] + public async Task Guardrail_MultiSheet_ReadBackEachSheet() + { + var plan = RowPlanBuilder.BuildTyped(new ExportProfile()); + using var ms = new MemoryStream(); + using (var w = new XlsxWriter(ms)) + { + w.AddSheet("Sheet1"); + w.ResolveColumnStyles(plan.Columns); + w.WriteSheetMeta(plan.Columns, freezeHeader: false); + w.WriteHeader(plan.Columns); + w.WriteRows(new[] { new ReadbackOrder { Name = "a1", Qty = 1, Price = 1.1m } }, plan); + w.AddSheet("Sheet2"); + w.ResolveColumnStyles(plan.Columns); + w.WriteSheetMeta(plan.Columns, freezeHeader: false); + w.WriteHeader(plan.Columns); + w.WriteRows(new[] { new ReadbackOrder { Name = "b2", Qty = 2, Price = 2.2m } }, plan); + w.AddSheet("Sheet3"); + w.ResolveColumnStyles(plan.Columns); + w.WriteSheetMeta(plan.Columns, freezeHeader: false); + w.WriteHeader(plan.Columns); + w.WriteRows(new[] { new ReadbackOrder { Name = "c3", Qty = 3, Price = 3.3m } }, plan); + } + var bytes = ms.ToArray(); + XlsxIO_TestSupport.AssertWellFormedXlsx(bytes); + var s1 = XlsxIO_TestSupport.ReadSheet(bytes, 1); + var s2 = XlsxIO_TestSupport.ReadSheet(bytes, 2); + var s3 = XlsxIO_TestSupport.ReadSheet(bytes, 3); + s1[1][0].ShouldBe("a1"); + s2[1][0].ShouldBe("b2"); + s3[1][0].ShouldBe("c3"); + } + + public sealed class WideDto + { + public string C00 { get; set; } = ""; + public string C01 { get; set; } = ""; + public string C02 { get; set; } = ""; + public string C03 { get; set; } = ""; + public string C04 { get; set; } = ""; + public string C05 { get; set; } = ""; + public string C06 { get; set; } = ""; + public string C07 { get; set; } = ""; + public string C08 { get; set; } = ""; + public string C09 { get; set; } = ""; + public string C10 { get; set; } = ""; + public string C11 { get; set; } = ""; + public string C12 { get; set; } = ""; + public string C13 { get; set; } = ""; + public string C14 { get; set; } = ""; + public string C15 { get; set; } = ""; + public string C16 { get; set; } = ""; + public string C17 { get; set; } = ""; + public string C18 { get; set; } = ""; + public string C19 { get; set; } = ""; + public string C20 { get; set; } = ""; + public string C21 { get; set; } = ""; + public string C22 { get; set; } = ""; + public string C23 { get; set; } = ""; + public string C24 { get; set; } = ""; + public string C25 { get; set; } = ""; + public string C26 { get; set; } = ""; + public string C27 { get; set; } = ""; + public string C28 { get; set; } = ""; + public string C29 { get; set; } = ""; + } + + [Fact] + public async Task Guardrail_WideTable_Beyond26Columns_ReadBack() + { + var bytes = Xlsx.ToBytes(new[] { new WideDto { C00 = "x", C26 = "y", C29 = "z" } }); + XlsxIO_TestSupport.AssertWellFormedXlsx(bytes); + var rows = XlsxIO_TestSupport.ReadSheet(bytes, 1); + rows.Count.ShouldBeGreaterThanOrEqualTo(2); + var header = string.Join(",", rows[0].Where(x => x is not null).Select(x => x!)); + header.ShouldContain("C26"); + header.ShouldContain("C29"); + rows[1][0].ShouldBe("x"); + } + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Styles_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Styles_Tests.cs new file mode 100644 index 00000000..932dec68 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Styles_Tests.cs @@ -0,0 +1,254 @@ + +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using System.Xml; +using Magicodes.IE.IO; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests + { + [Fact] + public async Task Style_Bold_WritesFontInStyles() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithBold()); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain(""); + } + + [Fact] + public async Task Style_BackgroundColor_WritesSolidFill() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithBackgroundColor("FFFF00")); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("().Column(x => x.OrderNo, c => c.WithFontSize(20)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("val=\"20\""); + } + + [Fact] + public async Task Style_FontColor_WritesColor() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithFontColor("FF0000")); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("FF0000"); + } + + [Fact] + public async Task Style_FontName_WritesCustomName() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithFontName("Consolas")); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("Consolas"); + } + + [Fact] + public async Task Style_Border_WritesThinBlackBorder() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithBorderStyle(BorderStyle.Thin)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain(""); + xml.ShouldContain("style=\"thin\""); + } + + [Fact] + public async Task Style_Border_CustomColor() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithBorderStyle(BorderStyle.Thin, "FF0000")); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("FF0000"); + } + + [Fact] + public async Task Style_Border_Dashed_WritesDashedBorder() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithBorderStyle(BorderStyle.Dashed)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("style=\"dashed\""); + } + + [Fact] + public async Task Style_Border_Dotted_WritesDottedBorder() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithBorderStyle(BorderStyle.Dotted)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("style=\"dotted\""); + } + + [Fact] + public async Task Style_Border_Double_WritesDoubleBorder() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithBorderStyle(BorderStyle.Double)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("style=\"double\""); + } + + [Fact] + public async Task Style_Italic_WritesItalic() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithItalic()); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain(""); + } + + [Fact] + public async Task Style_Underline_WritesUnderline() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithUnderline()); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("().Column(x => x.OrderNo, c => c.WithStrikeThrough()); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain(""); + } + + [Fact] + public async Task Style_VerticalAlignment_Center_WritesCenter() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithVerticalAlignment(VerticalAlignment.Center)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("vertical=\"center\""); + } + + [Fact] + public async Task Style_VerticalAlignment_Top_WritesTop() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithVerticalAlignment(VerticalAlignment.Top)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("vertical=\"top\""); + } + + [Fact] + public async Task RowHeight_Default_WritesSheetFormatPr() + { + var p = new ExportProfile().WithDefaultRowHeight(20); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain("().Column(x => x.OrderNo, c => c.WithWrap()); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain("wrapText=\"1\""); + } + + [Fact] + public async Task Style_BoldHeader_RealXfIdOnCell() + { + var p = new ExportProfile().Column(x => x.OrderNo, c => c.WithBold()); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/styles.xml"); + xml.ShouldContain(""); + } + + [Fact] + public async Task RowHeight_WritesSheetFormatPr() + { + var p = new ExportProfile().WithDefaultRowHeight(15); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain("defaultRowHeight=\"11.25\""); + } + + [Fact] + public async Task RowHeight_FromColumnConfig_WritesHeaderRowHeight() + { + var p = new ExportProfile() + .Column(x => x.OrderNo, c => c.WithRowHeight(30)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + xml.ShouldContain("ht=\"22.5\""); + } + + [Fact] + public async Task EveryCell_HasRAttribute_RequiredBySpec() + { + var bytes = Xlsx.ToBytes(new[] + { + new OrderDto { OrderNo = "A1", Amount = 1m, CreatedAt = new DateTime(2024, 1, 1) }, + new OrderDto { OrderNo = "A2", Amount = 2m, CreatedAt = new DateTime(2024, 1, 2) }, + }); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + int cellCount = xml.Split(" 都需要 r=\"A1\" — 缺 {cellCount - cellsWithR} 个"); + } + + [Fact] + public async Task ContentTypes_IncludesGifAndBmp() + { + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "[Content_Types].xml"); + xml.ShouldContain("Extension=\"gif\""); + xml.ShouldContain("Extension=\"bmp\""); + } + + [Fact] + public async Task Worksheet_ElementOrder_SpecCompliant() + { + var p = new ExportProfile() + .WithDefaultRowHeight(20) + .Column(x => x.OrderNo, c => c.WithWidth(15)); + var bytes = Xlsx.ToBytes(new[] { new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + int sheetViewsPos = xml.IndexOf("().WithAutoFilter("A1:C2"); + var bytes = Xlsx.ToBytes(new[] { new OrderDto(), new OrderDto() }, p); + var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/worksheets/sheet1.xml"); + int autoFilterPos = xml.IndexOf(""); + autoFilterPos.ShouldBeGreaterThan(sheetDataEnd); + pageMarginsPos.ShouldBeGreaterThan(sheetDataEnd); + } + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Template_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Template_Tests.cs new file mode 100644 index 00000000..e905e281 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Template_Tests.cs @@ -0,0 +1,161 @@ + +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using System.Xml; +using Magicodes.IE.IO; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests + { + [Fact] + public async Task Template_ReplacesSingleVar() + { + var templatePath = Path.Combine(Path.GetTempPath(), $"tpl_single_{Guid.NewGuid():N}.xlsx"); + var outputPath = Path.Combine(Path.GetTempPath(), $"out_single_{Guid.NewGuid():N}.xlsx"); + try + { + using (var fs = File.Create(templatePath)) + using (var zip = new System.IO.Compression.ZipArchive(fs, System.IO.Compression.ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = entry.Open(); + using var sw = new StreamWriter(es); + sw.Write(@"客户:{{CustomerName}}订单号:{{OrderNo}}"); + } + var data = new TemplateHolder { CustomerName = "张三", OrderNo = "SO-1" }; + await Xlsx.ExportByTemplateAsync(templatePath, outputPath, data); + File.Exists(outputPath).ShouldBeTrue(); + using var za = new ZipArchive(File.OpenRead(outputPath), ZipArchiveMode.Read); + using var sr = new StreamReader(za.GetEntry("xl/worksheets/sheet1.xml")!.Open()); + var xml = await sr.ReadToEndAsync(); + xml.ShouldContain("客户:张三"); + xml.ShouldContain("订单号:SO-1"); + } + finally + { + if (File.Exists(templatePath)) File.Delete(templatePath); + if (File.Exists(outputPath)) File.Delete(outputPath); + } + } + + [Fact] + public async Task Template_EscapesXmlTextValues() + { + using var templateMs = new MemoryStream(); + using (var zip = new ZipArchive(templateMs, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = entry.Open(); + using var sw = new StreamWriter(es); + sw.Write(@"{{CustomerName}}"); + } + + templateMs.Position = 0; + using var outputMs = new MemoryStream(); + await Xlsx.ExportByTemplateAsync(templateMs, outputMs, new TemplateHolder { CustomerName = "A&B" }); + + var xml = XlsxIO_TestSupport.ReadEntry(outputMs.ToArray(), "xl/worksheets/sheet1.xml"); + xml.ShouldContain("A&B<C>"); + XlsxIO_TestSupport.AssertWellFormedXml(xml, "sheet1.xml"); + } + + [Fact] + public async Task Template_ReplacesListBlock() + { + var templatePath = Path.Combine(Path.GetTempPath(), $"tpl_list_{Guid.NewGuid():N}.xlsx"); + var outputPath = Path.Combine(Path.GetTempPath(), $"out_list_{Guid.NewGuid():N}.xlsx"); + try + { + using (var fs = File.Create(templatePath)) + using (var zip = new System.IO.Compression.ZipArchive(fs, System.IO.Compression.ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using var es = entry.Open(); + using var sw = new StreamWriter(es); + sw.Write(@"客户:{{CustomerName}}{{#Items}}{{Name}} x{{Qty}} ${{Price}}{{/Items}}"); + } + var data = new TemplateHolder + { + CustomerName = "李四", + Items = new() { new TemplateCell { Name = "A", Qty = 2, Price = 5m }, new TemplateCell { Name = "B", Qty = 3, Price = 7m } } + }; + await Xlsx.ExportByTemplateAsync(templatePath, outputPath, data); + using var za = new ZipArchive(File.OpenRead(outputPath), ZipArchiveMode.Read); + using var sr = new StreamReader(za.GetEntry("xl/worksheets/sheet1.xml")!.Open()); + var xml = await sr.ReadToEndAsync(); + xml.ShouldContain("客户:李四"); + xml.ShouldContain("A x2 $5"); + xml.ShouldContain("B x3 $7"); + xml.ShouldContain(""); + xml.ShouldContain(""); + xml.ShouldContain("r=\"A2\""); + xml.ShouldContain("r=\"A3\""); + } + finally + { + if (File.Exists(templatePath)) File.Delete(templatePath); + if (File.Exists(outputPath)) File.Delete(outputPath); + } + } + + [Fact] + public async Task Template_ReplacesSharedStringsPlaceholders() + { + using var templateMs = new MemoryStream(); + using (var zip = new ZipArchive(templateMs, ZipArchiveMode.Create, leaveOpen: true)) + { + var sst = zip.CreateEntry("xl/sharedStrings.xml"); + using (var es = sst.Open()) + using (var sw = new StreamWriter(es)) + sw.Write(@"客户:{{CustomerName}}"); + + var sheet = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using (var es = sheet.Open()) + using (var sw = new StreamWriter(es)) + sw.Write(@"0"); + } + + templateMs.Position = 0; + using var outputMs = new MemoryStream(); + await Xlsx.ExportByTemplateAsync(templateMs, outputMs, new TemplateHolder { CustomerName = "赵六" }); + + var sstXml = XlsxIO_TestSupport.ReadEntry(outputMs.ToArray(), "xl/sharedStrings.xml"); + sstXml.ShouldContain("客户:赵六"); + sstXml.ShouldNotContain("{{CustomerName}}"); + } + + [Fact] + public async Task Template_ReplacesAllWorksheetParts() + { + using var templateMs = new MemoryStream(); + using (var zip = new ZipArchive(templateMs, ZipArchiveMode.Create, leaveOpen: true)) + { + var sheet1 = zip.CreateEntry("xl/worksheets/sheet1.xml"); + using (var es = sheet1.Open()) + using (var sw = new StreamWriter(es)) + sw.Write(@"{{CustomerName}}"); + + var sheet2 = zip.CreateEntry("xl/worksheets/sheet2.xml"); + using (var es = sheet2.Open()) + using (var sw = new StreamWriter(es)) + sw.Write(@"{{OrderNo}}"); + } + + templateMs.Position = 0; + using var outputMs = new MemoryStream(); + await Xlsx.ExportByTemplateAsync(templateMs, outputMs, new TemplateHolder { CustomerName = "孙七", OrderNo = "SO-7" }); + + var sheet1Xml = XlsxIO_TestSupport.ReadEntry(outputMs.ToArray(), "xl/worksheets/sheet1.xml"); + var sheet2Xml = XlsxIO_TestSupport.ReadEntry(outputMs.ToArray(), "xl/worksheets/sheet2.xml"); + + sheet1Xml.ShouldContain("孙七"); + sheet2Xml.ShouldContain("SO-7"); + } + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_TestSupport.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_TestSupport.cs new file mode 100644 index 00000000..4af2725b --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_TestSupport.cs @@ -0,0 +1,483 @@ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Magicodes.IE.IO; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + + public class OrderDto + { + public string OrderNo { get; set; } = ""; + public decimal Amount { get; set; } + public DateTime CreatedAt { get; set; } + } + + public class OrderWithFormatDto + { + [DisplayFormat(DataFormatString = "0.00")] + public decimal Amount { get; set; } + [DisplayFormat(DataFormatString = "yyyy-MM-dd")] + public DateTime Date { get; set; } + public string Note { get; set; } = ""; + } + + public class DescribedDto + { + [Description("备注")] + public string Note { get; set; } = ""; + } + + public enum Status + { + [Description("待支付")] + Pending = 0, + [Description("已支付")] + Paid = 1, + } + + public class OrderWithEnumDto + { + public string OrderNo { get; set; } = ""; + public Status Status { get; set; } + } + + public class StringDto { public string Name { get; set; } = ""; } + + public class ULongDto + { + public string Name { get; set; } = ""; + public ulong Value { get; set; } + } + + public enum BigFlag : ulong { None = 0, Huge = 1UL << 63 } + + public class ULongEnumDto + { + public string Name { get; set; } = ""; + public BigFlag Flag { get; set; } + } + + public class ULongEnumAsDoubleDto + { + public string Name { get; set; } = ""; + public double Flag { get; set; } + } + + public class DeclOrderDto + { + public string Alpha { get; set; } = ""; + public string Bravo { get; set; } = ""; + public string Charlie { get; set; } = ""; + } + + public class OrderWithAttributesDto + { + [ExporterHeader(Name = "订单号", IsIgnore = false)] + public string OrderNo { get; set; } = ""; + [ExporterHeader(IsIgnore = true)] + public string Secret { get; set; } = ""; + public decimal Amount { get; set; } + } + + public record struct TestOrderRecord(string OrderNo, decimal Amount); + + public class OrderWithExporterHeaderDto + { + [ExporterHeader(Name = "Order ID", Index = 0)] + public string OrderNo { get; set; } = ""; + [ExporterHeader(Name = "Total", Index = 1)] + public decimal Amount { get; set; } + } + + public class ReadbackOrder + { + public string Name { get; set; } = ""; + public int Qty { get; set; } + public decimal Price { get; set; } + } + + public class ReceiptDto + { + public string Customer { get; set; } = ""; + public List Items { get; set; } = new(); + } + public record ReceiptItem(string Name, int Qty, decimal Price); + + public class TemplateHolder + { + public string CustomerName { get; set; } = ""; + public string OrderNo { get; set; } = ""; + public List Items { get; set; } = new(); + } + public class TemplateCell + { + public string Name { get; set; } = ""; + public int Qty { get; set; } + public decimal Price { get; set; } + } + + public class Row2 { public string A { get; set; } = ""; public string B { get; set; } = ""; } + + public class BoolDto + { + public string Name { get; set; } = ""; + public bool Enabled { get; set; } + } + + public class OffsetEnumDto + { + public DateTimeOffset When { get; set; } + public Status? OptionalStatus { get; set; } + } + + public class NullableDto + { + public string? Name { get; set; } + public int? Qty { get; set; } + public decimal? Price { get; set; } + public DateTime? CreatedAt { get; set; } + public bool? Enabled { get; set; } + } + + [XlsxExportable] + public class ExportableDto + { + public string OrderNo { get; set; } = ""; + public decimal Amount { get; set; } + } + + [XlsxExportable] + public class ExportableReorderedDto + { + public string A { get; set; } = ""; + public string B { get; set; } = ""; + public string C { get; set; } = ""; + } + + + public static class XlsxIO_TestSupport + { + + public static List> ReadSheet(byte[] bytes) + { + using var ms = new MemoryStream(bytes); + using var zip = new ZipArchive(ms, ZipArchiveMode.Read); + var entry = zip.GetEntry("xl/worksheets/sheet1.xml"); + entry.ShouldNotBeNull("sheet1.xml 必须存在"); + using var es = entry.Open(); + var doc = new XmlDocument(); + doc.Load(es); + var ns = new XmlNamespaceManager(doc.NameTable); + ns.AddNamespace("x", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"); + + var rows = new List>(); + var rowNodes = doc.SelectNodes("/x:worksheet/x:sheetData/x:row", ns); + if (rowNodes is null) return rows; + foreach (XmlNode row in rowNodes) + { + var list = new List(); + var cNodes = row.SelectNodes("x:c", ns); + if (cNodes is null) { rows.Add(list); continue; } + foreach (XmlNode c in cNodes) + { + var t = c.SelectSingleNode("x:is/x:t", ns); + if (t is not null) { list.Add(t.InnerText); continue; } + var v = c.SelectSingleNode("x:v", ns); + list.Add(v?.InnerText); + } + rows.Add(list); + } + return rows; + } + + + public static List<(int StyleId, string? Format)> ReadStyles(byte[] bytes) + { + using var ms = new MemoryStream(bytes); + using var zip = new ZipArchive(ms, ZipArchiveMode.Read); + var entry = zip.GetEntry("xl/styles.xml"); + if (entry is null) return new(); + using var es = entry.Open(); + var doc = new XmlDocument(); + doc.Load(es); + var ns = new XmlNamespaceManager(doc.NameTable); + ns.AddNamespace("x", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"); + var result = new List<(int, string?)>(); + var xfs = doc.SelectNodes("/x:styleSheet/x:cellXfs/x:xf", ns); + if (xfs is null) return result; + foreach (XmlNode xf in xfs) + { + var numFmtId = xf.Attributes?["numFmtId"]?.Value; + if (numFmtId is not null && int.TryParse(numFmtId, out var id) && id > 0) + result.Add((id, numFmtId)); + } + return result; + } + + + public static List> ReadSheet(byte[] bytes, int sheetIndex = 1) + { + var path = $"xl/worksheets/sheet{sheetIndex}.xml"; + using var ms = new MemoryStream(bytes); + using var zip = new ZipArchive(ms, ZipArchiveMode.Read); + var entry = zip.GetEntry(path); + entry.ShouldNotBeNull($"{path} 必须存在"); + using var es = entry.Open(); + var doc = new XmlDocument(); + doc.Load(es); + var ns = new XmlNamespaceManager(doc.NameTable); + ns.AddNamespace("x", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"); + var rows = new List>(); + var rowNodes = doc.SelectNodes("/x:worksheet/x:sheetData/x:row", ns); + if (rowNodes is null) return rows; + foreach (XmlNode row in rowNodes) + { + var list = new List(); + var cNodes = row.SelectNodes("x:c", ns); + if (cNodes is null) { rows.Add(list); continue; } + foreach (XmlNode c in cNodes) + { + var t = c.SelectSingleNode("x:is/x:t", ns); + if (t is not null) { list.Add(t.InnerText); continue; } + var v = c.SelectSingleNode("x:v", ns); + list.Add(v?.InnerText); + } + rows.Add(list); + } + return rows; + } + + + + public static void AssertWellFormedXlsx(byte[] bytes) + { + bytes.ShouldNotBeNull(); + bytes.Length.ShouldBeGreaterThan(0); + using var ms = new MemoryStream(bytes); + using var zip = new ZipArchive(ms, ZipArchiveMode.Read); + foreach (var part in new[] { "[Content_Types].xml", "xl/workbook.xml", "xl/worksheets/sheet1.xml", "xl/styles.xml", "xl/_rels/workbook.xml.rels" }) + { + zip.GetEntry(part).ShouldNotBeNull($"{part} 必须存在"); + } + foreach (var entry in zip.Entries) + { + if (!entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) + && !entry.FullName.EndsWith(".rels", StringComparison.OrdinalIgnoreCase)) continue; + using var es = entry.Open(); + using var sr = new StreamReader(es); + var xml = sr.ReadToEnd(); + AssertWellFormedXml(xml, entry.FullName); + } + AssertPackageGraph(zip); + } + + + public static void AssertPackageGraph(ZipArchive zip) + { + foreach (var relsEntry in zip.Entries.Where(e => e.FullName.EndsWith(".rels", StringComparison.OrdinalIgnoreCase))) + { + string sourcePart = relsEntry.FullName == "_rels/.rels" + ? "" + : relsEntry.FullName.Substring(0, relsEntry.FullName.Length - ".rels".Length).Replace("/_rels/", "/", StringComparison.Ordinal); + var doc = new XmlDocument { XmlResolver = null }; + using (var stream = relsEntry.Open()) doc.Load(stream); + var ns = new XmlNamespaceManager(doc.NameTable); + ns.AddNamespace("r", "http://schemas.openxmlformats.org/package/2006/relationships"); + foreach (XmlElement rel in doc.SelectNodes("/r:Relationships/r:Relationship", ns)!) + { + if (string.Equals(rel.GetAttribute("TargetMode"), "External", StringComparison.OrdinalIgnoreCase)) continue; + var target = rel.GetAttribute("Target").Replace('\\', '/').TrimStart('/'); + var baseDir = sourcePart.Length == 0 ? "" : sourcePart.Substring(0, sourcePart.LastIndexOf('/') + 1); + var combined = NormalizePackagePath(baseDir + target); + zip.GetEntry(combined).ShouldNotBeNull($"{relsEntry.FullName} -> {target} ({combined}) 必须存在"); + } + } + } + + private static string NormalizePackagePath(string path) + { + var parts = path.Split('/'); + var result = new List(); + foreach (var part in parts) + { + if (part.Length == 0 || part == ".") continue; + if (part == "..") { if (result.Count > 0) result.RemoveAt(result.Count - 1); } + else result.Add(part); + } + return string.Join("/", result); + } + + public static string ReadEntry(byte[] bytes, string path) + { + using var ms = new MemoryStream(bytes); + using var zip = new ZipArchive(ms, ZipArchiveMode.Read); + var entry = zip.GetEntry(path); + entry.ShouldNotBeNull(); + using var es = entry.Open(); + using var sr = new StreamReader(es); + return sr.ReadToEnd(); + } + + public static Dictionary ReadCriticalXmlParts(byte[] bytes) + { + using var ms = new MemoryStream(bytes); + using var zip = new ZipArchive(ms, ZipArchiveMode.Read); + var critical = new[] + { + "[Content_Types].xml", + "xl/workbook.xml", + "xl/worksheets/sheet1.xml", + "xl/styles.xml", + "xl/_rels/workbook.xml.rels" + }; + var result = new Dictionary(StringComparer.Ordinal); + foreach (var path in critical) + { + var entry = zip.GetEntry(path); + entry.ShouldNotBeNull($"{path} 必须存在"); + using var es = entry!.Open(); + using var sr = new StreamReader(es); + result[path] = sr.ReadToEnd(); + } + return result; + } + + public static void AssertWellFormedXml(string xml, string partName) + { + var doc = new XmlDocument { XmlResolver = null }; + Should.NotThrow(() => doc.LoadXml(xml), $"{partName} 不是 well-formed XML"); + } + + public sealed class Order + { + public int A { get; set; } + public string? B { get; set; } + } + + public static ColumnMeta[] MakeCols() => new[] { new ColumnMeta("A", "A", null, null, false, 0, 0) }; + + public static TypedRowPlan MakeTypedPlan(string[] propertyNames) + { + var cols = new ColumnMeta[propertyNames.Length]; + for (int i = 0; i < propertyNames.Length; i++) cols[i] = new ColumnMeta(propertyNames[i], propertyNames[i], null, null, false, 0, i); + var props = typeof(Order).GetProperties(); + var getters = new Func[propertyNames.Length]; + for (int i = 0; i < propertyNames.Length; i++) + { + var p = Array.Find(props, x => x.Name == propertyNames[i])!; + getters[i] = o => p.GetValue(o) switch + { + int v => CellValue.FromInteger(v), + string s => CellValue.FromString(s), + _ => CellValue.Null + }; + } + return new TypedRowPlan(cols, new Func[0], getters, new int[propertyNames.Length], new Action?[propertyNames.Length], hasFormulas: false); + } + + public sealed class NonSeekableReadStream : Stream + { + private readonly Stream _inner; + public NonSeekableReadStream(Stream inner) => _inner = inner; + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => _inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) + { + if (disposing) _inner.Dispose(); + base.Dispose(disposing); + } + } + + public sealed class ThrowOnAsyncWriteStream : MemoryStream + { + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => Task.FromException(new IOException("injected async write failure")); + } + + public sealed class TrackingReadStream : Stream + { + private readonly Stream _inner; + + public TrackingReadStream(Stream inner) => _inner = inner; + + public int ReadCount { get; private set; } + public int ReadAsyncCount { get; private set; } + public int SeekCount { get; private set; } + public int PositionGetCount { get; private set; } + public int PositionSetCount { get; private set; } + public int LengthGetCount { get; private set; } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => _inner.CanSeek; + public override bool CanWrite => false; + + public override long Length + { + get + { + LengthGetCount++; + return _inner.Length; + } + } + + public override long Position + { + get + { + PositionGetCount++; + return _inner.Position; + } + set + { + PositionSetCount++; + _inner.Position = value; + } + } + + public override void Flush() => _inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) + { + ReadCount++; + return _inner.Read(buffer, offset, count); + } + public override long Seek(long offset, SeekOrigin origin) + { + SeekCount++; + return _inner.Seek(offset, origin); + } + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ReadAsyncCount++; + return _inner.ReadAsync(buffer, offset, count, cancellationToken); + } + protected override void Dispose(bool disposing) + { + if (disposing) _inner.Dispose(); + base.Dispose(disposing); + } + } + } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Tests.cs new file mode 100644 index 00000000..176d70b2 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Tests.cs @@ -0,0 +1,5 @@ + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests { } +} diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIoPerfRegressionTests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIoPerfRegressionTests.cs new file mode 100644 index 00000000..3538c33c --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/XlsxIoPerfRegressionTests.cs @@ -0,0 +1,91 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using Magicodes.IE.IO; +using Shouldly; +using Xunit; + +namespace Magicodes.IE.IO.Tests +{ + public partial class XlsxIO_Tests + { + [Fact] + public void ToBytes_ProducesValidZipReopenableByZipArchive() + { + var data = new List + { + new() { OrderNo = "R1", Amount = 12m, CreatedAt = new DateTime(2026, 1, 1) }, + new() { OrderNo = "R2", Amount = 34.5m, CreatedAt = new DateTime(2026, 2, 2) }, + }; + + byte[] bytes = Xlsx.ToBytes(data); + bytes.Length.ShouldBeGreaterThan(0); + + using var zip = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + var names = zip.Entries.Select(e => e.FullName).ToArray(); + names.ShouldContain("xl/worksheets/sheet1.xml"); + names.ShouldContain("xl/workbook.xml"); + names.ShouldContain("xl/styles.xml"); + names.ShouldContain("[Content_Types].xml"); + + var sheet = zip.GetEntry("xl/worksheets/sheet1.xml")!; + using var sr = new StreamReader(sheet.Open()); + var sheetXml = sr.ReadToEnd(); + sheetXml.ShouldContain("R1"); + sheetXml.ShouldContain("R2"); + } + + [Fact] + public void ToBytes_RoundTripsViaRead() + { + var data = new List + { + new() { OrderNo = "RT-1", Amount = 9.99m, CreatedAt = new DateTime(2026, 3, 3) }, + new() { OrderNo = "RT-2", Amount = 0m, CreatedAt = new DateTime(2026, 4, 4) }, + }; + + byte[] bytes = Xlsx.ToBytes(data); + using var ms = new MemoryStream(bytes); + var read = Xlsx.Read(ms).ToList(); + read.Count.ShouldBe(2); + read[0].OrderNo.ShouldBe("RT-1"); + read[0].Amount.ShouldBe(9.99m); + read[1].OrderNo.ShouldBe("RT-2"); + read[1].Amount.ShouldBe(0m); + } + + [Fact] + public void WriteWorkbookToBytes_ProducesMultipleSheets() + { + var s1 = new Sheet("Sheet1", new[] { new OrderDto { OrderNo = "A", Amount = 1 } }); + var s2 = new Sheet("Sheet2", new[] { new OrderDto { OrderNo = "B", Amount = 2 } }); + byte[] bytes = Xlsx.WriteWorkbookToBytes(s1, s2); + + using var zip = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + var names = zip.Entries.Select(e => e.FullName).ToArray(); + names.ShouldContain("xl/worksheets/sheet1.xml"); + names.ShouldContain("xl/worksheets/sheet2.xml"); + } + + [Fact] + public void ToBytes_WithAutoSst_WritesSharedStringsAndRoundTrips() + { + var data = new List(); + for (int i = 0; i < 20; i++) + data.Add(new() { OrderNo = "SST1", Amount = i }); + + byte[] bytes = Xlsx.ToBytes(data, cfg => cfg.WithAutoSst()); + using var zip = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + zip.Entries.Select(e => e.FullName).ShouldContain("xl/sharedStrings.xml"); + + using var ms = new MemoryStream(bytes); + var read = Xlsx.Read(ms).ToList(); + read.Count.ShouldBe(20); + read[0].OrderNo.ShouldBe("SST1"); + read[19].OrderNo.ShouldBe("SST1"); + } + } +} From 2b25f4927a9f19b244ab5bac61078e2e946e25ec Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 07:27:06 +0800 Subject: [PATCH 02/14] =?UTF-8?q?ci:=20IO=20=E6=B5=8B=E8=AF=95=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E4=B8=BA=E5=85=A8=E5=B9=B3=E5=8F=B0=E7=9F=A9=E9=98=B5?= =?UTF-8?q?(Linux/Windows/macOS=20=C3=97=20net8.0/net10.0)=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20check=20=E9=97=A8=E7=A6=81=20allowed-skips?= =?UTF-8?q?=20=E9=81=97=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnetcore.yml | 114 ++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 25 deletions(-) diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 988458d0..143bae92 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -7,12 +7,11 @@ # Frameworks: net6.0, net8.0, net9.0, net10.0, net471 # # Architecture: -# changed -> skip detection (docs-only PRs skip tests) -# test -> build & test matrix (legacy core test project) -# io-test-macos -> Magicodes.IE.IO dedicated regression suite on Apple-Silicon -# (-c Debug activates the CRC32 self-check; guards #1-class -# arm64 platform traps that x86-only CI would miss) -# check -> alls-green gate (single required status check) +# changed -> skip detection (docs-only PRs skip tests) +# test -> build & test matrix (legacy core test project) +# io-test -> Magicodes.IE.IO test matrix (Linux/Windows/macOS, net8.0/net10.0) +# io-test-macos-debug -> macOS arm64 Debug CRC32 self-check (catches arm64-specific bugs) +# check -> alls-green gate (single required status check) # # Test runner: # -parallel none -noshadow (Orleans pattern, prevents file locking) @@ -275,29 +274,94 @@ jobs: retention-days: 7 ################################################### - # IO PERF-REGRESSION GUARD (Apple Silicon / arm64) + # IO TESTS (all platforms) # - # Runs the *dedicated* Magicodes.IE.IO test project - # (tests/Magicodes.IE.IO.Tests) on Apple-Silicon. This is the project that - # holds XlsxIoPerfRegressionTests — the round-trip + ZipArchive-reopen guard - # for the ForwardOnlyZipWriter CRC32 path. The main matrix above targets the - # legacy core test project only, so without this job the IO regression suite - # never ran in CI and an arm64 CRC bug (#1 class) could slip through on - # x86-only runners. + # Runs the *dedicated* Magicodes.IE.IO test project across + # Linux / Windows / macOS to guard the ForwardOnlyZipWriter + # CRC32 path + XlsxIoPerfRegressionTests on all architectures. + ################################################### + + io-test: + name: IO ${{ matrix.os }} / ${{ matrix.framework }} + needs: changed + if: needs.changed.outputs.should_skip != 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + framework: [net8.0, net10.0] + env: + IO_TEST_PROJECT: tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + cache: true + cache-dependency-path: | + Magicodes.IE.sln + src/**/*.csproj + + - name: Restore + run: dotnet restore ${{ env.IO_TEST_PROJECT }} + + - name: Build + run: dotnet build ${{ env.IO_TEST_PROJECT }} -c Release --no-restore -bl + + - name: Test + run: > + dotnet test ${{ env.IO_TEST_PROJECT }} + -c Release --no-build + -f ${{ matrix.framework }} + --blame-hang-timeout 10m + --blame-crash-dump-type full + --blame-hang-dump-type full + --logger "trx;LogFileName=results-io-${{ matrix.framework }}.trx" + --logger GitHubActions + --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: io-test-results-${{ matrix.os }}-${{ matrix.framework }} + path: TestResults + if-no-files-found: ignore + retention-days: 7 + + - name: Upload build log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: io-build-log-${{ matrix.os }}-${{ matrix.framework }} + path: msbuild.binlog + if-no-files-found: ignore + retention-days: 7 + + ################################################### + # IO CRC32 SELF-CHECK (macOS arm64 / Debug) # - # Built/tested in -c Debug on purpose: ForwardOnlyZipWriter contains a - # #if DEBUG CRC32 self-check ("123456789" => 0xCBF43926). A wrong arm64 CRC - # throws at assembly load, failing the whole run immediately — the cheapest, - # strongest platform trap. (Release runtime CRC is still exercised by the - # ZipArchive reopen assertions inside the tests.) + # Built/tested in -c Debug on purpose: ForwardOnlyZipWriter + # contains a #if DEBUG CRC32 self-check ("123456789" => + # 0xCBF43926). A wrong arm64 CRC throws at assembly load, + # failing the whole run immediately — the cheapest, strongest + # platform trap. (Release runtime CRC is still exercised by the + # ZipArchive reopen assertions inside the io-test matrix above.) ################################################### - io-test-macos: - name: IO Tests (macOS arm64 / Debug) + io-test-macos-debug: + name: IO Debug (macOS arm64 CRC check) needs: changed if: needs.changed.outputs.should_skip != 'true' - runs-on: macos-15 - timeout-minutes: 20 + runs-on: macos-latest + timeout-minutes: 15 env: IO_TEST_PROJECT: tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj steps: @@ -367,7 +431,7 @@ jobs: check: name: CI Passed if: always() - needs: [changed, test, io-test-macos] + needs: [changed, test, io-test, io-test-macos-debug] runs-on: ubuntu-latest steps: - name: Evaluate results @@ -376,7 +440,7 @@ jobs: allowed-skips: >- ${{ needs.changed.outputs.should_skip == 'true' - && 'test' + && 'test,io-test,io-test-macos-debug' || '' }} jobs: ${{ toJSON(needs) }} From c7b9b8f53a42f40673ad32ca0249d0574e026447 Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 07:33:28 +0800 Subject: [PATCH 03/14] =?UTF-8?q?fix:=20IO=20=E6=B5=8B=E8=AF=95=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BC=BA=E5=A4=B1=20GitHubActionsTestLogger=20?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=20CI=20--logger=20GitHubActions=20=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj index 69e2fbbf..6b1423b3 100644 --- a/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj +++ b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj @@ -17,6 +17,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive From f60b059c3154dfc0d649fab0d9af20adca54d5ba Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 07:38:09 +0800 Subject: [PATCH 04/14] =?UTF-8?q?ci:=20=E9=87=8D=E6=9E=84=20CI=20=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=EF=BC=8C=E4=B8=89=E5=90=88=E4=B8=80=20test=20step?= =?UTF-8?q?=EF=BC=8CmacOS=20=E7=9C=9F=E5=A4=B1=E8=B4=A5=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=EF=BC=8Cmatrix=20=E7=AC=9B=E5=8D=A1=E5=B0=94=E7=A7=AF=E6=9B=B4?= =?UTF-8?q?=E4=BC=98=E9=9B=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnetcore.yml | 225 ++++++++++--------------------- 1 file changed, 71 insertions(+), 154 deletions(-) diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 143bae92..d5147b11 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -1,31 +1,29 @@ ################################################### # Magicodes.IE CI # -# Platforms: ubuntu-22.04, ubuntu-24.04, ubuntu-latest, -# windows-2022, windows-latest, -# macos-15, macos-latest -# Frameworks: net6.0, net8.0, net9.0, net10.0, net471 -# # Architecture: # changed -> skip detection (docs-only PRs skip tests) -# test -> build & test matrix (legacy core test project) -# io-test -> Magicodes.IE.IO test matrix (Linux/Windows/macOS, net8.0/net10.0) -# io-test-macos-debug -> macOS arm64 Debug CRC32 self-check (catches arm64-specific bugs) +# test -> legacy core test matrix +# io-test -> IO test matrix (all platforms, net8.0+net10.0) +# io-test-macos-debug -> macOS arm64 Debug CRC32 self-check # check -> alls-green gate (single required status check) # -# Test runner: -# -parallel none -noshadow (Orleans pattern, prevents file locking) -# --filter Category!=Flaky (MassTransit pattern, exclude flaky tests) -# --logger GitHubActions (MassTransit pattern, native PR annotations) -# --blame-hang/crash-dump (Orleans pattern, post-mortem diagnostics) -# -bl (binary log) (Orleans pattern, build diagnostics) +# Matrix coverage: +# test: 7 OS × [net8.0, net10.0] + net9.0 × 3 + net471/net6.0 (Win) +# io: 3 OS × [net8.0, net10.0] + macOS Debug CRC trap +# Total: ~22 parallel jobs (fail-fast: false) +# +# Test runner conventions: +# --filter Category!=Flaky exclude flaky tests (MassTransit pattern) +# --logger GitHubActions native PR annotations (MassTransit pattern) +# --blame-hang/crash-dump post-mortem diagnostics (Orleans pattern) +# -bl binary log (Orleans pattern) # # Notes: # - macOS: Qt cocoa plugin may crash on process exit in headless CI. -# Tests pass before the crash; warnings are emitted, not failures. -# - net471 / net6.0: Windows only (requires .NET Framework targeting pack). -# - Runner versions pinned where possible for reproducibility -# (Polly, efcore pattern). -latest aliases shift without notice. +# We parse "Test Run Successful"/"Test Run Failed" to distinguish. +# - net471 / net6.0: Windows only. +# - Runner versions pinned where possible (Polly/efcore pattern). ################################################### name: CI @@ -54,9 +52,7 @@ env: jobs: - ################################################### - # SKIP DETECTION - ################################################### + # ── SKIP DETECTION ────────────────────────────── changed: name: Detect changes @@ -64,8 +60,7 @@ jobs: outputs: should_skip: ${{ steps.skip.outputs.should_skip }} steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Check changed files id: filter @@ -90,12 +85,10 @@ jobs: echo "should_skip=false" >> "$GITHUB_OUTPUT" fi - ################################################### - # BUILD & TEST - ################################################### + # ── LEGACY CORE TEST MATRIX ───────────────────── test: - name: ${{ matrix.name }} + name: ${{ matrix.os }} / ${{ matrix.framework }} needs: changed if: needs.changed.outputs.should_skip != 'true' runs-on: ${{ matrix.os }} @@ -103,68 +96,28 @@ jobs: strategy: fail-fast: false matrix: + os: + - ubuntu-22.04 + - ubuntu-24.04 + - ubuntu-latest + - windows-2022 + - windows-latest + - macos-15 + - macos-latest + framework: + - net8.0 + - net10.0 include: - # ---- Linux ---- - - name: "ubuntu-22.04 / net8.0" - os: ubuntu-22.04 - framework: net8.0 - - name: "ubuntu-22.04 / net10.0" - os: ubuntu-22.04 - framework: net10.0 - - name: "ubuntu-24.04 / net8.0" - os: ubuntu-24.04 - framework: net8.0 - - name: "ubuntu-24.04 / net9.0" - os: ubuntu-24.04 - framework: net9.0 - - name: "ubuntu-24.04 / net10.0" - os: ubuntu-24.04 - framework: net10.0 - - name: "ubuntu-latest / net8.0" - os: ubuntu-latest - framework: net8.0 - - name: "ubuntu-latest / net10.0" - os: ubuntu-latest - framework: net10.0 - # ---- Windows ---- - - name: "windows-2022 / net8.0" - os: windows-2022 - framework: net8.0 - - name: "windows / net6.0" - os: windows-latest - framework: net6.0 - - name: "windows / net8.0" - os: windows-latest - framework: net8.0 - - name: "windows / net9.0" - os: windows-latest - framework: net9.0 - - name: "windows / net10.0" - os: windows-latest - framework: net10.0 - - name: "windows / net471" - os: windows-latest - framework: net471 - # ---- macOS (arm64) ---- - - name: "macos-15 / net8.0" - os: macos-15 - framework: net8.0 - - name: "macos-15 / net10.0" - os: macos-15 - framework: net10.0 - - name: "macos / net8.0" - os: macos-latest - framework: net8.0 - - name: "macos / net9.0" - os: macos-latest - framework: net9.0 - - name: "macos / net10.0" - os: macos-latest - framework: net10.0 + # net9.0 STS: one per OS family + - { os: ubuntu-latest, framework: net9.0 } + - { os: windows-latest, framework: net9.0 } + - { os: macos-latest, framework: net9.0 } + # Windows-only legacy frameworks + - { os: windows-latest, framework: net471 } + - { os: windows-latest, framework: net6.0 } steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Setup .NET SDK uses: actions/setup-dotnet@v4 @@ -179,6 +132,8 @@ jobs: Magicodes.IE.sln src/**/*.csproj + # ── OS-specific pre-flight ── + - name: Install native dependencies (Linux) if: runner.os == 'Linux' run: | @@ -198,6 +153,8 @@ jobs: New-Item -Path "D:\Temp" -ItemType Directory -Force | Out-Null "TEMP=D:\Temp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # ── Build & Test ── + - name: Restore run: dotnet restore ${{ env.TEST_PROJECT }} @@ -205,40 +162,11 @@ jobs: run: dotnet build ${{ env.TEST_PROJECT }} -c Release --no-restore -bl - name: Test - if: runner.os == 'Windows' - run: > - dotnet test ${{ env.TEST_PROJECT }} - -c Release --no-build - -f ${{ matrix.framework }} - --filter Category!=Flaky - --blame-hang-timeout 10m - --blame-crash-dump-type full - --blame-hang-dump-type full - --logger "trx;LogFileName=results-${{ matrix.framework }}.trx" - --logger GitHubActions - --results-directory TestResults - - - name: Test (Linux) - if: runner.os == 'Linux' - run: > - dotnet test ${{ env.TEST_PROJECT }} - -c Release --no-build - -f ${{ matrix.framework }} - --filter Category!=Flaky - --blame-hang-timeout 10m - --blame-crash-dump-type full - --blame-hang-dump-type full - --logger "trx;LogFileName=results-${{ matrix.framework }}.trx" - --logger GitHubActions - --results-directory TestResults - - - name: Test (macOS) - if: runner.os == 'macOS' shell: bash run: | - # Qt cocoa plugin may crash on process exit in headless CI. - # All tests pass before the crash; we capture results first. - set +e + # macOS: Qt cocoa plugin may crash on process exit in headless CI. + # We tee the output and check for "Test Run Successful"/"Test Run Failed" + # to distinguish real test failures from Qt cleanup crashes. dotnet test ${{ env.TEST_PROJECT }} \ -c Release --no-build \ -f ${{ matrix.framework }} \ @@ -248,11 +176,16 @@ jobs: --blame-hang-dump-type full \ --logger "trx;LogFileName=results-${{ matrix.framework }}.trx" \ --logger GitHubActions \ - --results-directory TestResults - TEST_EXIT=$? - set -e - if [ $TEST_EXIT -ne 0 ]; then - echo "::warning::Test process exited with $TEST_EXIT (Qt platform plugin cleanup in headless CI)" + --results-directory TestResults \ + 2>&1 | tee /tmp/test-output.log + EXIT=${PIPESTATUS[0]} + if [ $EXIT -ne 0 ]; then + if [ "$RUNNER_OS" = "macOS" ] && grep -q "Test Run Successful\." /tmp/test-output.log; then + echo "::warning::Test process exit $EXIT (Qt cocoa plugin cleanup); all tests passed" + else + echo "::error::Test run failed (exit $EXIT)" + exit 1 + fi fi - name: Upload test results @@ -273,13 +206,7 @@ jobs: if-no-files-found: ignore retention-days: 7 - ################################################### - # IO TESTS (all platforms) - # - # Runs the *dedicated* Magicodes.IE.IO test project across - # Linux / Windows / macOS to guard the ForwardOnlyZipWriter - # CRC32 path + XlsxIoPerfRegressionTests on all architectures. - ################################################### + # ── IO TEST MATRIX ────────────────────────────── io-test: name: IO ${{ matrix.os }} / ${{ matrix.framework }} @@ -295,8 +222,7 @@ jobs: env: IO_TEST_PROJECT: tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Setup .NET SDK uses: actions/setup-dotnet@v4 @@ -320,6 +246,7 @@ jobs: dotnet test ${{ env.IO_TEST_PROJECT }} -c Release --no-build -f ${{ matrix.framework }} + --filter Category!=Flaky --blame-hang-timeout 10m --blame-crash-dump-type full --blame-hang-dump-type full @@ -345,16 +272,12 @@ jobs: if-no-files-found: ignore retention-days: 7 - ################################################### - # IO CRC32 SELF-CHECK (macOS arm64 / Debug) + # ── IO CRC32 SELF-CHECK (macOS arm64 / Debug) ── # - # Built/tested in -c Debug on purpose: ForwardOnlyZipWriter - # contains a #if DEBUG CRC32 self-check ("123456789" => - # 0xCBF43926). A wrong arm64 CRC throws at assembly load, - # failing the whole run immediately — the cheapest, strongest - # platform trap. (Release runtime CRC is still exercised by the - # ZipArchive reopen assertions inside the io-test matrix above.) - ################################################### + # ForwardOnlyZipWriter has an #if DEBUG CRC32 self-check + # ("123456789" => 0xCBF43926). A wrong arm64 CRC throws at + # assembly load. Release CRC is still exercised by ZipArchive + # reopen assertions in the io-test matrix above. io-test-macos-debug: name: IO Debug (macOS arm64 CRC check) @@ -365,14 +288,12 @@ jobs: env: IO_TEST_PROJECT: tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.0.x + dotnet-version: 8.0.x cache: true cache-dependency-path: | Magicodes.IE.sln @@ -391,15 +312,16 @@ jobs: dotnet test ${{ env.IO_TEST_PROJECT }} \ -c Debug --no-build \ -f net8.0 \ + --filter Category!=Flaky \ --blame-hang-timeout 10m \ --blame-crash-dump-type full \ --blame-hang-dump-type full \ --logger "trx;LogFileName=results-io-macos-debug.trx" \ --logger GitHubActions \ --results-directory TestResults - TEST_EXIT=$? + EXIT=$? set -e - if [ $TEST_EXIT -ne 0 ]; then + if [ $EXIT -ne 0 ]; then echo "::error::IO tests failed on macOS arm64 (Debug CRC self-check active)" exit 1 fi @@ -422,11 +344,7 @@ jobs: if-no-files-found: ignore retention-days: 7 - ################################################### - # ALLS-GREEN GATE - # Single required status check for branch protection. - # Add "check" as the only required check in repo settings. - ################################################### + # ── ALLS-GREEN GATE ───────────────────────────── check: name: CI Passed @@ -434,8 +352,7 @@ jobs: needs: [changed, test, io-test, io-test-macos-debug] runs-on: ubuntu-latest steps: - - name: Evaluate results - uses: re-actors/alls-green@release/v1 + - uses: re-actors/alls-green@release/v1 with: allowed-skips: >- ${{ From b0f1cbbddab5d66e826cdd05cab45a249ba3e99d Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 07:41:33 +0800 Subject: [PATCH 05/14] =?UTF-8?q?ci:=20IO=20=E7=9F=A9=E9=98=B5=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=20net6.0(=E4=B8=89=E5=B9=B3=E5=8F=B0)=20=E5=92=8C=20n?= =?UTF-8?q?et471(Windows)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnetcore.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index d5147b11..b910fc29 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -10,8 +10,8 @@ # # Matrix coverage: # test: 7 OS × [net8.0, net10.0] + net9.0 × 3 + net471/net6.0 (Win) -# io: 3 OS × [net8.0, net10.0] + macOS Debug CRC trap -# Total: ~22 parallel jobs (fail-fast: false) +# io: 3 OS × [net8.0, net10.0] + net6.0 × 3 + net471 (Win) + macOS Debug CRC trap +# Total: ~30 parallel jobs (fail-fast: false) # # Test runner conventions: # --filter Category!=Flaky exclude flaky tests (MassTransit pattern) @@ -219,6 +219,11 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] framework: [net8.0, net10.0] + include: + - { os: ubuntu-latest, framework: net6.0 } + - { os: windows-latest, framework: net6.0 } + - { os: macos-latest, framework: net6.0 } + - { os: windows-latest, framework: net471 } env: IO_TEST_PROJECT: tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj steps: @@ -228,6 +233,7 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | + 6.0.x 8.0.x 10.0.x cache: true From 0cc74c46a59e0c31d9391644e9c1fe4879ab4be0 Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 07:47:36 +0800 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20IO=20=E6=B5=8B=E8=AF=95=20net471?= =?UTF-8?q?=20=E5=85=BC=E5=AE=B9=EF=BC=9A=E6=B7=BB=E5=8A=A0=20System.Memor?= =?UTF-8?q?y=EF=BC=8C=E4=BF=AE=E5=A4=8D=20File.ReadAllBytesAsync/WriteAllB?= =?UTF-8?q?ytesAsync/Write/MemoryStream.Write/string.Replace=20API?= =?UTF-8?q?=EF=BC=8CNuGet=20=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=20#if=20N?= =?UTF-8?q?ET=20=E5=8C=85=E8=A3=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Magicodes.IE.IO.Tests.csproj | 1 + .../NuGetConsumerIntegrationTests.cs | 7 ++++--- tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs | 14 +++++++++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj index 6b1423b3..e88c111d 100644 --- a/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj +++ b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/Magicodes.IE.IO.Tests/NuGetConsumerIntegrationTests.cs b/tests/Magicodes.IE.IO.Tests/NuGetConsumerIntegrationTests.cs index 64d1dc87..bd13c442 100644 --- a/tests/Magicodes.IE.IO.Tests/NuGetConsumerIntegrationTests.cs +++ b/tests/Magicodes.IE.IO.Tests/NuGetConsumerIntegrationTests.cs @@ -3,11 +3,11 @@ using System.IO; using System.IO.Compression; using System.Linq; -using System.Runtime.InteropServices; using System.Threading.Tasks; using Magicodes.IE.IO; using Xunit; +#if NET namespace Magicodes.IE.IO.Tests; public sealed class NuGetConsumerIntegrationTests @@ -46,7 +46,7 @@ private static void RunPackedConsumer(bool publishAot) var publishProperties = publishAot ? "truetrue" : string.Empty; File.WriteAllText(Path.Combine(consumer, "Consumer.csproj"), $"Exenet8.0enable{publishProperties}"); File.WriteAllText(Path.Combine(consumer, "Program.cs"), "using System.IO; using System.Linq; using Magicodes.IE.IO; namespace Consumer; public sealed class MoneyConverter : CellConverter { public override bool Read(string cell, out decimal value) => decimal.TryParse(cell, out value); } [XlsxExportable] public sealed class ConsumerRow { public string Name { get; set; } = \"\"; public decimal Amount { get; set; } } public static class Program { public static void Main() { var metadata = XlsxGeneratedTypeMetadataRegistry.TryGet(); if (metadata is null) throw new System.Exception(\"metadata:null\"); using var ms = new MemoryStream(); Xlsx.Write(ms, new[] { new ConsumerRow { Name = \"ok\", Amount = 12.5m } }); ms.Position = 0; var options = new XlsxReadOptions().WithConverter(new MoneyConverter()); var row = Xlsx.Read(ms, options).Single(); if (row.Name != \"ok\" || row.Amount != 12.5m) throw new System.Exception($\"{row.Name}|{row.Amount}\"); } }"); - var rid = RuntimeInformation.RuntimeIdentifier; + var rid = System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier; Run(dotnet, consumer, $"restore --no-cache --configfile NuGet.config{(publishAot ? $" -r {rid}" : string.Empty)} -v:minimal"); if (!publishAot) { @@ -58,7 +58,7 @@ private static void RunPackedConsumer(bool publishAot) var publishRoot = Path.Combine(consumer, "bin", "Release", "net8.0", rid, "publish"); var executable = Directory.GetFiles(publishRoot, "*", SearchOption.TopDirectoryOnly) .SingleOrDefault(path => - string.Equals(Path.GetFileName(path), OperatingSystem.IsWindows() ? "Consumer.exe" : "Consumer", StringComparison.Ordinal)); + string.Equals(Path.GetFileName(path), System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows) ? "Consumer.exe" : "Consumer", StringComparison.Ordinal)); if (executable is null) throw new FileNotFoundException($"NativeAOT executable was not found under {publishRoot}. Files: {string.Join(", ", Directory.Exists(publishRoot) ? Directory.GetFiles(publishRoot, "*", SearchOption.AllDirectories) : Array.Empty())}"); Run(executable, consumer, string.Empty); @@ -94,3 +94,4 @@ private static string FindRepositoryRoot() return directory?.FullName ?? throw new DirectoryNotFoundException("Magicodes.IE repository root not found."); } } +#endif diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs index 90adfe44..07180edc 100644 --- a/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs @@ -25,7 +25,11 @@ public async Task SaveAsync_PlainDto_WritesFile() { Xlsx.Write(path, new[] { new OrderDto { OrderNo = "A1", Amount = 10m, CreatedAt = new DateTime(2024, 1, 1) } }); File.Exists(path).ShouldBeTrue(); +#if NET6_0_OR_GREATER var bytes = await File.ReadAllBytesAsync(path); +#else + var bytes = File.ReadAllBytes(path); +#endif var rows = XlsxIO_TestSupport.ReadSheet(bytes); rows.Count.ShouldBe(2); rows[1][0].ShouldBe("A1"); @@ -415,7 +419,7 @@ public async Task WriteMultiSheetAsync_Stream_TwoSheets_BothWritten() var bytes = Xlsx.WriteWorkbookToBytes( new Sheet("First", new[] { new OrderDto { OrderNo = "A" } }), new Sheet("Second", new[] { new OrderDto { OrderNo = "B" } })); - ms.Write(bytes); + ms.Write(bytes, 0, bytes.Length); var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/workbook.xml"); xml.ShouldContain("First"); xml.ShouldContain("Second"); @@ -430,7 +434,11 @@ public async Task SaveMultiSheetAsync_Path_TwoSheets_BothWritten() var bytes = Xlsx.WriteWorkbookToBytes( new Sheet("First", new[] { new OrderDto { OrderNo = "A" } }), new Sheet("Second", new[] { new OrderDto { OrderNo = "B" } })); +#if NET6_0_OR_GREATER await File.WriteAllBytesAsync(path, bytes); +#else + File.WriteAllBytes(path, bytes); +#endif var xml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/workbook.xml"); xml.ShouldContain("First"); xml.ShouldContain("Second"); @@ -621,7 +629,7 @@ public async Task WriteMultiSheetAsync_Stream_WritesValidWorkbook() var bytes = Xlsx.WriteWorkbookToBytes( new Sheet("S1", new[] { new OrderDto { OrderNo = "A" } }), new Sheet("S2", Array.Empty())); - ms.Write(bytes); + ms.Write(bytes, 0, bytes.Length); var workbookXml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/workbook.xml"); workbookXml.ShouldContain("S1"); workbookXml.ShouldContain("S2"); @@ -654,7 +662,7 @@ async IAsyncEnumerable Data() await Xlsx.WriteAsync(ms, Data(), p => p.Sheet("Orders")); var bytes = ms.ToArray(); var workbookXml = XlsxIO_TestSupport.ReadEntry(bytes, "xl/workbook.xml"); - var sheetTagCount = (workbookXml.Length - workbookXml.Replace(" Date: Sun, 12 Jul 2026 08:53:59 +0800 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20IO=20=E6=B5=8B=E8=AF=95=20net471?= =?UTF-8?q?=20=E5=85=BC=E5=AE=B9(=E7=BB=AD)=EF=BC=9A=E4=BF=AE=E5=A4=8D=20I?= =?UTF-8?q?ndexOf/EndsWith/Replace/StringComparison=E3=80=81Split/StringSp?= =?UTF-8?q?litOptions=E3=80=81await=20using(IAsyncDisposable)=EF=BC=8C?= =?UTF-8?q?=E8=A1=A5=20System.Runtime.CompilerServices.Unsafe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 1 + tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj | 1 + tests/Magicodes.IE.IO.Tests/XlsxIO_EdgeCases_Tests.cs | 4 ++-- tests/Magicodes.IE.IO.Tests/XlsxIO_Features_Tests.cs | 8 +++++--- tests/Magicodes.IE.IO.Tests/XlsxIO_Styles_Tests.cs | 2 +- tests/Magicodes.IE.IO.Tests/XlsxIO_TestSupport.cs | 8 ++++---- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index c3403a3f..66dbbcfe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,7 @@ + + diff --git a/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj index 20d26597..ff891a1b 100644 --- a/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj +++ b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_S0_Guardrails_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_S0_Guardrails_Tests.cs index a8800d0f..4434b157 100644 --- a/tests/Magicodes.IE.IO.Tests/XlsxIO_S0_Guardrails_Tests.cs +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_S0_Guardrails_Tests.cs @@ -32,9 +32,9 @@ public static IEnumerable ColumnLetterCases() [MemberData(nameof(ColumnLetterCases))] public void ColumnLetter_KnownColumns_MatchExpected(int col0, string expected) { - Span dest = stackalloc byte[4]; + byte[] dest = new byte[4]; int n = XlsxWriter.ColumnLetter(col0, dest); - var actual = System.Text.Encoding.ASCII.GetString(dest.Slice(0, n)); + var actual = System.Text.Encoding.ASCII.GetString(dest, 0, n); actual.ShouldBe(expected, $"col0={col0}"); } @@ -53,13 +53,13 @@ string Ref(int col0) } return s; } - Span dest = stackalloc byte[4]; + byte[] dest = new byte[4]; var rnd = new Random(20240707); for (int i = 0; i < 200; i++) { int col0 = rnd.Next(0, 16384); int n = XlsxWriter.ColumnLetter(col0, dest); - var actual = System.Text.Encoding.ASCII.GetString(dest.Slice(0, n)); + var actual = System.Text.Encoding.ASCII.GetString(dest, 0, n); actual.ShouldBe(Ref(col0), $"col0={col0}"); } } From b5d1c8c7242ce2825354e71fa209660758a038e3 Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 11:11:40 +0800 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20net471=20=E8=A1=A5=20ArrayBufferWr?= =?UTF-8?q?iter=20polyfill(=E5=AE=9E=E7=8E=B0=20System.Memory=20?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E7=9A=84=20IBufferWriter)=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=97=A0=E7=94=A8=E7=9A=84=20System.Buffers?= =?UTF-8?q?=20=E5=8C=85=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 1 - .../ArrayBufferWriterPolyfill.cs | 73 +++++++++++++++++++ .../Magicodes.IE.IO.Tests.csproj | 1 - 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/Magicodes.IE.IO.Tests/ArrayBufferWriterPolyfill.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d6342867..66dbbcfe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,6 @@ - diff --git a/tests/Magicodes.IE.IO.Tests/ArrayBufferWriterPolyfill.cs b/tests/Magicodes.IE.IO.Tests/ArrayBufferWriterPolyfill.cs new file mode 100644 index 00000000..3b567568 --- /dev/null +++ b/tests/Magicodes.IE.IO.Tests/ArrayBufferWriterPolyfill.cs @@ -0,0 +1,73 @@ +// net471 does not ship ArrayBufferWriter (it was added in .NET Core 3.0 / netstandard2.1). +// The BCL IBufferWriter interface IS available via the System.Memory package, so we provide a +// minimal ArrayBufferWriter implementation for the .NET Framework target only. On .NET Core / +// .NET 5+ the real System.Buffers.ArrayBufferWriter is used instead. +#if NETFRAMEWORK +namespace System.Buffers +{ + using System; + + internal sealed class ArrayBufferWriter : IBufferWriter + { + private T[] _buffer = Array.Empty(); + private int _count; + + public ArrayBufferWriter() + { + } + + public ArrayBufferWriter(int initialCapacity) + { + if (initialCapacity > 0) + _buffer = new T[initialCapacity]; + } + + public ReadOnlyMemory WrittenMemory => _buffer.AsMemory(0, _count); + + public ReadOnlySpan WrittenSpan => _buffer.AsSpan(0, _count); + + public int WrittenCount => _count; + + public void Clear() => _count = 0; + + public void Write(ReadOnlySpan data) + { + if (_count + data.Length > _buffer.Length) + Resize(_count + data.Length); + data.CopyTo(_buffer.AsSpan(_count)); + _count += data.Length; + } + + public void Write(T[] data, int index, int count) => Write(data.AsSpan(index, count)); + + public void Advance(int count) => _count += count; + + public Memory GetMemory(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsMemory(_count); + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsSpan(_count); + } + + private void EnsureCapacity(int sizeHint) + { + int needed = _count + Math.Max(sizeHint, 1); + if (needed > _buffer.Length) + Resize(needed); + } + + private void Resize(int minimum) + { + int newSize = _buffer.Length == 0 ? 256 : _buffer.Length * 2; + if (newSize < minimum) + newSize = minimum; + Array.Resize(ref _buffer, newSize); + } + } +} +#endif diff --git a/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj index ff891a1b..20d26597 100644 --- a/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj +++ b/tests/Magicodes.IE.IO.Tests/Magicodes.IE.IO.Tests.csproj @@ -28,7 +28,6 @@ - From 19d9bf0dcc9691c7893e74f8c956448c5a1056bf Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 11:34:53 +0800 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20IO=20=E6=B5=8B=E8=AF=95=E8=B7=A8?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E7=A8=B3=E5=AE=9A=E6=80=A7=20-=20net471=20do?= =?UTF-8?q?uble/decimal=20=E6=A0=BC=E5=BC=8F=E5=B7=AE=E5=BC=82=E6=94=B9?= =?UTF-8?q?=E6=95=B0=E5=80=BC=E5=AE=B9=E5=B7=AE=EF=BC=9Bnet471=20=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=86=99=E5=A4=B1=E8=B4=A5=E6=B3=A8=E5=85=A5=E8=B7=B3?= =?UTF-8?q?=E8=BF=87(=E5=B9=B3=E5=8F=B0=E9=99=90=E5=88=B6)=EF=BC=9BMiniExc?= =?UTF-8?q?el=20=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E9=94=81=E5=8A=A0?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E4=B8=94=20File.OpenRead=20=E5=8A=A0=20using?= =?UTF-8?q?=20=E9=98=B2=E5=8F=A5=E6=9F=84=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../XlsxIO_EdgeCases_Tests.cs | 4 ++ .../XlsxIO_Features_Tests.cs | 7 ++- .../XlsxIO_Reader_Tests.cs | 44 +++++++++++++++---- .../XlsxIoPerfRegressionTests.cs | 4 +- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_EdgeCases_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_EdgeCases_Tests.cs index 41d2ecdb..48808ea8 100644 --- a/tests/Magicodes.IE.IO.Tests/XlsxIO_EdgeCases_Tests.cs +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_EdgeCases_Tests.cs @@ -198,7 +198,11 @@ async Task Async() list[99].Name.ShouldBe("Row99"); } +#if NETFRAMEWORK + [Fact(Skip = "net471 的 ZipArchive/DeflateStream 异步写实现不经由覆盖的 Stream.WriteAsync,无法注入异步写失败;该失败路径在 net6+ 已验证")] +#else [Fact] +#endif public async Task CompleteAsync_FailureIsFaultedAndCannotResume() { using var output = new XlsxIO_TestSupport.ThrowOnAsyncWriteStream(); diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Features_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Features_Tests.cs index 5b856c09..5f869a92 100644 --- a/tests/Magicodes.IE.IO.Tests/XlsxIO_Features_Tests.cs +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Features_Tests.cs @@ -375,7 +375,12 @@ public async Task SetNextRowHeight_EmitsHtAttrOnNextRow() writer.WriteRows(new[] { new XlsxIO_TestSupport.Order { A = 1 } }, XlsxIO_TestSupport.MakeTypedPlan(new[] { "A" })); } var xml = XlsxIO_TestSupport.ReadEntry(ms.ToArray(), "xl/worksheets/sheet1.xml"); - xml.ShouldContain("ht=\"24.974999999999998\""); + // Double formatting differs across runtimes (net471 G15 vs net6+ shortest round-trip), + // so compare the parsed numeric value with a tolerance instead of an exact string. + var htMatch = System.Text.RegularExpressions.Regex.Match(xml, "ht=\"([^\"]*)\""); + htMatch.Success.ShouldBeTrue(); + double.TryParse(htMatch.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var ht).ShouldBeTrue(); + ht.ShouldBe(24.975, 1e-6); xml.ShouldContain("customHeight=\"1\""); } diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs index dd403285..02ed45ed 100644 --- a/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs @@ -357,21 +357,49 @@ public void XlsxReader_UsesWorkbookRelationshipOrder_NotHardcodedSheet1Xml() [Fact] public async Task Read_XlsxWrittenByMiniExcel_RoundTrips() { + // On Windows, Defender / the Indexing Service may briefly lock a freshly written .xlsx + // temp file; retry the file-touching operations to stay resilient against that flakiness. var path = Path.Combine(Path.GetTempPath(), $"miniexcel_{Guid.NewGuid():N}.xlsx"); try { - MiniExcel.SaveAs(path, new[] + const int maxAttempts = 3; + bool verified = false; + for (int attempt = 1; attempt <= maxAttempts && !verified; attempt++) { - new { OrderNo = "M1", Amount = 10 }, - new { OrderNo = "M2", Amount = 20 }, - }); - var items = Xlsx.Read(File.OpenRead(path)).ToList(); - items.Count.ShouldBe(2); - items[0].OrderNo.ShouldBe("M1"); + try + { + MiniExcel.SaveAs(path, new[] + { + new { OrderNo = "M1", Amount = 10 }, + new { OrderNo = "M2", Amount = 20 }, + }); + using var fs = File.OpenRead(path); + var items = Xlsx.Read(fs).ToList(); + items.Count.ShouldBe(2); + items[0].OrderNo.ShouldBe("M1"); + verified = true; + } + catch (IOException) when (attempt < maxAttempts) + { + await Task.Delay(150); + } + } + verified.ShouldBeTrue("MiniExcel round-trip could not be verified (file locked by an external process)"); } finally { - if (File.Exists(path)) File.Delete(path); + for (int i = 0; i < 3; i++) + { + try + { + if (File.Exists(path)) File.Delete(path); + break; + } + catch (IOException) when (i < 2) + { + await Task.Delay(150); + } + } } } diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIoPerfRegressionTests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIoPerfRegressionTests.cs index 3538c33c..e3d224b9 100644 --- a/tests/Magicodes.IE.IO.Tests/XlsxIoPerfRegressionTests.cs +++ b/tests/Magicodes.IE.IO.Tests/XlsxIoPerfRegressionTests.cs @@ -52,7 +52,9 @@ public void ToBytes_RoundTripsViaRead() var read = Xlsx.Read(ms).ToList(); read.Count.ShouldBe(2); read[0].OrderNo.ShouldBe("RT-1"); - read[0].Amount.ShouldBe(9.99m); + // Decimal is stored as an OOXML double; net471 round-trips with a tiny representation + // error (9.9900000000000002m) while net6+ returns 9.99m. Compare numerically. + ((double)read[0].Amount).ShouldBe(9.99, 1e-9); read[1].OrderNo.ShouldBe("RT-2"); read[1].Amount.ShouldBe(0m); } From 1601ec9ed5e63c9e639168b08436737d44b3374a Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 13:29:03 +0800 Subject: [PATCH 11/14] chore: trigger CI From 48903ea2f3da15f03000200c08203ed3589ea741 Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 13:49:19 +0800 Subject: [PATCH 12/14] =?UTF-8?q?fix(IO):=20XlsxReader=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20leaveOpen=EF=BC=9BXlsx.Read(Stream)=20=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E9=87=8A=E6=94=BE=E4=BC=A0=E5=85=A5=E6=B5=81(?= =?UTF-8?q?=E4=B8=8E=E6=96=87=E6=A1=A3=E4=B8=80=E8=87=B4)=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AF=B9=E7=A7=B0=E7=9A=84=20Read(string)/Re?= =?UTF-8?q?adAsync(string)=20=E8=B7=AF=E5=BE=84=E9=87=8D=E8=BD=BD=E5=B9=B6?= =?UTF-8?q?=E6=8B=A5=E6=9C=89=E9=87=8A=E6=94=BE=20FileStream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Magicodes.IE.IO/Engine/XlsxReader.cs | 9 +++- src/Magicodes.IE.IO/Xlsx.cs | 30 +++++++++-- .../XlsxIO_Reader_Tests.cs | 50 +++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/Magicodes.IE.IO/Engine/XlsxReader.cs b/src/Magicodes.IE.IO/Engine/XlsxReader.cs index 97bf7b83..55efbb44 100644 --- a/src/Magicodes.IE.IO/Engine/XlsxReader.cs +++ b/src/Magicodes.IE.IO/Engine/XlsxReader.cs @@ -28,6 +28,8 @@ public sealed class XlsxReader : IDisposable private readonly ZipArchive _zip; private readonly Stream _sheetStream; private readonly XmlReader _xml; + private readonly Stream? _xlsxStream; + private readonly bool _leaveOpen; private readonly List _currentRow = new(16); private readonly bool _date1904; private bool _headerRead; @@ -37,9 +39,11 @@ public sealed class XlsxReader : IDisposable /// /// Opens a .xlsx workbook positioned at the first readable worksheet. /// - public XlsxReader(Stream xlsxStream) + public XlsxReader(Stream xlsxStream, bool leaveOpen = true) { if (xlsxStream is null) throw new ArgumentNullException(nameof(xlsxStream)); + _leaveOpen = leaveOpen; + _xlsxStream = xlsxStream; var zip = new ZipArchive(xlsxStream, ZipArchiveMode.Read, leaveOpen: true); Stream? sheetStream = null; XmlReader? xml = null; @@ -68,6 +72,7 @@ public XlsxReader(Stream xlsxStream) xml?.Dispose(); sheetStream?.Dispose(); zip.Dispose(); + if (!_leaveOpen) _xlsxStream?.Dispose(); throw; } } @@ -525,7 +530,7 @@ private string ReadInlineStringValue() public void Dispose() { - try { _xml.Dispose(); } finally { try { _sheetStream.Dispose(); } finally { _zip.Dispose(); } } + try { _xml.Dispose(); } finally { try { _sheetStream.Dispose(); } finally { try { _zip.Dispose(); } finally { if (!_leaveOpen) _xlsxStream?.Dispose(); } } } } } diff --git a/src/Magicodes.IE.IO/Xlsx.cs b/src/Magicodes.IE.IO/Xlsx.cs index 059cbb8c..8c9e5533 100644 --- a/src/Magicodes.IE.IO/Xlsx.cs +++ b/src/Magicodes.IE.IO/Xlsx.cs @@ -16,7 +16,7 @@ namespace Magicodes.IE.IO /// Import: use the overloads to stream deserialized rows from a workbook. /// Multi-sheet: . Template export: . /// When no is supplied, headers and formats are inferred from property names and from [Display], [Description], and [DisplayFormat] attributes. The underlying writer/reader is a dependency-free, streaming, low-allocation OOXML implementation and does not depend on EPPlus. - /// Lifetime: overloads that take a path own and dispose the underlying ; overloads that take a or do not, and the caller is responsible for disposal. + /// Lifetime: overloads that take a path own and dispose the underlying ; overloads that take a or do not, and the caller is responsible for disposal. Note: the Read/ReadAsync stream overloads DO own and dispose the supplied stream when enumeration completes. /// public static class Xlsx { @@ -207,7 +207,7 @@ private static int EstimateToBytesCapacity(IEnumerable data) /// When a source-generated reader is available for , it is used for reflection-free reading; otherwise reflection is used. public static IEnumerable Read(Stream stream, XlsxReadOptions? profile = null, Action? onParseError = null) where T : new() { - using var reader = new XlsxReader(stream); + using var reader = new XlsxReader(stream, leaveOpen: false); var headers = reader.ReadHeader(); var converters = profile?.GetConverters(); int rowIndex = 0; @@ -253,6 +253,18 @@ private static int EstimateToBytesCapacity(IEnumerable data) } } + /// + /// Reads the first worksheet of the .xlsx file at and lazily returns the deserialized rows as . + /// + /// This overload owns and disposes the underlying ; the file is read only while the returned sequence is enumerated. + public static IEnumerable Read(string path, XlsxReadOptions? profile = null, Action? onParseError = null) where T : new() + { + ValidatePath(path); + using var fs = File.OpenRead(path); + foreach (var item in Read(fs, profile, onParseError)) + yield return item; + } + /// /// Reads the first worksheet of a .xlsx workbook asynchronously and returns the deserialized rows as . /// @@ -261,7 +273,7 @@ private static int EstimateToBytesCapacity(IEnumerable data) public static async IAsyncEnumerable ReadAsync(Stream stream, XlsxReadOptions? profile = null, Action? onParseError = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : new() { cancellationToken.ThrowIfCancellationRequested(); - using var reader = new XlsxReader(stream); + using var reader = new XlsxReader(stream, leaveOpen: false); var headers = await reader.ReadHeaderAsync(cancellationToken).ConfigureAwait(false); var converters = profile?.GetConverters(); int rowIndex = 0; @@ -310,6 +322,18 @@ private static int EstimateToBytesCapacity(IEnumerable data) cancellationToken.ThrowIfCancellationRequested(); } + /// + /// Asynchronously reads the first worksheet of the .xlsx file at and lazily returns the deserialized rows as . + /// + /// This overload owns and disposes the underlying ; the file is read only while the returned sequence is enumerated. + public static async IAsyncEnumerable ReadAsync(string path, XlsxReadOptions? profile = null, Action? onParseError = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : new() + { + ValidatePath(path); + using var fs = File.OpenRead(path); + await foreach (var item in ReadAsync(fs, profile, onParseError, cancellationToken).ConfigureAwait(false)) + yield return item; + } + /// /// Writes multiple sheets to a stream. At least one non- sheet must be supplied. /// diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs index 02ed45ed..0b5a40c2 100644 --- a/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs @@ -49,6 +49,56 @@ public async Task XlsxRead_RoundTrip_PreservesData() } } + [Fact] + public void XlsxRead_PathOverload_OwnsAndDisposesFileStream() + { + var path = Path.Combine(Path.GetTempPath(), $"io_read_path_{Guid.NewGuid():N}.xlsx"); + try + { + Xlsx.Write(path, new[] + { + new OrderDto { OrderNo = "P1", Amount = 11m }, + new OrderDto { OrderNo = "P2", Amount = 22m }, + }); + + var list = Xlsx.Read(path).ToList(); + list.Count.ShouldBe(2); + list[0].OrderNo.ShouldBe("P1"); + list[0].Amount.ShouldBe(11m); + list[1].OrderNo.ShouldBe("P2"); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public async Task XlsxReadAsync_PathOverload_OwnsAndDisposesFileStream() + { + var path = Path.Combine(Path.GetTempPath(), $"io_read_path_async_{Guid.NewGuid():N}.xlsx"); + try + { + Xlsx.Write(path, new[] + { + new OrderDto { OrderNo = "PA1", Amount = 33m }, + new OrderDto { OrderNo = "PA2", Amount = 44m }, + }); + + var list = new List(); + await foreach (var o in Xlsx.ReadAsync(path)) + list.Add(o); + list.Count.ShouldBe(2); + list[0].OrderNo.ShouldBe("PA1"); + list[0].Amount.ShouldBe(33m); + list[1].OrderNo.ShouldBe("PA2"); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + [Fact] public async Task XlsxRead_AsyncStreaming() { From 32bb7e545905d0da81e1b60703bc04d800859c7e Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 14:59:28 +0800 Subject: [PATCH 13/14] feat(io): expose leaveOpen on Read/ReadAsync and WriteAsync(path) overload; fail fast on ZIP32 4GB limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Xlsx.Read/ReadAsync 增加 leaveOpen 参数,默认释放流,可选保留\n- 新增 Xlsx.WriteAsync(path) 便利重载\n- ForwardOnlyZipWriter 在超过 ZIP32(4GB)上限时提前抛出清晰异常\n- Directory.Build.props NoWarn 加入 NU5119,使 TreatWarningsAsErrors 下打包成功\n- 补充 WriteAsync(path)/leaveOpen 保留与默认释放的单元测试 --- Directory.Build.props | 2 +- src/Magicodes.IE.IO/Engine/XlsxReader.cs | 2 +- .../Internal/ForwardOnlyZipWriter.cs | 17 ++++- src/Magicodes.IE.IO/Magicodes.IE.IO.csproj | 2 +- src/Magicodes.IE.IO/Xlsx.cs | 73 +++++++++++++++++-- .../XlsxIO_Reader_Tests.cs | 57 +++++++++++++++ 6 files changed, 140 insertions(+), 13 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index d10a6177..2f86edb5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -33,7 +33,7 @@ 2.9.0 true snupkg - 1701;1702;CS1591;CS1573;1591;NU1507 + 1701;1702;CS1591;CS1573;1591;NU1507;NU5119 false direct diff --git a/src/Magicodes.IE.IO/Engine/XlsxReader.cs b/src/Magicodes.IE.IO/Engine/XlsxReader.cs index 55efbb44..2e8e81df 100644 --- a/src/Magicodes.IE.IO/Engine/XlsxReader.cs +++ b/src/Magicodes.IE.IO/Engine/XlsxReader.cs @@ -20,7 +20,7 @@ namespace Magicodes.IE.IO /// /// Advanced streaming reader for xlsx workbooks. - /// For ordinary reads, use . + /// For ordinary reads, use . /// [EditorBrowsable(EditorBrowsableState.Never)] public sealed class XlsxReader : IDisposable diff --git a/src/Magicodes.IE.IO/Internal/ForwardOnlyZipWriter.cs b/src/Magicodes.IE.IO/Internal/ForwardOnlyZipWriter.cs index d376c61d..3f84a1f0 100644 --- a/src/Magicodes.IE.IO/Internal/ForwardOnlyZipWriter.cs +++ b/src/Magicodes.IE.IO/Internal/ForwardOnlyZipWriter.cs @@ -119,6 +119,7 @@ internal static ValueTask DisposeEntryStreamAsync(Stream entryStream) internal void WriteRaw(byte[] buffer, int offset, int count) { if (count == 0) return; + if (_position + count > uint.MaxValue) ThrowZip32LimitExceeded(); #if NETSTANDARD2_0 _output.Write(buffer, offset, count); #else @@ -130,6 +131,7 @@ internal void WriteRaw(byte[] buffer, int offset, int count) internal async Task WriteRawAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { if (count == 0) return; + if (_position + count > uint.MaxValue) ThrowZip32LimitExceeded(); #if NETSTANDARD2_0 await _output.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); _position += count; @@ -143,6 +145,7 @@ internal async Task WriteRawAsync(byte[] buffer, int offset, int count, Cancella internal ValueTask WriteRawAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) { if (buffer.Length == 0) return default; + if (_position + buffer.Length > uint.MaxValue) ThrowZip32LimitExceeded(); var vt = _output.WriteAsync(buffer, cancellationToken); if (vt.IsCompletedSuccessfully) { @@ -461,10 +464,18 @@ private static Utf8NameBuffer RentUtf8Name(string name) internal static uint CheckedToUInt32(long value) { - if ((ulong)value > uint.MaxValue) throw new NotSupportedException("ZIP64 is not supported by this forward-only zip writer."); + if ((ulong)value > uint.MaxValue) ThrowZip32LimitExceeded(); return (uint)value; } + private static void ThrowZip32LimitExceeded() + { + throw new NotSupportedException( + "The output exceeds the 4 GB (ZIP32) size limit supported by this forward-only zip writer. " + + "Magicodes.IE.IO does not emit ZIP64 archives. To write workbooks larger than 4 GB, " + + "split the data across multiple files or sheets, or write to a consumer that supports ZIP64."); + } + private void EnsureNotDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(ForwardOnlyZipWriter)); @@ -1004,7 +1015,8 @@ public static DosDateTime From(DateTime value) private static readonly uint[] Crc32Table = BuildCrc32Table(); -#if DEBUG + // Self-check the CRC-32 implementation at type load. This guards the SIMD/intrinsic path + // (active on ARM64 in Release builds) so a wrong CRC never silently corrupts every xlsx. static ForwardOnlyZipWriter() { var data = System.Text.Encoding.ASCII.GetBytes("123456789"); @@ -1020,7 +1032,6 @@ static ForwardOnlyZipWriter() if (sliced.Result != expected) throw new InvalidOperationException($"SlicingBy8Crc32 self-test failed: {sliced.Result:X8}"); } -#endif internal interface ICrc32 { diff --git a/src/Magicodes.IE.IO/Magicodes.IE.IO.csproj b/src/Magicodes.IE.IO/Magicodes.IE.IO.csproj index 5bb1b8ad..a9e5b2f2 100644 --- a/src/Magicodes.IE.IO/Magicodes.IE.IO.csproj +++ b/src/Magicodes.IE.IO/Magicodes.IE.IO.csproj @@ -4,7 +4,7 @@ netstandard2.0;net6.0;net8.0;net10.0 latest enable - false + true Magicodes.IE.IO High-performance, low-allocation Excel (.xlsx) I/O library for .NET, with streaming write APIs and a simple high-level API for import and export. diff --git a/src/Magicodes.IE.IO/Xlsx.cs b/src/Magicodes.IE.IO/Xlsx.cs index 8c9e5533..06cafa3d 100644 --- a/src/Magicodes.IE.IO/Xlsx.cs +++ b/src/Magicodes.IE.IO/Xlsx.cs @@ -13,7 +13,7 @@ namespace Magicodes.IE.IO /// /// /// Export: use the overloads to write rows to a file, stream, , or array. - /// Import: use the overloads to stream deserialized rows from a workbook. + /// Import: use the overloads to stream deserialized rows from a workbook. /// Multi-sheet: . Template export: . /// When no is supplied, headers and formats are inferred from property names and from [Display], [Description], and [DisplayFormat] attributes. The underlying writer/reader is a dependency-free, streaming, low-allocation OOXML implementation and does not depend on EPPlus. /// Lifetime: overloads that take a path own and dispose the underlying ; overloads that take a or do not, and the caller is responsible for disposal. Note: the Read/ReadAsync stream overloads DO own and dispose the supplied stream when enumeration completes. @@ -161,6 +161,65 @@ public static async Task WriteAsync(Stream output, IEnumerable data, Expor await XlsxWritePipeline.RunAsync(writer, data, profile, cancellationToken).ConfigureAwait(false); } + /// + /// Asynchronously writes the specified rows to the .xlsx file at . + /// + /// Equivalent to opening a and calling the stream overload; the file stream is owned and disposed by this method. + public static async Task WriteAsync(string path, IEnumerable data, Action>? configure = null, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default) + { + ValidatePath(path); + if (data is null) throw new ArgumentNullException(nameof(data)); + var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest; + var strictCellReferences = options?.StrictCellReferences ?? true; + using var fs = File.Create(path); + await using var writer = new XlsxWriter(fs, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences); + await XlsxWritePipeline.RunAsync(writer, data, configure, cancellationToken).ConfigureAwait(false); + } + + /// + /// Asynchronously writes the specified rows to the .xlsx file at using a pre-built profile. + /// + public static async Task WriteAsync(string path, IEnumerable data, ExportProfile profile, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default) + { + ValidatePath(path); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (profile is null) throw new ArgumentNullException(nameof(profile)); + var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest; + var strictCellReferences = options?.StrictCellReferences ?? true; + using var fs = File.Create(path); + await using var writer = new XlsxWriter(fs, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences); + await XlsxWritePipeline.RunAsync(writer, data, profile, cancellationToken).ConfigureAwait(false); + } + + /// + /// Asynchronously streams rows from an to the .xlsx file at . + /// + public static async Task WriteAsync(string path, IAsyncEnumerable data, Action>? configure = null, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default) + { + ValidatePath(path); + if (data is null) throw new ArgumentNullException(nameof(data)); + var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest; + var strictCellReferences = options?.StrictCellReferences ?? true; + using var fs = File.Create(path); + await using var writer = new XlsxWriter(fs, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences); + await XlsxWritePipeline.RunAsync(writer, data, configure, cancellationToken).ConfigureAwait(false); + } + + /// + /// Asynchronously streams rows from an to the .xlsx file at using a pre-built profile. + /// + public static async Task WriteAsync(string path, IAsyncEnumerable data, ExportProfile profile, XlsxWriteOptions? options = null, CancellationToken cancellationToken = default) + { + ValidatePath(path); + if (data is null) throw new ArgumentNullException(nameof(data)); + if (profile is null) throw new ArgumentNullException(nameof(profile)); + var compression = options?.Compression ?? System.IO.Compression.CompressionLevel.Fastest; + var strictCellReferences = options?.StrictCellReferences ?? true; + using var fs = File.Create(path); + await using var writer = new XlsxWriter(fs, sheetName: null, compression, defaultRowHeight: 0, strictCellReferences); + await XlsxWritePipeline.RunAsync(writer, data, profile, cancellationToken).ConfigureAwait(false); + } + /// /// Exports the specified rows to a array. /// @@ -205,9 +264,9 @@ private static int EstimateToBytesCapacity(IEnumerable data) /// Optional callback invoked when a cell cannot be parsed. When omitted, a is thrown on the first parse error. /// A lazy . Each row is parsed on demand as you iterate; do not enumerate the result more than once. /// When a source-generated reader is available for , it is used for reflection-free reading; otherwise reflection is used. - public static IEnumerable Read(Stream stream, XlsxReadOptions? profile = null, Action? onParseError = null) where T : new() + public static IEnumerable Read(Stream stream, XlsxReadOptions? profile = null, Action? onParseError = null, bool leaveOpen = false) where T : new() { - using var reader = new XlsxReader(stream, leaveOpen: false); + using var reader = new XlsxReader(stream, leaveOpen); var headers = reader.ReadHeader(); var converters = profile?.GetConverters(); int rowIndex = 0; @@ -268,12 +327,12 @@ private static int EstimateToBytesCapacity(IEnumerable data) /// /// Reads the first worksheet of a .xlsx workbook asynchronously and returns the deserialized rows as . /// - /// Supports cancellation via . Otherwise the behavior matches . + /// Supports cancellation via . Otherwise the behavior matches . /// A lazy . Do not enumerate the result more than once. - public static async IAsyncEnumerable ReadAsync(Stream stream, XlsxReadOptions? profile = null, Action? onParseError = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : new() + public static async IAsyncEnumerable ReadAsync(Stream stream, XlsxReadOptions? profile = null, Action? onParseError = null, [EnumeratorCancellation] CancellationToken cancellationToken = default, bool leaveOpen = false) where T : new() { cancellationToken.ThrowIfCancellationRequested(); - using var reader = new XlsxReader(stream, leaveOpen: false); + using var reader = new XlsxReader(stream, leaveOpen); var headers = await reader.ReadHeaderAsync(cancellationToken).ConfigureAwait(false); var converters = profile?.GetConverters(); int rowIndex = 0; @@ -330,7 +389,7 @@ private static int EstimateToBytesCapacity(IEnumerable data) { ValidatePath(path); using var fs = File.OpenRead(path); - await foreach (var item in ReadAsync(fs, profile, onParseError, cancellationToken).ConfigureAwait(false)) + await foreach (var item in ReadAsync(fs, profile, onParseError, cancellationToken: cancellationToken).ConfigureAwait(false)) yield return item; } diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs index 0b5a40c2..645db0e8 100644 --- a/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Reader_Tests.cs @@ -792,5 +792,62 @@ private static async IAsyncEnumerable DataToAsync(IEnumerable data, [Sy await Task.Yield(); } } + + [Fact] + public async Task WriteAsync_PathOverload_WritesReadableFile() + { + var path = Path.Combine(Path.GetTempPath(), $"io_writeasync_{Guid.NewGuid():N}.xlsx"); + try + { + var data = new[] + { + new OrderDto { OrderNo = "WA1", Amount = 7m, CreatedAt = new DateTime(2024, 7, 7) }, + new OrderDto { OrderNo = "WA2", Amount = 8m, CreatedAt = new DateTime(2024, 7, 8) }, + }; + await Xlsx.WriteAsync(path, data); + + var list = Xlsx.Read(path).ToList(); + list.Count.ShouldBe(2); + list[0].OrderNo.ShouldBe("WA1"); + list[0].Amount.ShouldBe(7m); + list[1].OrderNo.ShouldBe("WA2"); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void Read_Stream_LeaveOpen_KeepsStreamUsable() + { + var bytes = Xlsx.ToBytes(new[] { new OrderDto { OrderNo = "LO", Amount = 1m } }); + using var ms = new MemoryStream(bytes); + var list = Xlsx.Read(ms, leaveOpen: true).ToList(); + list.Count.ShouldBe(1); + // Stream must still be readable because we asked the reader to leave it open. + ms.Position = 0; + ms.Length.ShouldBe(bytes.Length); + ms.Dispose(); + } + + [Fact] + public void Read_Stream_DefaultDisposesStream() + { + var path = Path.Combine(Path.GetTempPath(), $"io_read_dispose_{Guid.NewGuid():N}.xlsx"); + try + { + Xlsx.Write(path, new[] { new OrderDto { OrderNo = "DX" } }); + var fs = File.OpenRead(path); + var list = Xlsx.Read(fs).ToList(); + list.Count.ShouldBe(1); + // Default behavior disposes the stream; touching it afterwards must fail. + Should.Throw(() => fs.ReadByte()); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } } } From 58bdcfd64af6d60ccc4d436647767965d33e1f61 Mon Sep 17 00:00:00 2001 From: hueifeng Date: Sun, 12 Jul 2026 17:30:33 +0800 Subject: [PATCH 14/14] fix(io): honor RowFilter on async IAsyncEnumerable path when AutoSst is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunAsync 的 AutoSst 探测改用已过滤的 preparedData(原为原始 data),避免 RowFilter 被静默丢弃导致全量导出 - 修正 WriteAsync(IAsyncEnumerable) XML 文档:去掉误导性的 concurrently 措辞 - 新增回归测试 WriteAsync_AsyncEnumerable_AppliesRowFilter_WhenAutoSstEnabled(异步流+AutoSst+RowFilter 叠加) --- .../Engine/XlsxWritePipeline.cs | 3 +- src/Magicodes.IE.IO/Xlsx.cs | 2 +- .../XlsxIO_Core_Tests.cs | 32 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/Magicodes.IE.IO/Engine/XlsxWritePipeline.cs b/src/Magicodes.IE.IO/Engine/XlsxWritePipeline.cs index adca0b22..d0208862 100644 --- a/src/Magicodes.IE.IO/Engine/XlsxWritePipeline.cs +++ b/src/Magicodes.IE.IO/Engine/XlsxWritePipeline.cs @@ -77,7 +77,8 @@ public static async Task RunAsync(XlsxWriter writer, IAsyncEnumerable data bool autoSst = false; if (profile.AutoSst && writer.SupportsAutoSharedStrings) { - var prepared = await AutoSstBufferAndDetectAsync(data, plan, cancellationToken).ConfigureAwait(false); + // Probe the already-filtered sequence so RowFilter is honored on this path. + var prepared = await AutoSstBufferAndDetectAsync(preparedData, plan, cancellationToken).ConfigureAwait(false); preparedData = prepared.Data; autoSst = prepared.Enable; } diff --git a/src/Magicodes.IE.IO/Xlsx.cs b/src/Magicodes.IE.IO/Xlsx.cs index 06cafa3d..4588bba9 100644 --- a/src/Magicodes.IE.IO/Xlsx.cs +++ b/src/Magicodes.IE.IO/Xlsx.cs @@ -102,7 +102,7 @@ public static void Write(IBufferWriter output, IEnumerable data, Exp } /// - /// Streams rows from an to a stream, enumerating and writing concurrently so the data is never fully materialized. + /// Streams rows from an to a stream, writing each row as it is enumerated so the sequence is never fully materialized. /// /// The caller owns and disposes . Use this overload for large or paged data sources (for example, a database query). Unlike the async overloads, the data source itself is asynchronous. /// or is . diff --git a/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs b/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs index 07180edc..7606f7cd 100644 --- a/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs +++ b/tests/Magicodes.IE.IO.Tests/XlsxIO_Core_Tests.cs @@ -601,6 +601,38 @@ async IAsyncEnumerable Data() rows[2][0].ShouldBe("keep-2"); } + [Fact] + public async Task WriteAsync_AsyncEnumerable_AppliesRowFilter_WhenAutoSstEnabled() + { + // Regression: on the async path the AutoSst probe must run over the *filtered* + // sequence, otherwise RowFilter is silently dropped and every row is exported. + // Enough repeated strings so AutoSst turns on (distinct/total < 0.7). + async IAsyncEnumerable Data() + { + for (int i = 0; i < 100; i++) + { + yield return new OrderDto { OrderNo = i % 5 == 0 ? "drop" : "keep" }; + if (i % 17 == 0) await Task.Yield(); + } + } + + using var ms = new MemoryStream(); + await Xlsx.WriteAsync(ms, Data(), new ExportProfile() + .Where(x => x.OrderNo != "drop") + .WithAutoSst()); + + var bytes = ms.ToArray(); + // AutoSst path must actually be taken (shared strings emitted). + using (var za = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read)) + za.GetEntry("xl/sharedStrings.xml").ShouldNotBeNull(); + + // Read back typed rows so shared-string indices are resolved. + var list = Xlsx.Read(new MemoryStream(bytes)).ToList(); + // 100 rows, 20 "drop" (i % 5 == 0) filtered out => 80 kept rows. + list.Count.ShouldBe(80); + list.ShouldAllBe(x => x.OrderNo == "keep"); + } + [Fact] public async Task WriteAsync_Iterator_AppliesRowFilter() {